diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a495954 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,23 @@ +name: Mac checks + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: mac-checks-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + name: impl-swift checks + runs-on: macos-15 + env: + DEVELOPER_DIR: /Applications/Xcode_26.2.app/Contents/Developer + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: 检查格式、lint 与 SwiftPM 测试 + run: bash scripts/check.sh diff --git a/.swift-format b/.swift-format new file mode 100644 index 0000000..f57bc26 --- /dev/null +++ b/.swift-format @@ -0,0 +1,4 @@ +{ + "version": 1, + "indentation": { "spaces": 4 } +} diff --git a/AGENTS.md b/AGENTS.md index 4036584..0615da5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,4 +12,4 @@ Capture 和此次 Mac 分发尚未发布,直接修订候选 schema,不增加 同次提交不得换 UUID、变更材料或改投另一个信息库。只有经过指纹核对的共享回执可以清理本机材料。选区失败不能读旧剪贴板充数;截图只持久化明确选择的区域。 -运行 `swift test` 和 `bash scripts/build-app.sh`。编译不证明系统权限、真实选区、焦点或多屏行为;真实应用验证使用合成内容,按实际结果记录。开发包使用 `dev.inkcre.capture`;发布身份、签名、公证只能使用显式提供的输入。 +运行 `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/Package.swift b/Packages/PeerCore/Package.swift index 0b53b6b..2867f8a 100644 --- a/Packages/PeerCore/Package.swift +++ b/Packages/PeerCore/Package.swift @@ -9,7 +9,9 @@ let package = Package( ], dependencies: [.package(url: "https://github.com/vapor/postgres-nio.git", from: "1.32.0")], targets: [ - .target(name: "PeerCore", dependencies: [.product(name: "PostgresNIO", package: "postgres-nio")]), + .target( + name: "PeerCore", dependencies: [.product(name: "PostgresNIO", package: "postgres-nio")] + ), .target(name: "PeerMacUI", dependencies: ["PeerCore"]), ] ) diff --git a/Packages/PeerCore/Sources/PeerCore/BundledParticipation.swift b/Packages/PeerCore/Sources/PeerCore/BundledParticipation.swift index 54af37f..7e623ee 100644 --- a/Packages/PeerCore/Sources/PeerCore/BundledParticipation.swift +++ b/Packages/PeerCore/Sources/PeerCore/BundledParticipation.swift @@ -2,82 +2,84 @@ import Foundation import PostgresNIO extension PeerDatabase { - /// First joining may create a selection; subsequent reconnects never recreate removed participation. - public func join( - _ target: ConnectionTarget, password: String, 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 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) - } - do { - try await Self.requireSelection(descriptor, peerID: target.peerID, on: connection) - return nil - } catch PeerError.needsAction(let message) { return message } + /// First joining may create a selection; subsequent reconnects never recreate removed participation. + public func join( + _ target: ConnectionTarget, password: String, + 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 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) + } + 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) } } - defaults.set(true, forKey: key) - if let issue { throw PeerError.needsAction(issue) } - } - public static func requireSelection( - _ descriptor: NativeExtensionDescriptor, peerID: UUID, on connection: PostgresConnection - ) async throws { - let rows = try await connection.query( - """ - SELECT distribution,version,enabled FROM inkcre.extension_peer_participations - WHERE name=\(descriptor.name) AND peer_id=\(peerID) FOR SHARE - """, logger: Self.logger) - 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 选择的扩展版本不在当前应用中,请核对扩展选择。") - } - guard enabled else { throw PeerError.needsAction("信息库中的扩展参与已停用;本机材料保留,请在线明确启用后继续。") } - return + public static func requireSelection( + _ descriptor: NativeExtensionDescriptor, peerID: UUID, on connection: PostgresConnection + ) async throws { + let rows = try await connection.query( + """ + SELECT distribution,version,enabled FROM inkcre.extension_peer_participations + WHERE name=\(descriptor.name) AND peer_id=\(peerID) FOR SHARE + """, logger: Self.logger) + 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 选择的扩展版本不在当前应用中,请核对扩展选择。") + } + guard enabled else { throw PeerError.needsAction("信息库中的扩展参与已停用;本机材料保留,请在线明确启用后继续。") } + return + } + throw PeerError.needsAction("此 Peer 的扩展参与已移除;不会在重连时自动重建。") } - throw PeerError.needsAction("此 Peer 的扩展参与已移除;不会在重连时自动重建。") - } - public func setEnabled( - _ enabled: Bool, target: ConnectionTarget, password: String, - extension descriptor: NativeExtensionDescriptor - ) 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 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 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("信息库中没有与本机匹配的扩展选择。") } + public func setEnabled( + _ enabled: Bool, target: ConnectionTarget, password: String, + extension descriptor: NativeExtensionDescriptor + ) 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 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 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)") } - 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 2f5d097..e82f6a3 100644 --- a/Packages/PeerCore/Sources/PeerCore/ConnectionSettings.swift +++ b/Packages/PeerCore/Sources/PeerCore/ConnectionSettings.swift @@ -24,11 +24,13 @@ public actor ConnectionSettings { let web = web.trimmingCharacters(in: .whitespacesAndNewlines) let webURL = web.isEmpty ? nil : URL(string: web) if !web.isEmpty && webURL == nil { throw PeerError.invalid("Web 地址无效。") } - let same = previous?.host == host && previous?.port == port && previous?.database == database + 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(), - host: host, port: port, database: database, username: username, tls: tls, webURL: webURL, + host: host, port: port, database: database, username: username, tls: tls, + webURL: webURL, name: host) try target.validate() if !password.isEmpty { try credentials.save(password, for: target.id) } diff --git a/Packages/PeerCore/Sources/PeerCore/InfoBase.swift b/Packages/PeerCore/Sources/PeerCore/InfoBase.swift index 9fcbcf2..a8aabb8 100644 --- a/Packages/PeerCore/Sources/PeerCore/InfoBase.swift +++ b/Packages/PeerCore/Sources/PeerCore/InfoBase.swift @@ -11,6 +11,8 @@ public struct GraphBlock: Sendable, Encodable { self.content = content } } +// 数据库图协议直接使用 from_/to_ 字段。 +// swift-format-ignore: AlwaysUseLowerCamelCase public struct GraphRelation: Sendable, Encodable { public let from_: Int public let to_: Int diff --git a/Packages/PeerCore/Sources/PeerCore/PeerDatabase.swift b/Packages/PeerCore/Sources/PeerCore/PeerDatabase.swift index f7a8d95..32c90d2 100644 --- a/Packages/PeerCore/Sources/PeerCore/PeerDatabase.swift +++ b/Packages/PeerCore/Sources/PeerCore/PeerDatabase.swift @@ -54,7 +54,9 @@ public actor PeerDatabase { """, 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) } + for try await expiry in rows.decode(Date.self) { + return expiry.addingTimeInterval(-120) + } throw PeerError.connection("信息库未返回 Peer 联络结果。") } } diff --git a/Packages/PeerCore/Sources/PeerMacUI/PeerConnectionEditor.swift b/Packages/PeerCore/Sources/PeerMacUI/PeerConnectionEditor.swift index 16393f9..cdef355 100644 --- a/Packages/PeerCore/Sources/PeerMacUI/PeerConnectionEditor.swift +++ b/Packages/PeerCore/Sources/PeerMacUI/PeerConnectionEditor.swift @@ -71,15 +71,18 @@ public struct PeerConnectionEditor: View { defer { saving = false } do { let saved = try await settings.saveEdited( - host: server, port: port, database: database, username: username, tls: tls, - web: web, password: secret, previous: target, credentials: credentials) + host: server, port: port, database: database, username: username, + tls: tls, + web: web, password: secret, previous: target, + credentials: credentials) onSaved(saved) secret = "" dismiss() } catch { issue = error.localizedDescription } } }.keyboardShortcut(.defaultAction) - .disabled(saving || server.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .disabled( + saving || server.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } }.padding(20).frame(width: 520) .interactiveDismissDisabled(saving) diff --git a/README.md b/README.md index bc5214f..d2dbcd5 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ 需要 Xcode 26.2 / Swift 6.2。PostgresNIO 及依赖通过 SwiftPM 解析,精确修订固定在 Package.resolved。 ```sh -swift test +bash scripts/check.sh bash scripts/build-app.sh open dist/InKCre.app ``` diff --git a/Sources/InKCreApp/AppMain.swift b/Sources/InKCreApp/AppMain.swift index 48557d1..a53bb11 100644 --- a/Sources/InKCreApp/AppMain.swift +++ b/Sources/InKCreApp/AppMain.swift @@ -15,14 +15,16 @@ enum InKCreApplication { } @MainActor -final class PeerHost: NSObject, NSApplicationDelegate, ObservableObject, NSUserInterfaceValidations { +final class PeerHost: NSObject, NSApplicationDelegate, ObservableObject, NSUserInterfaceValidations +{ let context: PeerHostContext let runtime: BundledExtensionRuntime let capture: CaptureExtension private var module: any NativeExtension { runtime.module } override convenience init() { let identity = Bundle.main.bundleIdentifier ?? "dev.inkcre.capture" - let directory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + let directory = FileManager.default.urls( + for: .applicationSupportDirectory, in: .userDomainMask)[0] .appendingPathComponent(identity) self.init(context: PeerHostContext(identity: identity, directory: directory)) } @@ -33,7 +35,9 @@ final class PeerHost: NSObject, NSApplicationDelegate, ObservableObject, NSUserI runtime = BundledExtensionRuntime(module: capture, defaults: defaults) super.init() capture.showPreferences = { [weak self] in self?.showSettings(.capture) } - capture.editConnection = { [weak self] target, completion in self?.editConnection(target, onFinished: completion) } + capture.editConnection = { [weak self] target, completion in + self?.editConnection(target, onFinished: completion) + } } private var presence: Task? @Published var connectionIssue: String? @@ -78,24 +82,31 @@ final class PeerHost: NSObject, NSApplicationDelegate, ObservableObject, NSUserI } let app = addMenu("InKCre") app.addItem( - withTitle: "关于 InKCre", action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), + withTitle: "关于 InKCre", + action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "") - app.addItem(withTitle: "设置…", action: #selector(showPreferences(_:)), keyEquivalent: ",").target = self - app.addItem(withTitle: "Peer 连接…", action: #selector(showConnection(_:)), keyEquivalent: "").target = + app.addItem(withTitle: "设置…", action: #selector(showPreferences(_:)), keyEquivalent: ",") + .target = self + app.addItem(withTitle: "Peer 连接…", action: #selector(showConnection(_:)), keyEquivalent: "") + .target = self - app.addItem(withTitle: "开源许可与致谢…", action: #selector(showLicenses(_:)), keyEquivalent: "").target = + app.addItem(withTitle: "开源许可与致谢…", action: #selector(showLicenses(_:)), keyEquivalent: "") + .target = self app.addItem(.separator()) - app.addItem(withTitle: "隐藏 InKCre", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h") + app.addItem( + withTitle: "隐藏 InKCre", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h") let others = app.addItem( withTitle: "隐藏其他应用", action: #selector(NSApplication.hideOtherApplications(_:)), keyEquivalent: "h") others.keyEquivalentModifierMask = [.command, .option] app.addItem( - withTitle: "显示全部", action: #selector(NSApplication.unhideAllApplications(_:)), keyEquivalent: "") + withTitle: "显示全部", action: #selector(NSApplication.unhideAllApplications(_:)), + keyEquivalent: "") app.addItem(.separator()) app.addItem( - withTitle: "退出 InKCre", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") + withTitle: "退出 InKCre", action: #selector(NSApplication.terminate(_:)), + keyEquivalent: "q") let edit = addMenu("编辑") for (title, action, key) in [ ("撤销", Selector(("undo:")), "z"), ("剪切", #selector(NSText.cut(_:)), "x"), @@ -111,17 +122,23 @@ final class PeerHost: NSObject, NSApplicationDelegate, ObservableObject, NSUserI withTitle: "显示/隐藏侧栏", action: #selector(NSSplitViewController.toggleSidebar(_:)), keyEquivalent: "s" ).keyEquivalentModifierMask = [.command, .option] - view.addItem(withTitle: "进入全屏", action: #selector(NSWindow.toggleFullScreen(_:)), keyEquivalent: "f") - .keyEquivalentModifierMask = [.command, .control] + view.addItem( + withTitle: "进入全屏", action: #selector(NSWindow.toggleFullScreen(_:)), keyEquivalent: "f" + ) + .keyEquivalentModifierMask = [.command, .control] let window = addMenu("窗口") window.addItem( - withTitle: "最小化", action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m") - window.addItem(withTitle: "缩放", action: #selector(NSWindow.performZoom(_:)), keyEquivalent: "") - window.addItem(withTitle: "关闭窗口", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w") + withTitle: "最小化", action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m" + ) + window.addItem( + withTitle: "缩放", action: #selector(NSWindow.performZoom(_:)), keyEquivalent: "") + window.addItem( + withTitle: "关闭窗口", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w") window.addItem(withTitle: "扩展设置…", action: #selector(showExtensions(_:)), keyEquivalent: "") .target = self window.addItem( - withTitle: "前置全部窗口", action: #selector(NSApplication.arrangeInFront(_:)), keyEquivalent: "") + withTitle: "前置全部窗口", action: #selector(NSApplication.arrangeInFront(_:)), + keyEquivalent: "") NSApp.windowsMenu = window NSApp.mainMenu = main } @@ -131,7 +148,10 @@ final class PeerHost: NSObject, NSApplicationDelegate, ObservableObject, NSUserI @objc private func showConnection(_ sender: Any?) { showSettings(.connection) } func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool { - if [#selector(showPreferences(_:)), #selector(showConnection(_:)), #selector(showExtensions(_:))].contains(item.action) { + if [ + #selector(showPreferences(_:)), #selector(showConnection(_:)), + #selector(showExtensions(_:)), + ].contains(item.action) { return manager?.attachedSheet != nil || capture.canOpenSettings } return true @@ -145,8 +165,9 @@ final class PeerHost: NSObject, NSApplicationDelegate, ObservableObject, NSUserI NSApp.activate(ignoringOtherApps: true) manager?.makeKeyAndOrderFront(nil) Task { - do { connection = try await context.connections.load() } - catch { connectionIssue = error.localizedDescription } + do { connection = try await context.connections.load() } catch { + connectionIssue = error.localizedDescription + } } } @@ -166,21 +187,23 @@ final class PeerHost: NSObject, NSApplicationDelegate, ObservableObject, NSUserI guard capture.canOpenSettings else { return } showSettings(.connection) guard let manager, manager.attachedSheet == nil else { return } - let sheet = NSWindow(contentRect: .zero, styleMask: [.titled], backing: .buffered, defer: false) + let sheet = NSWindow( + contentRect: .zero, styleMask: [.titled], backing: .buffered, defer: false) let close: @MainActor @Sendable () -> Void = { [weak manager, weak sheet] in guard let manager, let sheet else { return } manager.endSheet(sheet) sheet.orderOut(nil) } - let content = NSHostingView(rootView: PeerConnectionEditor( - settings: context.connections, credentials: context.credentials, - target: target, saveTitle: onFinished == nil ? "保存" : "保存并返回采集", onCancel: close, - onSaved: { [weak self] target in - self?.connection = target - self?.connectionIssue = nil - self?.capture.connectionDidChange(target) - close() - })) + let content = NSHostingView( + rootView: PeerConnectionEditor( + settings: context.connections, credentials: context.credentials, + target: target, saveTitle: onFinished == nil ? "保存" : "保存并返回采集", onCancel: close, + onSaved: { [weak self] target in + self?.connection = target + self?.connectionIssue = nil + self?.capture.connectionDidChange(target) + close() + })) sheet.contentView = content sheet.setContentSize(content.fittingSize) manager.beginSheet(sheet) { [weak manager] _ in @@ -204,7 +227,9 @@ final class PeerHost: NSObject, NSApplicationDelegate, ObservableObject, NSUserI alert.runModal() } - func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) + -> Bool + { if runtime.running { module.showMain() } else { showExtensions(nil) } return false } diff --git a/Sources/InKCreApp/PeerSettingsWindow.swift b/Sources/InKCreApp/PeerSettingsWindow.swift index d7b1a89..dcf4112 100644 --- a/Sources/InKCreApp/PeerSettingsWindow.swift +++ b/Sources/InKCreApp/PeerSettingsWindow.swift @@ -9,20 +9,24 @@ final class PeerSettingsWindow: NSWindow, NSWindowDelegate { private let tabs = NSTabViewController() init(host: PeerHost) { - super.init(contentRect: NSRect(x: 0, y: 0, width: 640, height: 580), - styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false) + super.init( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 580), + styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false) self.host = host delegate = self title = "InKCre 设置" isReleasedWhenClosed = false minSize = NSSize(width: 600, height: 540) tabs.tabStyle = .toolbar - let connection = NSTabViewItem(viewController: NSHostingController(rootView: ConnectionSettingsView(host: host))) + let connection = NSTabViewItem( + viewController: NSHostingController(rootView: ConnectionSettingsView(host: host))) connection.label = "连接" connection.image = NSImage(systemSymbolName: "network", accessibilityDescription: "连接") - let extensions = NSTabViewItem(viewController: NSHostingController(rootView: ExtensionSettingsView(host: host))) + let extensions = NSTabViewItem( + viewController: NSHostingController(rootView: ExtensionSettingsView(host: host))) extensions.label = "扩展" - extensions.image = NSImage(systemSymbolName: "puzzlepiece.extension", accessibilityDescription: "扩展") + extensions.image = NSImage( + systemSymbolName: "puzzlepiece.extension", accessibilityDescription: "扩展") let capture = NSTabViewItem(viewController: captureController(host: host)) capture.label = "采集" capture.image = NSImage(systemSymbolName: "viewfinder", accessibilityDescription: "采集") @@ -53,9 +57,12 @@ final class PeerSettingsWindow: NSWindow, NSWindowDelegate { } private func captureController(host: PeerHost) -> NSViewController { - host.capture.makeSettingsViewController() ?? NSHostingController(rootView: - ContentUnavailableView("采集扩展已停用", systemImage: "viewfinder", - description: Text("在“扩展”中启用后,可设置采集快捷键和系统权限。"))) + host.capture.makeSettingsViewController() + ?? NSHostingController( + rootView: + ContentUnavailableView( + "采集扩展已停用", systemImage: "viewfinder", + description: Text("在“扩展”中启用后,可设置采集快捷键和系统权限。"))) } } @@ -66,15 +73,22 @@ private struct ConnectionSettingsView: View { Section("共享信息库") { if let target = host.connection { LabeledContent("信息库", value: target.name) - LabeledContent("PostgreSQL", value: "\(target.host):\(target.port)/\(target.database)") + LabeledContent( + "PostgreSQL", value: "\(target.host):\(target.port)/\(target.database)") LabeledContent("运行角色", value: target.username) - if let web = target.webURL { LabeledContent("Web 地址", value: web.absoluteString) } + if let web = target.webURL { + LabeledContent("Web 地址", value: web.absoluteString) + } } else { Text("连接信息库后可以收入。未连接时仍可采集、保留草稿和导出材料。") .foregroundStyle(.secondary) } - Button(host.connection == nil ? "连接信息库…" : "编辑连接…") { host.editConnection(host.connection) } - if let issue = host.connectionIssue { Text(issue).foregroundStyle(.red).textSelection(.enabled) } + Button(host.connection == nil ? "连接信息库…" : "编辑连接…") { + host.editConnection(host.connection) + } + if let issue = host.connectionIssue { + Text(issue).foregroundStyle(.red).textSelection(.enabled) + } } Text("连接由此 Mac 的各扩展共享;密码保存在系统钥匙串中。") .font(.caption).foregroundStyle(.secondary) @@ -87,11 +101,20 @@ private struct ExtensionSettingsView: View { var body: some View { Form { Section("随手收集") { - Toggle("在此 Mac 启用", isOn: Binding(get: { host.runtime.enabled }, set: { host.changeEnabled($0) })) - .disabled(host.runtime.applying) - Text(host.runtime.applying ? "正在应用…" : host.runtime.syncIssue ?? - (host.runtime.running ? "本机运行中" : host.runtime.enabled ? "尚未启动;请停用后重新启用。" : "已停用;本机材料与共享内容保留")) - .foregroundStyle(.secondary) + Toggle( + "在此 Mac 启用", + isOn: Binding(get: { host.runtime.enabled }, set: { host.changeEnabled($0) }) + ) + .disabled(host.runtime.applying) + Text( + host.runtime.applying + ? "正在应用…" + : host.runtime.syncIssue + ?? (host.runtime.running + ? "本机运行中" + : host.runtime.enabled ? "尚未启动;请停用后重新启用。" : "已停用;本机材料与共享内容保留") + ) + .foregroundStyle(.secondary) LabeledContent("版本", value: host.capture.descriptor.version) Text("随应用交付,提供选区、粘贴、截图与想法采集。") .font(.caption).foregroundStyle(.secondary) diff --git a/Tests/CaptureMacTests/AppIconTests.swift b/Tests/CaptureMacTests/AppIconTests.swift index 4e6bb08..e29a684 100644 --- a/Tests/CaptureMacTests/AppIconTests.swift +++ b/Tests/CaptureMacTests/AppIconTests.swift @@ -5,7 +5,8 @@ struct AppIconTests { @Test func iconsetHasTransparentMacMarginsAtEveryScale() throws { let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent() .deletingLastPathComponent().deletingLastPathComponent() - let folder = root.appendingPathComponent("extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset") + let folder = root.appendingPathComponent( + "extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset") for (name, pixels, fraction) in [ ("16x16", 16, 0.875), ("16x16@2x", 32, 0.875), ("32x32", 32, 0.875), ("32x32@2x", 64, 0.875), @@ -13,12 +14,15 @@ struct AppIconTests { ("256x256", 256, 0.8046875), ("256x256@2x", 512, 0.8046875), ("512x512", 512, 0.8046875), ("512x512@2x", 1024, 0.8046875), ] { - let bitmap = try #require(NSBitmapImageRep(data: Data(contentsOf: folder.appendingPathComponent("icon_\(name).png")))) + let bitmap = try #require( + NSBitmapImageRep( + data: Data(contentsOf: folder.appendingPathComponent("icon_\(name).png")))) #expect(bitmap.pixelsWide == pixels && bitmap.pixelsHigh == pixels) let inset = Double(pixels) * (1 - fraction) / 2 for vertical in [false, true] { let visible = (0.. 0.05 } let first = try #require(visible.first) diff --git a/Tests/CaptureMacTests/CaptureInteractionTests.swift b/Tests/CaptureMacTests/CaptureInteractionTests.swift index 3b856b1..70eebdf 100644 --- a/Tests/CaptureMacTests/CaptureInteractionTests.swift +++ b/Tests/CaptureMacTests/CaptureInteractionTests.swift @@ -1,16 +1,19 @@ import AppKit import Capture import PeerCore -import Testing import Security +import Testing + @testable import CaptureMac @Suite(.serialized) @MainActor struct CaptureInteractionTests { @Test func emptyCaptureShortcutDoesNotOpenConnectionOrStartSubmission() throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } - let model = try CaptureModel(directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)") + let model = try CaptureModel( + directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)") defer { model.cancelWork() } var mainOpened = false model.editConnection = { _, _ in mainOpened = true } @@ -23,16 +26,23 @@ struct CaptureInteractionTests { } @Test func backgroundQueueRefreshCannotReplaceNewDraftFeedbackWithAnOldRecord() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } - let model = try CaptureModel(directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)") + let model = try CaptureModel( + directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)") defer { model.cancelWork() } let old = CaptureRecord(payload: CapturePayload(materials: [Material(text: "旧的已收入记录")])) try await model.store.saveDraft(old) - _ = try await model.store.freeze(old, target: ConnectionTarget(host: "fixture.test", database: "inkcre", username: "inkcre_core")) - try await model.store.confirm(old.id, result: CaptureResult( - submissionID: old.id, rootBlockID: 42, committedAt: Date(), availability: .available - )) + _ = try await model.store.freeze( + old, + target: ConnectionTarget( + host: "fixture.test", database: "inkcre", username: "inkcre_core")) + try await model.store.confirm( + old.id, + result: CaptureResult( + submissionID: old.id, rootBlockID: 42, committedAt: Date(), availability: .available + )) model.newThought() for _ in 0..<100 where model.submitting { try await Task.sleep(for: .milliseconds(10)) } let newDraftID = model.draft.id @@ -44,9 +54,11 @@ struct CaptureInteractionTests { } @Test func quickCaptureDoesNotChangeMainWindowNavigationAndUsesTheSameDraft() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } - let model = try CaptureModel(directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)") + let model = try CaptureModel( + directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)") defer { model.cancelWork() } model.thought = "主窗口中未提交的草稿" let previousID = model.draft.id @@ -59,7 +71,9 @@ struct CaptureInteractionTests { #expect(model.pane == .peer) #expect(presented == .quick) #expect(model.draft.id == payload.submissionID) - #expect(try await model.store.record(previousID)?.payload?.materials.first?.text == "主窗口中未提交的草稿") + #expect( + try await model.store.record(previousID)?.payload?.materials.first?.text == "主窗口中未提交的草稿" + ) let currentID = model.draft.id model.editorOwner = .main #expect(model.draft.id == currentID) @@ -74,11 +88,14 @@ struct CaptureInteractionTests { @Test(arguments: [false, true]) func missingConnectionReturnsToTheOriginalDraftWithoutSubmitting(saved: Bool) async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } - let model = try CaptureModel(directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)") + let model = try CaptureModel( + directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)") defer { model.cancelWork() } - await model.receive(CapturePayload(materials: [Material(text: "连接后继续收入的原文")], annotation: "原附注")) + await model.receive( + CapturePayload(materials: [Material(text: "连接后继续收入的原文")], annotation: "原附注")) let original = model.draft model.editorOwner = .quick var completion: (@MainActor () -> Void)? @@ -91,7 +108,10 @@ struct CaptureInteractionTests { model.presentEditor = { returnedTo = model.editorOwner } model.submit() #expect(returnedTo == nil) - if saved { model.connection = ConnectionTarget(host: "fixture.test", database: "inkcre", username: "test") } + if saved { + model.connection = ConnectionTarget( + host: "fixture.test", database: "inkcre", username: "test") + } let finish = try #require(completion) finish() #expect(returnedTo == .quick) @@ -107,17 +127,22 @@ struct CaptureInteractionTests { } @Test func passwordRepairRetriesTheFrozenTargetWhileKeepingTheActiveConnection() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) let identity = "dev.inkcre.password.\(UUID().uuidString)" defer { - SecItemDelete([kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: "\(identity).connections"] as CFDictionary) + SecItemDelete( + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "\(identity).connections", + ] as CFDictionary) try? FileManager.default.removeItem(at: directory) } let transport = JourneyTransport() let model = try CaptureModel(directory: directory, identity: identity, client: transport) defer { model.cancelWork() } - let originalTarget = ConnectionTarget(host: "original.test", database: "original", username: "test") + let originalTarget = ConnectionTarget( + 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(active) @@ -128,27 +153,37 @@ struct CaptureInteractionTests { await model.sendPending(force: true) #expect(await transport.calls.isEmpty) try await model.updatePassword("new-fixture-password", for: cancelled) - for _ in 0..<100 where model.records.first?.state != .confirmed { try await Task.sleep(for: .milliseconds(10)) } + for _ in 0..<100 where model.records.first?.state != .confirmed { + try await Task.sleep(for: .milliseconds(10)) + } #expect(model.connection == active) #expect(try await model.connections.load() == active) let calls = await transport.calls #expect(calls.map(\.kind) == ["recover", "submit"]) for call in calls { - #expect(call.record.id == draft.id && call.record.target == originalTarget && call.secret == "new-fixture-password") - #expect(try CaptureJSON.encode(call.record.payload) == CaptureJSON.encode(draft.payload)) + #expect( + call.record.id == draft.id && call.record.target == originalTarget + && call.secret == "new-fixture-password") + #expect( + try CaptureJSON.encode(call.record.payload) == CaptureJSON.encode(draft.payload)) } #expect(model.records.first?.state == .confirmed) } @Test func submittedFeedbackRemainsFindableWhileWritingTheNextDraft() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) let identity = "dev.inkcre.feedback.\(UUID().uuidString)" defer { - SecItemDelete([kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: "\(identity).connections"] as CFDictionary) + SecItemDelete( + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "\(identity).connections", + ] as CFDictionary) try? FileManager.default.removeItem(at: directory) } - let model = try CaptureModel(directory: directory, identity: identity, client: JourneyTransport()) + let model = try CaptureModel( + directory: directory, identity: identity, client: JourneyTransport()) defer { model.cancelWork() } let target = ConnectionTarget(host: "fixture.test", database: "inkcre", username: "test") model.connection = target @@ -156,7 +191,9 @@ struct CaptureInteractionTests { model.thought = "第一条想法" let submittedID = model.draft.id model.submit() - for _ in 0..<100 where model.submittedRecord?.state != .confirmed { try await Task.sleep(for: .milliseconds(10)) } + for _ in 0..<100 where model.submittedRecord?.state != .confirmed { + try await Task.sleep(for: .milliseconds(10)) + } let record = try #require(model.submittedRecord) #expect(record.id == submittedID && record.state == .confirmed) model.thought = "下一条尚未收入的想法" @@ -172,60 +209,78 @@ struct CaptureInteractionTests { } @Test func historicalWebAddressUpdatesOnlyThatRecordsDisplayMetadata() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } - let model = try CaptureModel(directory: directory, identity: "dev.inkcre.web.\(UUID().uuidString)") + let model = try CaptureModel( + directory: directory, identity: "dev.inkcre.web.\(UUID().uuidString)") defer { model.cancelWork() } - let original = ConnectionTarget(host: "original.test", database: "original", username: "test") + let original = ConnectionTarget( + 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(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) + let receipt = CaptureResult( + submissionID: draft.id, rootBlockID: 42, committedAt: Date(), availability: .available) 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) var expected = original expected.webURL = URL(string: "https://original-web.test") - #expect(updated.target == expected && updated.id == confirmed.id && updated.result == receipt) + #expect( + updated.target == expected && updated.id == confirmed.id && updated.result == receipt) #expect(updated.payload == nil && updated.updatedAt == confirmed.updatedAt) #expect(model.connection == active) #expect(try await model.connections.load() == active) #expect(model.resultURL(for: updated)?.host == "original-web.test") model.connection = original #expect(model.resultURL(for: updated)?.host == "original-web.test") - await #expect(throws: PeerError.self) { _ = try await model.updateWebAddress("javascript:alert(1)", for: confirmed) } + await #expect(throws: PeerError.self) { + _ = try await model.updateWebAddress("javascript:alert(1)", for: confirmed) + } #expect(try await model.store.record(draft.id)?.target == expected) } @Test func configuringWebAddressMakesExistingResultsOpenableWithoutRetargetingThem() throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } - let model = try CaptureModel(directory: directory, identity: "dev.inkcre.result.\(UUID().uuidString)") + let model = try CaptureModel( + directory: directory, identity: "dev.inkcre.result.\(UUID().uuidString)") defer { model.cancelWork() } - let target = ConnectionTarget(host: "fixture.test", database: "inkcre", username: "inkcre_core") + let target = ConnectionTarget( + host: "fixture.test", database: "inkcre", username: "inkcre_core") var record = CaptureRecord() record.target = target record.state = .confirmed - record.result = CaptureResult(submissionID: record.id, rootBlockID: 42, committedAt: Date(), availability: .available) + record.result = CaptureResult( + submissionID: record.id, rootBlockID: 42, committedAt: Date(), availability: .available) #expect(model.resultURL(for: record) == nil) var configured = target configured.webURL = URL(string: "https://fixture.test") model.connection = configured - #expect(model.resultURL(for: record)?.absoluteString == "https://fixture.test/info-base/list/blocks/42/content") + #expect( + model.resultURL(for: record)?.absoluteString + == "https://fixture.test/info-base/list/blocks/42/content") #expect(record.target == target) - model.connection = ConnectionTarget(host: "other.test", database: "other", username: "inkcre_core", webURL: URL(string: "https://other.test")) + model.connection = ConnectionTarget( + 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) + record.result = CaptureResult( + submissionID: record.id, rootBlockID: 42, committedAt: Date(), availability: .deleted) #expect(model.resultURL(for: record) == nil) } @Test func commandAvailabilityFollowsEditorContentAndPresentation() throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } - let model = try CaptureModel(directory: directory, identity: "dev.inkcre.commands.\(UUID().uuidString)") + let model = try CaptureModel( + directory: directory, identity: "dev.inkcre.commands.\(UUID().uuidString)") defer { model.cancelWork() } #expect(!model.canPerform(.submit, isEditor: true, isQuickPanel: false)) model.thought = "待收入的内容" @@ -244,13 +299,16 @@ struct CaptureInteractionTests { } @Test func materialRemovalRequiresConfirmationAndPreservesOtherContent() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } - let model = try CaptureModel(directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)") + let model = try CaptureModel( + directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)") defer { model.cancelWork() } let material = Material(text: "误采的材料") let kept = Material(text: "保留的材料") - model.draft = CaptureRecord(payload: CapturePayload(entry: .paste, materials: [material, kept], annotation: "附注")) + model.draft = CaptureRecord( + payload: CapturePayload(entry: .paste, materials: [material, kept], annotation: "附注")) try await model.flush() model.requestRemoveMaterial(material.id) #expect(model.draft.payload?.materials == [material, kept]) @@ -265,9 +323,11 @@ struct CaptureInteractionTests { } @Test func deletingBeforeDebounceRequiresConfirmationAndDoesNotReappear() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } - let model = try CaptureModel(directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)") + let model = try CaptureModel( + directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)") defer { model.cancelWork() } model.thought = "尚未等到自动保存的中文 👩🏽‍💻" model.annotation = "最后一次附注" @@ -291,9 +351,11 @@ struct CaptureInteractionTests { } @Test func cancellingQuickCaptureOnlyDiscardsContentAfterConfirmation() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } - let model = try CaptureModel(directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)") + let model = try CaptureModel( + directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)") defer { model.cancelWork() } var closed = false model.dismissQuickCapture = { closed = true } @@ -313,9 +375,11 @@ struct CaptureInteractionTests { } @Test func deletingAnotherRecordKeepsTheCurrentUnsavedDraft() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } - let model = try CaptureModel(directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)") + let model = try CaptureModel( + directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)") defer { model.cancelWork() } let other = CaptureRecord(payload: CapturePayload(materials: [Material(text: "另一份草稿")])) try await model.store.saveDraft(other) @@ -325,7 +389,9 @@ struct CaptureInteractionTests { model.confirmRemoval(try #require(model.removalRequest)) for _ in 0..<100 where model.submitting { try await Task.sleep(for: .milliseconds(10)) } #expect(model.draft.id == currentID) - #expect(try await model.store.record(currentID)?.payload?.materials.first?.text == "当前尚未等到自动保存的内容") + #expect( + try await model.store.record(currentID)?.payload?.materials.first?.text + == "当前尚未等到自动保存的内容") #expect(try await model.store.record(other.id) == nil) #expect(model.error == nil) } @@ -344,6 +410,7 @@ 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) + return CaptureResult( + submissionID: record.id, rootBlockID: 42, committedAt: Date(), availability: .available) } } diff --git a/Tests/CaptureMacTests/CaptureLayoutTests.swift b/Tests/CaptureMacTests/CaptureLayoutTests.swift index c816bcd..7c4214c 100644 --- a/Tests/CaptureMacTests/CaptureLayoutTests.swift +++ b/Tests/CaptureMacTests/CaptureLayoutTests.swift @@ -1,24 +1,28 @@ import AppKit import Capture -import SwiftUI import PeerCore import PeerMacUI +import SwiftUI import Testing + @testable import CaptureMac @Suite(.serialized) @MainActor struct CaptureLayoutTests { @Test func emptyRecordsContentIsCenteredAcrossDetailWidths() throws { _ = NSApplication.shared - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } - let model = try CaptureModel(directory: directory, identity: "dev.inkcre.layout.\(UUID().uuidString)") + let model = try CaptureModel( + directory: directory, identity: "dev.inkcre.layout.\(UUID().uuidString)") defer { model.cancelWork() } for width in [520, 550, 790] { - let renderer = ImageRenderer(content: CaptureRecordsView(model: model) - .environment(\.colorScheme, .light) - .frame(width: CGFloat(width), height: 517) - .background(.white)) + let renderer = ImageRenderer( + content: CaptureRecordsView(model: model) + .environment(\.colorScheme, .light) + .frame(width: CGFloat(width), height: 517) + .background(.white)) renderer.scale = 1 let image = try #require(renderer.cgImage) let bitmap = NSBitmapImageRep(cgImage: image) @@ -29,8 +33,12 @@ struct CaptureLayoutTests { for y in 0..= minX) #expect(abs(Double(minX + maxX) / 2 - Double(width - 1) / 2) < 1) if let path = ProcessInfo.processInfo.environment["INKCRE_LAYOUT_EVIDENCE_DIR"] { - let output = URL(fileURLWithPath: path).appendingPathComponent("records-empty-\(width).png") + let output = URL(fileURLWithPath: path).appendingPathComponent( + "records-empty-\(width).png") try #require(bitmap.representation(using: .png, properties: [:])).write(to: output) } } } @Test func nativeConnectionEditorRendersWithoutOpeningAWindow() async throws { _ = NSApplication.shared - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } - let model = try CaptureModel(directory: directory, identity: "dev.inkcre.layout.\(UUID().uuidString)") + let model = try CaptureModel( + directory: directory, identity: "dev.inkcre.layout.\(UUID().uuidString)") defer { model.cancelWork() } - let view = NSHostingView(rootView: PeerConnectionEditor(settings: model.connections, credentials: model.credentials, target: nil, onSaved: { _ in }).environment(\.colorScheme, .light).background(Color.white)) - let window = NSWindow(contentRect: NSRect(x:0,y:0,width:520,height:440), styleMask: [.titled], backing:.buffered, defer:false) - window.appearance = NSAppearance(named:.aqua) + let view = NSHostingView( + rootView: PeerConnectionEditor( + settings: model.connections, credentials: model.credentials, target: nil, + onSaved: { _ in } + ).environment(\.colorScheme, .light).background(Color.white)) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 520, height: 440), styleMask: [.titled], + backing: .buffered, defer: false) + window.appearance = NSAppearance(named: .aqua) window.contentView = view defer { window.orderOut(nil) } try await Task.sleep(for: .milliseconds(100)) @@ -66,21 +83,26 @@ struct CaptureLayoutTests { let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds)) view.cacheDisplay(in: view.bounds, to: bitmap) if let path = ProcessInfo.processInfo.environment["INKCRE_LAYOUT_EVIDENCE_DIR"] { - try #require(bitmap.representation(using: .png, properties: [:])).write(to: URL(fileURLWithPath: path).appendingPathComponent("native-connection.png")) + try #require(bitmap.representation(using: .png, properties: [:])).write( + to: URL(fileURLWithPath: path).appendingPathComponent("native-connection.png")) } } @Test func mainWindowSidebarAndPrimarySurfacesRenderOffscreen() async throws { _ = NSApplication.shared - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } - let model = try CaptureModel(directory: directory, identity: "dev.inkcre.surfaces.\(UUID().uuidString)") + let model = try CaptureModel( + directory: directory, identity: "dev.inkcre.surfaces.\(UUID().uuidString)") defer { model.cancelWork() } let window = PeerMainWindow(model: model) window.setFrameAutosaveName("") window.setFrame(NSRect(x: 0, y: 0, width: 760, height: 520), display: false) defer { window.orderOut(nil) } - let item = NSMenuItem(title: "侧栏", action: #selector(NSSplitViewController.toggleSidebar(_:)), keyEquivalent: "s") + let item = NSMenuItem( + title: "侧栏", action: #selector(NSSplitViewController.toggleSidebar(_:)), + keyEquivalent: "s") #expect(window.validateUserInterfaceItem(item)) #expect(item.title == "隐藏侧栏") #expect(window.tryToPerform(item.action!, with: item)) @@ -101,17 +123,20 @@ struct CaptureLayoutTests { try await snapshot(window, named: "records-annotation-\(appearance.rawValue)") model.records = [] } - let auxiliary = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 600, height: 560), - styleMask: [.titled, .closable], backing: .buffered, defer: false) + let auxiliary = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 600, height: 560), + styleMask: [.titled, .closable], backing: .buffered, defer: false) defer { auxiliary.orderOut(nil) } for appearance in [NSAppearance.Name.aqua, .darkAqua] { auxiliary.appearance = NSAppearance(named: appearance) - auxiliary.contentViewController = NSHostingController(rootView: CaptureSettingsView(model: model)) + auxiliary.contentViewController = NSHostingController( + rootView: CaptureSettingsView(model: model)) auxiliary.setContentSize(NSSize(width: 600, height: 560)) try await snapshot(auxiliary, named: "capture-settings-\(appearance.rawValue)") model.editorOwner = .quick model.thought = "用于界面验收的合成想法,不读取剪贴板或屏幕。" - auxiliary.contentViewController = NSHostingController(rootView: QuickCaptureView(model: model)) + auxiliary.contentViewController = NSHostingController( + rootView: QuickCaptureView(model: model)) auxiliary.setContentSize(NSSize(width: 500, height: 500)) try await snapshot(auxiliary, named: "quick-\(appearance.rawValue)") } @@ -120,20 +145,31 @@ struct CaptureLayoutTests { @Test func captureJourneysRenderAtMinimumSizeWithReadableActions() async throws { _ = NSApplication.shared - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } - let model = try CaptureModel(directory: directory, identity: "dev.inkcre.journey-layout.\(UUID().uuidString)") + let model = try CaptureModel( + directory: directory, identity: "dev.inkcre.journey-layout.\(UUID().uuidString)") defer { model.cancelWork() } - let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 520, height: 480), - styleMask: [.titled, .closable], backing: .buffered, defer: false) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 520, height: 480), + styleMask: [.titled, .closable], backing: .buffered, defer: false) defer { window.orderOut(nil) } let source = NSImage(size: NSSize(width: 1200, height: 800), flipped: false) { rect in - NSColor.white.setFill(); rect.fill() - NSColor.systemBlue.setFill(); NSRect(x: 40, y: 600, width: 1120, height: 150).fill() - ("截图原文 · 合成内容" as NSString).draw(at: NSPoint(x: 80, y: 650), - withAttributes: [.font: NSFont.systemFont(ofSize: 48), .foregroundColor: NSColor.white]) - ("原图 1200 × 800,可用快速查看阅读细节。" as NSString).draw(at: NSPoint(x: 80, y: 500), - withAttributes: [.font: NSFont.systemFont(ofSize: 28), .foregroundColor: NSColor.black]) + NSColor.white.setFill() + rect.fill() + NSColor.systemBlue.setFill() + NSRect(x: 40, y: 600, width: 1120, height: 150).fill() + ("截图原文 · 合成内容" as NSString).draw( + at: NSPoint(x: 80, y: 650), + withAttributes: [ + .font: NSFont.systemFont(ofSize: 48), .foregroundColor: NSColor.white, + ]) + ("原图 1200 × 800,可用快速查看阅读细节。" as NSString).draw( + at: NSPoint(x: 80, y: 500), + withAttributes: [ + .font: NSFont.systemFont(ofSize: 28), .foregroundColor: NSColor.black, + ]) return true } let tiff = try #require(source.tiffRepresentation) @@ -143,52 +179,81 @@ struct CaptureLayoutTests { let preview = try CaptureImagePreview.makePreviewFile(material) defer { try? FileManager.default.removeItem(at: preview) } #expect(try Data(contentsOf: preview) == data) - #expect((try FileManager.default.attributesOfItem(atPath: preview.path)[.posixPermissions] as? NSNumber)?.intValue == 0o600) - let target = ConnectionTarget(host: "original.test", database: "inkcre", username: "inkcre_core", name: "original.test/inkcre") + #expect( + (try FileManager.default.attributesOfItem(atPath: preview.path)[.posixPermissions] + as? NSNumber)?.intValue == 0o600) + let target = ConnectionTarget( + host: "original.test", database: "inkcre", username: "inkcre_core", + name: "original.test/inkcre") for appearance in [NSAppearance.Name.aqua, .darkAqua] { window.appearance = NSAppearance(named: appearance) model.editorOwner = .quick - model.draft = CaptureRecord(payload: CapturePayload(materials: [Capture.Material(text: "手写想法只有一个主要输入区,可以持续输入并自动保留。")])) - window.contentViewController = NSHostingController(rootView: QuickCaptureView(model: model)) + model.draft = CaptureRecord( + payload: CapturePayload(materials: [ + Capture.Material(text: "手写想法只有一个主要输入区,可以持续输入并自动保留。") + ])) + window.contentViewController = NSHostingController( + rootView: QuickCaptureView(model: model)) window.setContentSize(NSSize(width: 500, height: 500)) try await snapshot(window, named: "journey-manual-\(appearance.rawValue)") - model.draft = CaptureRecord(payload: CapturePayload(entry: .screenshot, materials: [material], annotation: "这张图说明了什么?")) + model.draft = CaptureRecord( + payload: CapturePayload( + entry: .screenshot, materials: [material], annotation: "这张图说明了什么?")) try await snapshot(window, named: "journey-image-\(appearance.rawValue)") - model.draft = CaptureRecord(payload: CapturePayload(entry: .paste, materials: [Capture.Material(text: String(repeating: "原文内容可以滚动阅读;自己的想法在下面单独输入。\n", count: 16))])) + model.draft = CaptureRecord( + payload: CapturePayload( + entry: .paste, + materials: [ + Capture.Material( + text: String(repeating: "原文内容可以滚动阅读;自己的想法在下面单独输入。\n", count: 16)) + ])) try await snapshot(window, named: "journey-long-source-\(appearance.rawValue)") model.connection = target model.submit() - for _ in 0..<100 where model.submittedRecord?.state != .needsAction { try await Task.sleep(for: .milliseconds(10)) } + for _ in 0..<100 where model.submittedRecord?.state != .needsAction { + try await Task.sleep(for: .milliseconds(10)) + } let failed = try #require(model.submittedRecord) try await snapshot(window, named: "journey-feedback-\(appearance.rawValue)") model.showRecord(failed) - window.contentViewController = NSHostingController(rootView: CaptureRecordsView(model: model)) + window.contentViewController = NSHostingController( + rootView: CaptureRecordsView(model: model)) window.setContentSize(NSSize(width: 520, height: 480)) try await snapshot(window, named: "journey-failure-record-\(appearance.rawValue)") let payload = try #require(failed.payload) - window.contentViewController = NSHostingController(rootView: - ScrollView { CaptureRecordMaterials(payload: payload).padding() }) + window.contentViewController = NSHostingController( + rootView: + ScrollView { CaptureRecordMaterials(payload: payload).padding() }) window.setContentSize(NSSize(width: 520, height: 480)) try await snapshot(window, named: "journey-all-materials-\(appearance.rawValue)") for field in [CaptureRecordSettings.Field.password, .webAddress] { - window.contentViewController = NSHostingController(rootView: CaptureRecordSettings(model: model, record: failed, field: field)) + window.contentViewController = NSHostingController( + rootView: CaptureRecordSettings(model: model, record: failed, field: field)) window.setContentSize(NSSize(width: 520, height: 400)) - try await snapshot(window, named: "journey-\(field)-\(appearance.rawValue)", minimumSize: NSSize(width: 480, height: 250)) + try await snapshot( + window, named: "journey-\(field)-\(appearance.rawValue)", + minimumSize: NSSize(width: 480, height: 250)) } } #expect(!window.isVisible) } - private func snapshot(_ window: NSWindow, named name: String, minimumSize: NSSize = NSSize(width: 500, height: 400)) async throws { + private func snapshot( + _ window: NSWindow, named name: String, + minimumSize: NSSize = NSSize(width: 500, height: 400) + ) async throws { try await Task.sleep(for: .milliseconds(200)) 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) - #expect(bitmap.pixelsWide >= Int(minimumSize.width) && bitmap.pixelsHigh >= Int(minimumSize.height)) + #expect( + bitmap.pixelsWide >= Int(minimumSize.width) + && bitmap.pixelsHigh >= Int(minimumSize.height)) if let path = ProcessInfo.processInfo.environment["INKCRE_LAYOUT_EVIDENCE_DIR"] { - try #require(bitmap.representation(using: .png, properties: [:])).write(to: - URL(fileURLWithPath: path).appendingPathComponent("\(name).png")) + try #require(bitmap.representation(using: .png, properties: [:])).write( + to: + URL(fileURLWithPath: path).appendingPathComponent("\(name).png")) } } diff --git a/Tests/CaptureMacTests/RegionOverlayTests.swift b/Tests/CaptureMacTests/RegionOverlayTests.swift index 511a506..18d6060 100644 --- a/Tests/CaptureMacTests/RegionOverlayTests.swift +++ b/Tests/CaptureMacTests/RegionOverlayTests.swift @@ -3,6 +3,7 @@ import AppKit import ImageIO import Testing + @testable import CaptureMac @Suite(.serialized) @MainActor @@ -11,12 +12,18 @@ struct RegionOverlayTests { _ = NSApplication.shared let windows = NSApp.windows.count var checks = 0 - let capture = RegionCapture(hasPermission: { checks += 1; return false }) + let capture = RegionCapture(hasPermission: { + checks += 1 + return false + }) var denied = 0 for _ in 0..<3 { capture.begin { result in - if case .failure(let error) = result, error is ScreenRecordingPermissionError { denied += 1 } - else { Issue.record("拒绝访问不得进入框选或截取。") } + if case .failure(let error) = result, error is ScreenRecordingPermissionError { + denied += 1 + } else { + Issue.record("拒绝访问不得进入框选或截取。") + } } } #expect(checks == 3) @@ -25,10 +32,14 @@ struct RegionOverlayTests { #expect(NSApp.windows.count == windows) } - private func event(_ type: NSEvent.EventType, at point: CGPoint, in view: NSView) throws -> NSEvent { - try #require(NSEvent.mouseEvent(with: type, location: view.convert(point, to: nil), modifierFlags: [], - timestamp: 0, windowNumber: view.window!.windowNumber, - context: nil, eventNumber: 1, clickCount: 1, pressure: 1)) + private func event(_ type: NSEvent.EventType, at point: CGPoint, in view: NSView) throws + -> NSEvent + { + try #require( + NSEvent.mouseEvent( + with: type, location: view.convert(point, to: nil), modifierFlags: [], + timestamp: 0, windowNumber: view.window!.windowNumber, + context: nil, eventNumber: 1, clickCount: 1, pressure: 1)) } private func drag(_ view: SelectionView, from start: CGPoint, to end: CGPoint) throws { @@ -39,8 +50,9 @@ struct RegionOverlayTests { private func window() -> NSWindow { _ = NSApplication.shared - let window = NSWindow(contentRect: CGRect(x: 0, y: 0, width: 800, height: 520), styleMask: .borderless, - backing: .buffered, defer: false) + let window = NSWindow( + contentRect: CGRect(x: 0, y: 0, width: 800, height: 520), styleMask: .borderless, + backing: .buffered, defer: false) window.isReleasedWhenClosed = false return window } @@ -49,8 +61,9 @@ struct RegionOverlayTests { let window = window() defer { window.close() } var results: [CGRect] = [] - let view = SelectionView(frame: window.contentView!.bounds, scaleFactor: 2, - onSelect: { results.append($0) }, onCancel: { Issue.record("不应取消。") }) + let view = SelectionView( + frame: window.contentView!.bounds, scaleFactor: 2, + onSelect: { results.append($0) }, onCancel: { Issue.record("不应取消。") }) window.contentView = view #expect(view.acceptsFirstMouse(for: nil)) try drag(view, from: CGPoint(x: 500, y: 400), to: CGPoint(x: 100, y: 120)) @@ -66,12 +79,17 @@ struct RegionOverlayTests { let buttonRect = view.confirmButton.convert(view.confirmButton.bounds, to: view) #expect(view.bounds.contains(buttonRect)) #expect(!view.confirmButton.isHidden && view.confirmButton.isEnabled) - #expect(view.hitTest(CGPoint(x: buttonRect.midX, y: buttonRect.midY)) === view.confirmButton) + #expect( + view.hitTest(CGPoint(x: buttonRect.midX, y: buttonRect.midY)) === view.confirmButton) let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds)) view.cacheDisplay(in: view.bounds, to: bitmap) let scale = CGFloat(bitmap.pixelsWide) / view.bounds.width - let edge = try #require(bitmap.colorAt(x: Int(rect.minX * scale), y: Int((view.bounds.height - rect.midY) * scale))) - let inside = try #require(bitmap.colorAt(x: Int(rect.midX * scale), y: Int((view.bounds.height - rect.midY) * scale))) + let edge = try #require( + bitmap.colorAt( + x: Int(rect.minX * scale), y: Int((view.bounds.height - rect.midY) * scale))) + let inside = try #require( + bitmap.colorAt( + x: Int(rect.midX * scale), y: Int((view.bounds.height - rect.midY) * scale))) let outside = try #require(bitmap.colorAt(x: Int(40 * scale), y: Int(250 * scale))) #expect(edge.alphaComponent > 0.9) #expect(inside.alphaComponent < 0.05) @@ -88,8 +106,9 @@ struct RegionOverlayTests { let window = window() defer { window.close() } var cancelled = 0 - let view = SelectionView(frame: window.contentView!.bounds, scaleFactor: 1, - onSelect: { _ in Issue.record("取消不得产图。") }, onCancel: { cancelled += 1 }) + let view = SelectionView( + frame: window.contentView!.bounds, scaleFactor: 1, + onSelect: { _ in Issue.record("取消不得产图。") }, onCancel: { cancelled += 1 }) window.contentView = view try drag(view, from: CGPoint(x: 200, y: 200), to: CGPoint(x: 201, y: 201)) #expect(view.selection == nil) @@ -98,27 +117,39 @@ struct RegionOverlayTests { try drag(view, from: CGPoint(x: 40, y: 40), to: CGPoint(x: 300, y: 260)) #expect(view.confirmButton.isEnabled) if escape { - let key = try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [], timestamp: 0, - windowNumber: window.windowNumber, context: nil, - characters: "\u{1b}", charactersIgnoringModifiers: "\u{1b}", - isARepeat: false, keyCode: 53)) + let key = try #require( + NSEvent.keyEvent( + with: .keyDown, location: .zero, modifierFlags: [], timestamp: 0, + windowNumber: window.windowNumber, context: nil, + characters: "\u{1b}", charactersIgnoringModifiers: "\u{1b}", + isARepeat: false, keyCode: 53)) view.keyDown(with: key) - } else { view.cancelButton.performClick(nil) } + } else { + view.cancelButton.performClick(nil) + } view.cancelSelection() #expect(cancelled == 1) } } - @Test func stopClosesActualOverlayAndDiscardsPendingResultWithoutPresentingWindows() async throws { + @Test func stopClosesActualOverlayAndDiscardsPendingResultWithoutPresentingWindows() + async throws + { _ = NSApplication.shared let screen = try #require(NSScreen.screens.first) - let number = try #require(screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber) - let frame = FrozenScreenFrame(displayID: number.uint32Value, pointsRect: CGDisplayBounds(number.uint32Value), - image: try pixels(width: 8, height: 6)) + let number = try #require( + screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber) + let frame = FrozenScreenFrame( + displayID: number.uint32Value, pointsRect: CGDisplayBounds(number.uint32Value), + image: try pixels(width: 8, height: 6)) var windows: [NSWindow] = [] let closing = WindowCloseObserver() - let overlay = RegionSelectionOverlay(present: { windows = $0; windows.forEach { $0.delegate = closing } }) - let capture = RegionCapture(hasPermission: { true }, freezeScreens: { [frame] }, overlay: overlay) + let overlay = RegionSelectionOverlay(present: { + windows = $0 + for window in windows { window.delegate = closing } + }) + let capture = RegionCapture( + hasPermission: { true }, freezeScreens: { [frame] }, overlay: overlay) var callbacks = 0 capture.begin { _ in callbacks += 1 } for _ in 0..<100 where windows.isEmpty { await Task.yield() } @@ -136,9 +167,11 @@ struct RegionOverlayTests { @Test func confirmedRegionReturnsOnlyFrozenPngThroughCaptureCoordinator() async throws { _ = NSApplication.shared let screen = try #require(NSScreen.screens.first) - let number = try #require(screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber) - let frame = FrozenScreenFrame(displayID: number.uint32Value, pointsRect: CGDisplayBounds(number.uint32Value), - image: try pixels(width: 8, height: 6)) + let number = try #require( + screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber) + let frame = FrozenScreenFrame( + displayID: number.uint32Value, pointsRect: CGDisplayBounds(number.uint32Value), + image: try pixels(width: 8, height: 6)) var freezes = 0 let overlay = RegionSelectionOverlay(present: { windows in let view = windows[0].contentView!.subviews[0] as! SelectionView @@ -146,16 +179,23 @@ struct RegionOverlayTests { view.endDrag(at: CGPoint(x: screen.frame.width * 3 / 4, y: screen.frame.height * 3 / 4)) view.confirmButton.performClick(nil) }) - let capture = RegionCapture(hasPermission: { true }, freezeScreens: { freezes += 1; return [frame] }, overlay: overlay) + let capture = RegionCapture( + hasPermission: { true }, + freezeScreens: { + freezes += 1 + return [frame] + }, overlay: overlay) let result: Result = await withCheckedContinuation { continuation in capture.begin { continuation.resume(returning: $0) } } let data = try #require(try result.get()) let source = try #require(CGImageSourceCreateWithData(data as CFData, nil)) let image = try #require(CGImageSourceCreateImageAtIndex(source, 0, nil)) - let expected = try frame.croppedImage(in: CGRect(x: frame.pointsRect.minX + frame.pointsRect.width / 4, - y: frame.pointsRect.minY + frame.pointsRect.height / 4, - width: frame.pointsRect.width / 2, height: frame.pointsRect.height / 2)) + let expected = try frame.croppedImage( + in: CGRect( + x: frame.pointsRect.minX + frame.pointsRect.width / 4, + y: frame.pointsRect.minY + frame.pointsRect.height / 4, + width: frame.pointsRect.width / 2, height: frame.pointsRect.height / 2)) #expect(rgba(image) == rgba(expected)) #expect(freezes == 1) #expect(!capture.isCapturing) @@ -171,39 +211,59 @@ struct RegionOverlayTests { NSColor(calibratedRed: 0.91, green: 0.93, blue: 0.96, alpha: 1).setFill() NSBezierPath(rect: CGRect(origin: .zero, size: image.size)).fill() NSColor.white.setFill() - NSBezierPath(roundedRect: CGRect(x: 80, y: 80, width: 640, height: 360), xRadius: 12, yRadius: 12).fill() - ("InKCre · 合成截图验收" as NSString).draw(at: CGPoint(x: 120, y: 365), withAttributes: [ - .font: NSFont.systemFont(ofSize: 26, weight: .semibold), .foregroundColor: NSColor.black]) + NSBezierPath( + roundedRect: CGRect(x: 80, y: 80, width: 640, height: 360), xRadius: 12, yRadius: 12 + ).fill() + ("InKCre · 合成截图验收" as NSString).draw( + at: CGPoint(x: 120, y: 365), + withAttributes: [ + .font: NSFont.systemFont(ofSize: 26, weight: .semibold), + .foregroundColor: NSColor.black, + ]) ("选中眼前的信息,随手收入。\n\n框选后仍可移动、调整大小,再确认保存。\n这段文字与背景均为测试生成的合成内容。" as NSString).draw( - in: CGRect(x: 120, y: 170, width: 550, height: 160), withAttributes: [ - .font: NSFont.systemFont(ofSize: 18), .foregroundColor: NSColor.darkGray]) + in: CGRect(x: 120, y: 170, width: 550, height: 160), + withAttributes: [ + .font: NSFont.systemFont(ofSize: 18), .foregroundColor: NSColor.darkGray, + ]) image.unlockFocus() - let backdrop = FrozenBackdropView(image: try #require(image.cgImage(forProposedRect: nil, context: nil, hints: nil)), - frame: window.contentView!.bounds) - let view = SelectionView(frame: backdrop.bounds, scaleFactor: 2, onSelect: { _ in }, onCancel: {}) + let backdrop = FrozenBackdropView( + image: try #require(image.cgImage(forProposedRect: nil, context: nil, hints: nil)), + frame: window.contentView!.bounds) + let view = SelectionView( + frame: backdrop.bounds, scaleFactor: 2, onSelect: { _ in }, onCancel: {}) backdrop.addSubview(view) window.contentView = backdrop for state in ["ready", "selected", "edge"] { - if state == "selected" { try drag(view, from: CGPoint(x: 100, y: 130), to: CGPoint(x: 690, y: 415)) } - if state == "edge" { try drag(view, from: CGPoint(x: 740, y: 10), to: CGPoint(x: 790, y: 70)) } + if state == "selected" { + try drag(view, from: CGPoint(x: 100, y: 130), to: CGPoint(x: 690, y: 415)) + } + if state == "edge" { + try drag(view, from: CGPoint(x: 740, y: 10), to: CGPoint(x: 790, y: 70)) + } backdrop.layoutSubtreeIfNeeded() let button = state == "ready" ? view.cancelButton : view.confirmButton let rect = button.convert(button.bounds, to: view) #expect(rect.width > 35 && rect.height > 15) #expect(view.bounds.contains(rect)) #expect(view.hitTest(CGPoint(x: rect.midX, y: rect.midY)) === button) - let bitmap = try #require(backdrop.bitmapImageRepForCachingDisplay(in: backdrop.bounds)) + let bitmap = try #require( + backdrop.bitmapImageRepForCachingDisplay(in: backdrop.bounds)) backdrop.cacheDisplay(in: backdrop.bounds, to: bitmap) let scale = CGFloat(bitmap.pixelsWide) / backdrop.bounds.width - let outside = try #require(bitmap.colorAt(x: Int(40 * scale), y: Int(260 * scale))?.usingColorSpace(.deviceRGB)) + let outside = try #require( + bitmap.colorAt(x: Int(40 * scale), y: Int(260 * scale))?.usingColorSpace( + .deviceRGB)) #expect(outside.redComponent > 0.5 && outside.redComponent < 0.75) if state == "selected" { - let inside = try #require(bitmap.colorAt(x: Int(670 * scale), y: Int(370 * scale))?.usingColorSpace(.deviceRGB)) + let inside = try #require( + bitmap.colorAt(x: Int(670 * scale), y: Int(370 * scale))?.usingColorSpace( + .deviceRGB)) #expect(inside.redComponent > 0.95 && inside.alphaComponent > 0.95) } if let path = ProcessInfo.processInfo.environment["INKCRE_LAYOUT_EVIDENCE_DIR"] { let file = "capture-\(name.rawValue)-\(state).png" - try #require(bitmap.representation(using: .png, properties: [:])).write(to: URL(fileURLWithPath: path).appendingPathComponent(file)) + try #require(bitmap.representation(using: .png, properties: [:])).write( + to: URL(fileURLWithPath: path).appendingPathComponent(file)) } } } @@ -211,7 +271,8 @@ struct RegionOverlayTests { @Test func frozenCropUsesOriginalPixelsAtEachDisplayScaleAndGlobalOrigin() throws { let image = try pixels(width: 8, height: 6) - let frame = FrozenScreenFrame(displayID: 2, pointsRect: CGRect(x: -4, y: -3, width: 4, height: 3), image: image) + let frame = FrozenScreenFrame( + displayID: 2, pointsRect: CGRect(x: -4, y: -3, width: 4, height: 3), image: image) let region = CGRect(x: -3, y: -2.5, width: 2, height: 1.5) let cropped = try frame.croppedImage(in: region) #expect(cropped.width == 4 && cropped.height == 3) @@ -223,43 +284,58 @@ struct RegionOverlayTests { #expect(bytes[i + 1] == UInt8((y + 1) * 30)) } } - let source = try #require(CGImageSourceCreateWithData(try frame.png(in: region) as CFData, nil)) + let source = try #require( + CGImageSourceCreateWithData(try frame.png(in: region) as CFData, nil)) let png = try #require(CGImageSourceCreateImageAtIndex(source, 0, nil)) #expect(rgba(png) == bytes) - #expect(RegionGeometry.pointsRect(global: CGRect(x: -3, y: 101, width: 2, height: 1.5), primaryHeight: 100) == region) - let single = FrozenScreenFrame(displayID: 3, pointsRect: CGRect(x: 100, y: 50, width: 8, height: 6), image: image) + #expect( + RegionGeometry.pointsRect( + global: CGRect(x: -3, y: 101, width: 2, height: 1.5), primaryHeight: 100) == region) + let single = FrozenScreenFrame( + displayID: 3, pointsRect: CGRect(x: 100, y: 50, width: 8, height: 6), image: image) let singleCrop = try single.croppedImage(in: CGRect(x: 102, y: 51, width: 4, height: 3)) #expect(singleCrop.width == 4 && singleCrop.height == 3) #expect(throws: (any Error).self) { try frame.croppedImage(in: .zero) } - #expect(throws: (any Error).self) { try frame.croppedImage(in: CGRect(x: -5, y: -3, width: 2, height: 2)) } + #expect(throws: (any Error).self) { + try frame.croppedImage(in: CGRect(x: -5, y: -3, width: 2, height: 2)) + } } @Test func resizingAcrossOppositeEdgeAndMovingStaysWithinDisplay() { let selection = CGRect(x: 100, y: 100, width: 200, height: 100) let screen = CGRect(x: 0, y: 0, width: 1000, height: 800) - #expect(RegionAdjustment.apply(.left, delta: CGSize(width: 250, height: 0), to: selection, within: screen) + #expect( + RegionAdjustment.apply( + .left, delta: CGSize(width: 250, height: 0), to: selection, within: screen) == CGRect(x: 300, y: 100, width: 50, height: 100)) - #expect(RegionAdjustment.apply(.move, delta: CGSize(width: 900, height: -10), to: selection, within: screen) + #expect( + RegionAdjustment.apply( + .move, delta: CGSize(width: 900, height: -10), to: selection, within: screen) == CGRect(x: 800, y: 90, width: 200, height: 100)) } private func pixels(width: Int, height: Int) throws -> CGImage { var bytes = [UInt8]() - for y in 0.. [UInt8] { var bytes = [UInt8](repeating: 0, count: image.width * image.height * 4) bytes.withUnsafeMutableBytes { buffer in - let context = CGContext(data: buffer.baseAddress, width: image.width, height: image.height, - bitsPerComponent: 8, bytesPerRow: image.width * 4, - space: CGColorSpace(name: CGColorSpace.sRGB)!, - bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)! + let context = CGContext( + data: buffer.baseAddress, width: image.width, height: image.height, + bitsPerComponent: 8, bytesPerRow: image.width * 4, + space: CGColorSpace(name: CGColorSpace.sRGB)!, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)! context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height)) } return bytes diff --git a/Tests/CaptureMacTests/ShortcutTests.swift b/Tests/CaptureMacTests/ShortcutTests.swift index 4db4782..39ca299 100644 --- a/Tests/CaptureMacTests/ShortcutTests.swift +++ b/Tests/CaptureMacTests/ShortcutTests.swift @@ -1,8 +1,9 @@ import AppKit -import Carbon.HIToolbox import Capture +import Carbon.HIToolbox import PeerCore import Testing + @testable import CaptureMac @Suite(.serialized) @MainActor @@ -12,18 +13,25 @@ struct ShortcutTests { let (defaults, domain) = defaults() defer { defaults.removePersistentDomain(forName: domain) } let shortcuts = try GlobalShortcuts(defaults: defaults) - let window = NSWindow(contentRect: .init(x: 0, y: 0, width: 500, height: 400), - styleMask: [.titled], backing: .buffered, defer: false) - let sheet = NSWindow(contentRect: .init(x: 0, y: 0, width: 200, height: 100), - styleMask: [.titled], backing: .buffered, defer: false) + let window = NSWindow( + contentRect: .init(x: 0, y: 0, width: 500, height: 400), + styleMask: [.titled], backing: .buffered, defer: false) + let sheet = NSWindow( + contentRect: .init(x: 0, y: 0, width: 200, height: 100), + styleMask: [.titled], backing: .buffered, defer: false) window.beginSheet(sheet) - defer { window.endSheet(sheet); window.orderOut(nil) } + defer { + window.endSheet(sheet) + window.orderOut(nil) + } shortcuts.isCaptureWindow = { $0 === window } var performed = false shortcuts.perform = { _ in performed = true } - let event = try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: .command, - timestamp: 0, windowNumber: window.windowNumber, context: nil, characters: "\r", - charactersIgnoringModifiers: "\r", isARepeat: false, keyCode: UInt16(kVK_Return))) + let event = try #require( + NSEvent.keyEvent( + with: .keyDown, location: .zero, modifierFlags: .command, + timestamp: 0, windowNumber: window.windowNumber, context: nil, characters: "\r", + charactersIgnoringModifiers: "\r", isARepeat: false, keyCode: UInt16(kVK_Return))) #expect(window.attachedSheet === sheet) #expect(shortcuts.handle(event) === event) #expect(!performed) @@ -39,7 +47,8 @@ struct ShortcutTests { let cg = try #require(CGEvent(keyboardEventSource: nil, virtualKey: 0, keyDown: true)) let characters = Array(text.utf16) characters.withUnsafeBufferPointer { buffer in - cg.keyboardSetUnicodeString(stringLength: buffer.count, unicodeString: buffer.baseAddress) + cg.keyboardSetUnicodeString( + stringLength: buffer.count, unicodeString: buffer.baseAddress) } let event = try #require(NSEvent(cgEvent: cg)) let original = try #require(event.characters) @@ -48,7 +57,8 @@ struct ShortcutTests { #expect(event.characters == original) var count = 0 var result = [UniChar](repeating: 0, count: 256) - cg.keyboardGetUnicodeString(maxStringLength: result.count, actualStringLength: &count, unicodeString: &result) + cg.keyboardGetUnicodeString( + maxStringLength: result.count, actualStringLength: &count, unicodeString: &result) #expect(String(utf16CodeUnits: result, count: count) == original) } @@ -62,9 +72,11 @@ struct ShortcutTests { var performed = 0 shortcuts.canPerform = { _, _ in enabled } shortcuts.perform = { _ in performed += 1 } - let event = try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: .command, - timestamp: 0, windowNumber: 0, context: nil, characters: "\r", - charactersIgnoringModifiers: "\r", isARepeat: false, keyCode: UInt16(kVK_Return))) + let event = try #require( + NSEvent.keyEvent( + with: .keyDown, location: .zero, modifierFlags: .command, + timestamp: 0, windowNumber: 0, context: nil, characters: "\r", + charactersIgnoringModifiers: "\r", isARepeat: false, keyCode: UInt16(kVK_Return))) #expect(shortcuts.handle(event) === event) #expect(performed == 0) enabled = true @@ -75,8 +87,10 @@ struct ShortcutTests { @Test func invalidSavedBindingDoesNotPreventOpeningSettingsOrDisableOtherActions() throws { let (defaults, domain) = defaults() defer { defaults.removePersistentDomain(forName: domain) } - var saved = Dictionary(uniqueKeysWithValues: ShortcutAction.allCases.map { ($0, $0.defaultShortcut) }) - saved[.hide] = RecordedShortcut(keyCode: UInt16(kVK_ANSI_W), modifiers: .command, keyLabel: "W") + var saved = Dictionary( + uniqueKeysWithValues: ShortcutAction.allCases.map { ($0, $0.defaultShortcut) }) + saved[.hide] = RecordedShortcut( + keyCode: UInt16(kVK_ANSI_W), modifiers: .command, keyLabel: "W") let data = try JSONEncoder().encode(saved) defaults.set(data, forKey: GlobalShortcuts.settingsKey) let shortcuts = try GlobalShortcuts(defaults: defaults) @@ -93,10 +107,12 @@ struct ShortcutTests { for (key, label, modifiers) in [ (kVK_ANSI_W, "W", NSEvent.ModifierFlags.command), (kVK_ANSI_Q, "Q", .command), (kVK_ANSI_Comma, ",", .command), (kVK_ANSI_V, "V", .command), - (kVK_ANSI_S, "S", [.command, .option]) + (kVK_ANSI_S, "S", [.command, .option]), ] { #expect(throws: PeerError.self) { - try shortcuts.set(RecordedShortcut(keyCode: UInt16(key), modifiers: modifiers, keyLabel: label), for: .hide) + try shortcuts.set( + RecordedShortcut(keyCode: UInt16(key), modifiers: modifiers, keyLabel: label), + for: .hide) } } #expect(shortcuts.bindings[.hide] == ShortcutAction.hide.defaultShortcut) @@ -112,7 +128,8 @@ struct ShortcutTests { let (defaults, domain) = defaults() defer { defaults.removePersistentDomain(forName: domain) } let shortcuts = try GlobalShortcuts(defaults: defaults) - let replacement = RecordedShortcut(keyCode: UInt16(kVK_ANSI_K), modifiers: [.control, .option], keyLabel: "K") + let replacement = RecordedShortcut( + keyCode: UInt16(kVK_ANSI_K), modifiers: [.control, .option], keyLabel: "K") try shortcuts.set(replacement, for: .capture) try shortcuts.set(nil, for: .screenshot) let reopened = try GlobalShortcuts(defaults: defaults) @@ -128,9 +145,13 @@ struct ShortcutTests { let (defaults, domain) = defaults() defer { defaults.removePersistentDomain(forName: domain) } let shortcuts = try GlobalShortcuts(defaults: defaults) - #expect(throws: PeerError.self) { try shortcuts.set(ShortcutAction.submit.defaultShortcut, for: .capture) } #expect(throws: PeerError.self) { - try shortcuts.set(RecordedShortcut(keyCode: UInt16(kVK_ANSI_A), modifiers: .shift, keyLabel: "A"), for: .submit) + try shortcuts.set(ShortcutAction.submit.defaultShortcut, for: .capture) + } + #expect(throws: PeerError.self) { + try shortcuts.set( + RecordedShortcut(keyCode: UInt16(kVK_ANSI_A), modifiers: .shift, keyLabel: "A"), + for: .submit) } #expect(shortcuts.bindings[.capture] == ShortcutAction.capture.defaultShortcut) #expect(shortcuts.bindings[.submit] == ShortcutAction.submit.defaultShortcut) @@ -142,23 +163,29 @@ struct ShortcutTests { let (defaults, domain) = defaults() defer { defaults.removePersistentDomain(forName: domain) } let shortcuts = try GlobalShortcuts(defaults: defaults) - let old = RecordedShortcut(keyCode: UInt16(kVK_F17), modifiers: [.control, .option, .command], keyLabel: "F17") - let blocked = RecordedShortcut(keyCode: UInt16(kVK_F18), modifiers: [.control, .option, .command], keyLabel: "F18") + let old = RecordedShortcut( + keyCode: UInt16(kVK_F17), modifiers: [.control, .option, .command], keyLabel: "F17") + let blocked = RecordedShortcut( + keyCode: UInt16(kVK_F18), modifiers: [.control, .option, .command], keyLabel: "F18") try shortcuts.set(old, for: .capture) try shortcuts.set(nil, for: .screenshot) try shortcuts.start() defer { shortcuts.stop() } #expect(shortcuts.message == nil) var occupied: EventHotKeyRef? - #expect(RegisterEventHotKey(UInt32(blocked.keyCode), blocked.carbonModifiers, - EventHotKeyID(signature: 0x54455354, id: 77), GetApplicationEventTarget(), 0, &occupied) == noErr) + #expect( + RegisterEventHotKey( + UInt32(blocked.keyCode), blocked.carbonModifiers, + EventHotKeyID(signature: 0x5445_5354, id: 77), GetApplicationEventTarget(), 0, + &occupied) == noErr) defer { if let occupied { UnregisterEventHotKey(occupied) } } #expect(throws: PeerError.self) { try shortcuts.set(blocked, for: .capture) } #expect(shortcuts.bindings[.capture] == old) #expect(try GlobalShortcuts(defaults: defaults).bindings[.capture] == old) var probe: EventHotKeyRef? - let result = RegisterEventHotKey(UInt32(old.keyCode), old.carbonModifiers, - EventHotKeyID(signature: 0x54455354, id: 78), GetApplicationEventTarget(), 0, &probe) + let result = RegisterEventHotKey( + UInt32(old.keyCode), old.carbonModifiers, + EventHotKeyID(signature: 0x5445_5354, id: 78), GetApplicationEventTarget(), 0, &probe) defer { if let probe { UnregisterEventHotKey(probe) } } #expect(result != noErr) } @@ -170,16 +197,22 @@ struct ShortcutTests { var performed = false shortcuts.perform = { _ in performed = true } shortcuts.beginRecording(.paste) - let event = try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [.command, .option], - timestamp: 0, windowNumber: 0, context: nil, characters: "p", charactersIgnoringModifiers: "p", - isARepeat: false, keyCode: UInt16(kVK_ANSI_P))) + let event = try #require( + NSEvent.keyEvent( + with: .keyDown, location: .zero, modifierFlags: [.command, .option], + timestamp: 0, windowNumber: 0, context: nil, characters: "p", + charactersIgnoringModifiers: "p", + isARepeat: false, keyCode: UInt16(kVK_ANSI_P))) #expect(shortcuts.handle(event) == nil) #expect(shortcuts.bindings[.paste]?.keyCode == UInt16(kVK_ANSI_P)) #expect(!performed && shortcuts.recording == nil) shortcuts.beginRecording(.submit) - let escape = try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [], - timestamp: 0, windowNumber: 0, context: nil, characters: "\u{1b}", charactersIgnoringModifiers: "\u{1b}", - isARepeat: false, keyCode: UInt16(kVK_Escape))) + let escape = try #require( + NSEvent.keyEvent( + with: .keyDown, location: .zero, modifierFlags: [], + timestamp: 0, windowNumber: 0, context: nil, characters: "\u{1b}", + charactersIgnoringModifiers: "\u{1b}", + isARepeat: false, keyCode: UInt16(kVK_Escape))) #expect(shortcuts.handle(escape) == nil) #expect(shortcuts.recording == nil) #expect(shortcuts.bindings[.submit] == ShortcutAction.submit.defaultShortcut) diff --git a/Tests/CaptureTests/CaptureContractTests.swift b/Tests/CaptureTests/CaptureContractTests.swift index 9b78d5d..549f09d 100644 --- a/Tests/CaptureTests/CaptureContractTests.swift +++ b/Tests/CaptureTests/CaptureContractTests.swift @@ -10,7 +10,8 @@ import Testing let digest: String } let url = try #require( - Bundle.module.url(forResource: "capture-v1", withExtension: "json", subdirectory: "Fixtures")) + Bundle.module.url( + forResource: "capture-v1", withExtension: "json", subdirectory: "Fixtures")) let fixtures = try CaptureJSON.decode([Fixture].self, from: Data(contentsOf: url)) for fixture in fixtures { #expect(try fixture.payload.canonicalDigest() == fixture.digest) @@ -26,7 +27,8 @@ import Testing let digest: String } let url = try #require( - Bundle.module.url(forResource: "capture-v1", withExtension: "json", subdirectory: "Fixtures")) + 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)))) @@ -45,7 +47,9 @@ import Testing #expect(blocks[1]["content"] as? String == fixture.payload.materials[0].text) #expect(blocks[4]["content"] as? String == fixture.payload.annotation) #expect( - relations.map { $0["content"] as? String } == ["material:0", "content", "material:1", "annotation"]) + relations.map { $0["content"] as? String } == [ + "material:0", "content", "material:1", "annotation", + ]) #expect(blobs.count == 1 && blobs[0]["block_id"] as? Int == -4) #expect( Data(base64Encoded: try #require(blobs[0]["data_base64"] as? String)) @@ -55,9 +59,11 @@ import Testing @Test func nativeValidationRejectsMismatchedImagesAndCountsUTF8Bytes() throws { struct Fixture: Decodable { let payload: CapturePayload } let url = try #require( - Bundle.module.url(forResource: "capture-v1", withExtension: "json", subdirectory: "Fixtures")) + Bundle.module.url( + forResource: "capture-v1", withExtension: "json", subdirectory: "Fixtures")) let image = try #require( - CaptureJSON.decode([Fixture].self, from: Data(contentsOf: url))[1].payload.materials[1].data) + CaptureJSON.decode([Fixture].self, from: Data(contentsOf: url))[1].payload.materials[1].data + ) #expect(throws: PeerError.self) { try CapturePayload(materials: [Material(image: image, mimeType: "image/jpeg")]).validate() } diff --git a/Tests/CaptureTests/CaptureLifecycleTests.swift b/Tests/CaptureTests/CaptureLifecycleTests.swift index 3e80f6f..f997990 100644 --- a/Tests/CaptureTests/CaptureLifecycleTests.swift +++ b/Tests/CaptureTests/CaptureLifecycleTests.swift @@ -46,7 +46,8 @@ private actor PausedRecovery: CaptureTransport { let store = try CaptureStore(url: directory.appendingPathComponent("capture.sqlite")) for text in ["第一份", "第二份"] { _ = try await store.freeze( - CaptureRecord(payload: CapturePayload(materials: [Material(text: text)])), target: target) + CaptureRecord(payload: CapturePayload(materials: [Material(text: text)])), + target: target) } let transport = PausedRecovery() let queue = CaptureQueue(store: store, credentials: credentials, client: transport) diff --git a/Tests/CaptureTests/CaptureStoreTests.swift b/Tests/CaptureTests/CaptureStoreTests.swift index 49e96c8..60f2df7 100644 --- a/Tests/CaptureTests/CaptureStoreTests.swift +++ b/Tests/CaptureTests/CaptureStoreTests.swift @@ -7,7 +7,8 @@ import Testing struct CaptureStoreTests { @Test func deletedDraftIsAbsentAfterRestartAndLateAutosaveCannotRecreateIt() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let url = directory.appendingPathComponent("capture.sqlite") let store = try CaptureStore(url: url) @@ -18,7 +19,8 @@ struct CaptureStoreTests { await #expect(throws: PeerError.self) { _ = try await store.freeze( draft, - target: ConnectionTarget(host: "example.test", database: "inkcre", username: "inkcre_core")) + target: ConnectionTarget( + host: "example.test", database: "inkcre", username: "inkcre_core")) } let reopened = try CaptureStore(url: url) #expect(try await reopened.record(draft.id) == nil) @@ -26,10 +28,12 @@ struct CaptureStoreTests { } @Test func cancelledIntentNeverAutomaticallyDispatches() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let store = try CaptureStore(url: directory.appendingPathComponent("capture.sqlite")) - let target = ConnectionTarget(host: "example.test", database: "inkcre", username: "inkcre_core") + let target = ConnectionTarget( + host: "example.test", database: "inkcre", username: "inkcre_core") let draft = CaptureRecord(payload: CapturePayload(materials: [Material(text: "尚未发送")])) _ = try await store.freeze(draft, target: target) try await store.cancel(draft.id) @@ -48,12 +52,15 @@ struct CaptureStoreTests { } @Test func cancellationKeepsUnknownOutcomeButDeletionIgnoresLateCallbacks() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let store = try CaptureStore(url: directory.appendingPathComponent("capture.sqlite")) let draft = CaptureRecord(payload: CapturePayload(materials: [Material(text: "在途材料")])) _ = try await store.freeze( - draft, target: ConnectionTarget(host: "example.test", database: "inkcre", username: "inkcre_core") + draft, + target: ConnectionTarget( + host: "example.test", database: "inkcre", username: "inkcre_core") ) _ = try await store.markDispatch(draft.id) try await store.cancel(draft.id) @@ -73,7 +80,8 @@ struct CaptureStoreTests { } @Test func clearingDraftAndContinuingToTypeKeepsTheSameIdentity() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let store = try CaptureStore(url: directory.appendingPathComponent("capture.sqlite")) var draft = CaptureRecord(payload: CapturePayload(materials: [Material(text: "清空前")])) @@ -86,7 +94,8 @@ struct CaptureStoreTests { } @Test func failedFreezeRetainsTheSavedDraftAndCannotDispatch() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let url = directory.appendingPathComponent("capture.sqlite") let store = try CaptureStore(url: url) @@ -106,7 +115,8 @@ struct CaptureStoreTests { await #expect(throws: PeerError.self) { _ = try await store.freeze( draft, - target: ConnectionTarget(host: "example.test", database: "inkcre", username: "inkcre_core")) + target: ConnectionTarget( + host: "example.test", database: "inkcre", username: "inkcre_core")) } let retained = try #require(try await store.record(draft.id)) #expect(retained.state == .draft) @@ -115,11 +125,13 @@ struct CaptureStoreTests { } @Test func frozenMaterialSurvivesReopeningAndTargetChanges() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let url = directory.appendingPathComponent("capture.sqlite") let store = try CaptureStore(url: url) - let target = ConnectionTarget(host: "one.example", database: "inkcre", username: "inkcre_core") + let target = ConnectionTarget( + host: "one.example", database: "inkcre", username: "inkcre_core") let image = try #require( Data( base64Encoded: @@ -129,7 +141,8 @@ struct CaptureStoreTests { payload: CapturePayload( entry: .paste, materials: [ - Material(text: "原文\n中文 👩🏽‍💻 e\u{301}"), Material(image: image, mimeType: "image/png"), + Material(text: "原文\n中文 👩🏽‍💻 e\u{301}"), + Material(image: image, mimeType: "image/png"), ], annotation: "自己的想法" )) @@ -146,21 +159,27 @@ struct CaptureStoreTests { #expect(recovered.payload?.materials[1].data == image) #expect(recovered.payload?.annotation == "自己的想法") await #expect(throws: PeerError.self) { try await reopened.saveDraft(draft) } - await #expect(throws: PeerError.self) { _ = try await reopened.freeze(draft, target: target) } + await #expect(throws: PeerError.self) { + _ = try await reopened.freeze(draft, target: target) + } } @Test func confirmationAtomicallyRetainsIdentityAndClearsMaterial() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let url = directory.appendingPathComponent("capture.sqlite") let store = try CaptureStore(url: url) let draft = CaptureRecord(payload: CapturePayload(materials: [Material(text: "一次意图")])) _ = try await store.freeze( - draft, target: ConnectionTarget(host: "example.test", database: "inkcre", username: "inkcre_core") + draft, + target: ConnectionTarget( + host: "example.test", database: "inkcre", username: "inkcre_core") ) _ = try await store.markDispatch(draft.id) let result = CaptureResult( - submissionID: draft.id, rootBlockID: 123, committedAt: Date(timeIntervalSince1970: 1_780_000_000), + submissionID: draft.id, rootBlockID: 123, + committedAt: Date(timeIntervalSince1970: 1_780_000_000), availability: .deleted ) try await store.confirm(draft.id, result: result) @@ -173,10 +192,12 @@ struct CaptureStoreTests { } @Test func emptyDraftCannotEraseAnAlreadyFrozenIntention() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let store = try CaptureStore(url: directory.appendingPathComponent("capture.sqlite")) - let target = ConnectionTarget(host: "example.test", database: "inkcre", username: "inkcre_core") + let target = ConnectionTarget( + host: "example.test", database: "inkcre", username: "inkcre_core") await #expect(throws: PeerError.self) { _ = try await store.freeze(CaptureRecord(), target: target) } diff --git a/Tests/CaptureTests/LivePeerTests.swift b/Tests/CaptureTests/LivePeerTests.swift index 4bff258..f2c872c 100644 --- a/Tests/CaptureTests/LivePeerTests.swift +++ b/Tests/CaptureTests/LivePeerTests.swift @@ -6,131 +6,135 @@ import Testing @Test(.enabled(if: ProcessInfo.processInfo.environment["INKCRE_CAPTURE_TEST_DATABASE"] != nil)) func nativeTransactionCommitsOriginalImageOnceAndRecoversAfterRestart() 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 client = CaptureClient(database: database) - try await client.checkConnection(target, secret: password) - let pg = try await database.client(for: target, password: password) - struct Fixture: Decodable { let payload: CapturePayload } - let fixtureURL = try #require( - Bundle.module.url(forResource: "capture-v1", withExtension: "json", subdirectory: "Fixtures")) - let fixture = try CaptureJSON.decode([Fixture].self, from: Data(contentsOf: fixtureURL))[1] - let payload = CapturePayload( - entry: .paste, origin: fixture.payload.origin, materials: fixture.payload.materials, - annotation: fixture.payload.annotation) - var record = CaptureRecord(payload: payload) - record.target = target - let submission = record - async let a = client.submit(submission, secret: password) - async let b = client.submit(submission, secret: password) - async let c = client.submit(submission, secret: password) - let results = try await [a, b, c] - let result = try #require(results.first) - #expect(results.allSatisfy { $0 == result }) - let rows = try await pg.query( - """ - WITH RECURSIVE captured(id) AS (SELECT \(result.rootBlockID)::integer UNION - SELECT to_ FROM inkcre.relations JOIN captured ON from_=captured.id) - SELECT b.resolver,b.content,s.data FROM inkcre.blocks b JOIN captured USING(id) - LEFT JOIN inkcre.storage_blobs s ON s.id=CASE WHEN b.storage=-4 THEN (b.content::jsonb->>'blob_id')::uuid END - """) - var blockCount = 0 - for try await (resolver, content, bytes) in rows.decode((String, String, Data?).self) { - blockCount += 1 - if resolver == "core.image.v1" { #expect(bytes == payload.materials[1].data) } - if resolver == "core.text.v1" { - #expect([payload.materials[0].text, payload.annotation].contains(content)) + 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 client = CaptureClient(database: database) + try await client.checkConnection(target, secret: password) + let pg = try await database.client(for: target, password: password) + struct Fixture: Decodable { let payload: CapturePayload } + let fixtureURL = try #require( + Bundle.module.url( + forResource: "capture-v1", withExtension: "json", subdirectory: "Fixtures")) + let fixture = try CaptureJSON.decode([Fixture].self, from: Data(contentsOf: fixtureURL))[1] + let payload = CapturePayload( + entry: .paste, origin: fixture.payload.origin, materials: fixture.payload.materials, + annotation: fixture.payload.annotation) + var record = CaptureRecord(payload: payload) + record.target = target + let submission = record + async let a = client.submit(submission, secret: password) + async let b = client.submit(submission, secret: password) + async let c = client.submit(submission, secret: password) + let results = try await [a, b, c] + let result = try #require(results.first) + #expect(results.allSatisfy { $0 == result }) + let rows = try await pg.query( + """ + WITH RECURSIVE captured(id) AS (SELECT \(result.rootBlockID)::integer UNION + SELECT to_ FROM inkcre.relations JOIN captured ON from_=captured.id) + SELECT b.resolver,b.content,s.data FROM inkcre.blocks b JOIN captured USING(id) + LEFT JOIN inkcre.storage_blobs s ON s.id=CASE WHEN b.storage=-4 THEN (b.content::jsonb->>'blob_id')::uuid END + """) + var blockCount = 0 + for try await (resolver, content, bytes) in rows.decode((String, String, Data?).self) { + blockCount += 1 + if resolver == "core.image.v1" { #expect(bytes == payload.materials[1].data) } + if resolver == "core.text.v1" { + #expect([payload.materials[0].text, payload.annotation].contains(content)) + } } - } - #expect(blockCount == 5) + #expect(blockCount == 5) - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - let service = "dev.inkcre.native.acceptance.\(UUID().uuidString)" - let credentials = CredentialStore(service: service) - try credentials.save(password, for: target.id) - defer { - SecItemDelete( - [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service] - as CFDictionary) - try? FileManager.default.removeItem(at: directory) - } - // Commit has happened but the local process has not observed its response. - let store = try CaptureStore(url: directory.appendingPathComponent("capture.sqlite")) - _ = 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( - force: true) - let recovered = try #require(try await reopened.record(payload.submissionID)) - #expect(recovered.state == .confirmed && recovered.payload == nil) - #expect(recovered.result == result) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let service = "dev.inkcre.native.acceptance.\(UUID().uuidString)" + let credentials = CredentialStore(service: service) + try credentials.save(password, for: target.id) + defer { + SecItemDelete( + [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service] + as CFDictionary) + try? FileManager.default.removeItem(at: directory) + } + // Commit has happened but the local process has not observed its response. + let store = try CaptureStore(url: directory.appendingPathComponent("capture.sqlite")) + _ = 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( + force: true) + let recovered = try #require(try await reopened.record(payload.submissionID)) + #expect(recovered.state == .confirmed && recovered.payload == nil) + #expect(recovered.result == result) - var changed = payload - changed.annotation = "另一份材料" - var conflict = CaptureRecord(payload: changed) - conflict.target = target - await #expect(throws: PeerError.self) { _ = try await client.submit(conflict, secret: password) } + var changed = payload + changed.annotation = "另一份材料" + var conflict = CaptureRecord(payload: changed) + conflict.target = target + await #expect(throws: PeerError.self) { + _ = try await client.submit(conflict, secret: password) + } - let before = try await pg.query("SELECT count(*) FROM inkcre.blocks").decode(Int.self).first { - _ in true - } - await #expect(throws: (any Error).self) { - try await pg.withTransaction(logger: PeerDatabase.logger) { connection in - _ = try await InfoBase.persist( - blocks: [GraphBlock(id: -1, resolver: "core.text.v1", content: "must rollback")], - relations: [GraphRelation(from_: -1, to_: -999, content: "invalid")], blobs: [:], - on: connection) + let before = try await pg.query("SELECT count(*) FROM inkcre.blocks").decode(Int.self).first { + _ in true } - } - let after = try await pg.query("SELECT count(*) FROM inkcre.blocks").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) - do { - _ = try await client.submit(record, secret: password) - Issue.record("停用后不得继续共享写入") - } catch PeerError.needsAction(let message) { #expect(message.contains("停用")) } - await #expect(throws: PeerError.self) { - try await client.checkConnection(target, secret: password) - } - try await pg.query( - "DELETE FROM inkcre.extension_peer_participations WHERE peer_id=\(target.peerID)") - await #expect(throws: PeerError.self) { + await #expect(throws: (any Error).self) { + try await pg.withTransaction(logger: PeerDatabase.logger) { connection in + _ = try await InfoBase.persist( + blocks: [GraphBlock(id: -1, resolver: "core.text.v1", content: "must rollback")], + relations: [GraphRelation(from_: -1, to_: -999, content: "invalid")], blobs: [:], + on: connection) + } + } + let after = try await pg.query("SELECT count(*) FROM inkcre.blocks").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) + do { + _ = try await client.submit(record, secret: password) + Issue.record("停用后不得继续共享写入") + } catch PeerError.needsAction(let message) { #expect(message.contains("停用")) } + await #expect(throws: PeerError.self) { + try await client.checkConnection(target, secret: password) + } + try await pg.query( + "DELETE FROM inkcre.extension_peer_participations WHERE peer_id=\(target.peerID)") + await #expect(throws: PeerError.self) { + try await client.checkConnection(target, secret: password) + } + let removed = try await pg.query( + "SELECT count(*) FROM inkcre.extension_peer_participations WHERE peer_id=\(target.peerID)" + ).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 client.checkConnection(target, secret: password) - } - let removed = try await pg.query( - "SELECT count(*) FROM inkcre.extension_peer_participations WHERE peer_id=\(target.peerID)" - ).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 client.checkConnection(target, 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 pg.query( - "DELETE FROM inkcre.extension_peer_participations WHERE peer_id=\(first.peerID)") - await #expect(throws: PeerError.self) { + 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 pg.query( + "DELETE FROM inkcre.extension_peer_participations WHERE peer_id=\(first.peerID)") + await #expect(throws: PeerError.self) { + try await client.checkConnection(first, secret: password) + } + let removedBeforeFirstJoin = try await pg.query( + "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 client.checkConnection(first, secret: password) - } - let removedBeforeFirstJoin = try await pg.query( - "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 client.checkConnection(first, secret: password) - await database.shutdown() + await database.shutdown() } diff --git a/Tests/HostTests/HostInterfaceTests.swift b/Tests/HostTests/HostInterfaceTests.swift index ce9b41b..5eb9811 100644 --- a/Tests/HostTests/HostInterfaceTests.swift +++ b/Tests/HostTests/HostInterfaceTests.swift @@ -1,15 +1,17 @@ import AppKit import PeerCore import SwiftUI -@testable import CaptureMac import Testing + +@testable import CaptureMac @testable import InKCreApp @Suite(.serialized) @MainActor struct HostInterfaceTests { @Test func nativeSidebarShortcutAndSettingsTabsRemainAvailableWithoutCapture() async throws { _ = NSApplication.shared - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) let domain = "dev.inkcre.host-tests.\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: domain)) defer { @@ -17,18 +19,26 @@ struct HostInterfaceTests { try? FileManager.default.removeItem(at: directory) } defaults.set(false, forKey: "extensions.inkcre/capture.enabled") - let host = PeerHost(context: PeerHostContext(identity: domain, directory: directory), defaults: defaults) + let host = PeerHost( + context: PeerHostContext(identity: domain, directory: directory), defaults: defaults) let previousMenu = NSApp.mainMenu let previousWindowsMenu = NSApp.windowsMenu - defer { NSApp.mainMenu = previousMenu; NSApp.windowsMenu = previousWindowsMenu } + defer { + NSApp.mainMenu = previousMenu + NSApp.windowsMenu = previousWindowsMenu + } host.installHostMenu() let menu = try #require(NSApp.mainMenu) let viewMenu = try #require(menu.items.first(where: { $0.title == "显示" })?.submenu) - let sidebar = try #require(viewMenu.items.first(where: { $0.action == #selector(NSSplitViewController.toggleSidebar(_:)) })) + let sidebar = try #require( + viewMenu.items.first(where: { + $0.action == #selector(NSSplitViewController.toggleSidebar(_:)) + })) #expect(sidebar.keyEquivalent == "s") #expect(sidebar.keyEquivalentModifierMask == [.command, .option]) #expect(sidebar.target == nil) - let settings = try #require(menu.items.first?.submenu?.items.first(where: { $0.keyEquivalent == "," })) + let settings = try #require( + menu.items.first?.submenu?.items.first(where: { $0.keyEquivalent == "," })) #expect(settings.keyEquivalentModifierMask == .command) #expect(host.validateUserInterfaceItem(settings)) let window = PeerSettingsWindow(host: host) @@ -48,14 +58,18 @@ struct HostInterfaceTests { let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds)) view.cacheDisplay(in: view.bounds, to: bitmap) if let path = ProcessInfo.processInfo.environment["INKCRE_LAYOUT_EVIDENCE_DIR"] { - try #require(bitmap.representation(using: .png, properties: [:])).write(to: - URL(fileURLWithPath: path).appendingPathComponent("settings-\(pane)-\(appearance.rawValue).png")) + try #require(bitmap.representation(using: .png, properties: [:])).write( + to: + URL(fileURLWithPath: path).appendingPathComponent( + "settings-\(pane)-\(appearance.rawValue).png")) } } } - let captureModel = try CaptureModel(directory: directory.appendingPathComponent("capture"), identity: domain) + let captureModel = try CaptureModel( + directory: directory.appendingPathComponent("capture"), identity: domain) defer { captureModel.cancelWork() } - window.replaceCaptureSettings(with: NSHostingController(rootView: CaptureSettingsView(model: captureModel))) + window.replaceCaptureSettings( + with: NSHostingController(rootView: CaptureSettingsView(model: captureModel))) window.select(.capture) for appearance in [NSAppearance.Name.aqua, .darkAqua] { window.appearance = NSAppearance(named: appearance) @@ -66,8 +80,10 @@ struct HostInterfaceTests { let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds)) view.cacheDisplay(in: view.bounds, to: bitmap) if let path = ProcessInfo.processInfo.environment["INKCRE_LAYOUT_EVIDENCE_DIR"] { - try #require(bitmap.representation(using: .png, properties: [:])).write(to: - URL(fileURLWithPath: path).appendingPathComponent("settings-capture-enabled-\(appearance.rawValue).png")) + try #require(bitmap.representation(using: .png, properties: [:])).write( + to: + URL(fileURLWithPath: path).appendingPathComponent( + "settings-capture-enabled-\(appearance.rawValue).png")) } } #expect(!window.isVisible) diff --git a/extensions/capture/Package.swift b/extensions/capture/Package.swift index 0df7def..a2afa13 100644 --- a/extensions/capture/Package.swift +++ b/extensions/capture/Package.swift @@ -11,7 +11,8 @@ let package = Package( targets: [ .systemLibrary(name: "CSQLite"), .target( - name: "Capture", dependencies: ["CSQLite", .product(name: "PeerCore", package: "PeerCore")], + name: "Capture", + dependencies: ["CSQLite", .product(name: "PeerCore", package: "PeerCore")], resources: [.copy("Resources/extension.json")]), .target( name: "CaptureMac", diff --git a/extensions/capture/Sources/Capture/CaptureClient.swift b/extensions/capture/Sources/Capture/CaptureClient.swift index c191340..fc9cf54 100644 --- a/extensions/capture/Sources/Capture/CaptureClient.swift +++ b/extensions/capture/Sources/Capture/CaptureClient.swift @@ -12,7 +12,9 @@ public struct CaptureClient: CaptureTransport { public static let descriptor: NativeExtensionDescriptor = { let bundle: Bundle if Bundle.main.bundleURL.pathExtension == "app" { - guard let url = Bundle.main.url(forResource: "InKCreCapture_Capture", withExtension: "bundle"), + guard + let url = Bundle.main.url( + forResource: "InKCreCapture_Capture", withExtension: "bundle"), let packaged = Bundle(url: url) else { preconditionFailure("Missing packaged Capture distribution") @@ -38,7 +40,8 @@ public struct CaptureClient: CaptureTransport { try await database.join(target, password: secret, extension: Self.descriptor) let client = try await database.client(for: target, password: secret) _ = try await client.query( - "SELECT submission_id,payload_digest,graph_digest FROM inkcre.capture_receipts LIMIT 0") + "SELECT submission_id,payload_digest,graph_digest FROM inkcre.capture_receipts LIMIT 0" + ) } catch { throw PeerDatabase.failure(error, dispatched: false) } } @@ -48,7 +51,8 @@ public struct CaptureClient: CaptureTransport { } 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 Self.receipt( + record.id, digest: payload.canonicalDigest(), client: client) } catch { throw PeerDatabase.failure(error, dispatched: false) } } @@ -79,7 +83,8 @@ public struct CaptureClient: CaptureTransport { }.joined() do { let client = try await database.client(for: target, password: secret) - let result = try await client.withTransaction(logger: PeerDatabase.logger) { connection in + 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. @@ -104,10 +109,14 @@ public struct CaptureClient: CaptureTransport { 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) }), + 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("采集图缺少根。") } + 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) diff --git a/extensions/capture/Sources/Capture/CaptureDigest.swift b/extensions/capture/Sources/Capture/CaptureDigest.swift index 1ae1660..13af159 100644 --- a/extensions/capture/Sources/Capture/CaptureDigest.swift +++ b/extensions/capture/Sources/Capture/CaptureDigest.swift @@ -29,7 +29,8 @@ extension CapturePayload { throw PeerError.invalid("图片材料格式不正确。") } return [ - "kind": "image", "mime_type": mime, "filename": material.filename as Any? ?? null, + "kind": "image", "mime_type": mime, + "filename": material.filename as Any? ?? null, "data_sha256": Self.sha256(data), ] } diff --git a/extensions/capture/Sources/Capture/CaptureGraph.swift b/extensions/capture/Sources/Capture/CaptureGraph.swift index 5284de8..89bb406 100644 --- a/extensions/capture/Sources/Capture/CaptureGraph.swift +++ b/extensions/capture/Sources/Capture/CaptureGraph.swift @@ -2,6 +2,8 @@ import Foundation 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 @@ -37,7 +39,9 @@ struct CaptureCommit: Encodable, Sendable { GraphBlock( id: -1, resolver: "extensions.capture.capture.v1", content: try json( - Metadata(captured_at: payload.capturedAt, entry: payload.entry, origin: payload.origin)) + Metadata( + captured_at: payload.capturedAt, entry: payload.entry, + origin: payload.origin)) ) ]) for (index, material) in payload.materials.enumerated() { diff --git a/extensions/capture/Sources/Capture/CaptureQueue.swift b/extensions/capture/Sources/Capture/CaptureQueue.swift index b2aba93..de7b55f 100644 --- a/extensions/capture/Sources/Capture/CaptureQueue.swift +++ b/extensions/capture/Sources/Capture/CaptureQueue.swift @@ -10,7 +10,8 @@ public actor CaptureQueue { private var idleWaiters: [CheckedContinuation] = [] public init( - store: CaptureStore, credentials: CredentialStore, client: any CaptureTransport = CaptureClient() + store: CaptureStore, credentials: CredentialStore, + client: any CaptureTransport = CaptureClient() ) { self.store = store self.credentials = credentials @@ -34,7 +35,9 @@ public actor CaptureQueue { for item in try await store.records() where [.pending, .unknown].contains(item.state) { guard !isStopped, !Task.isCancelled else { return } let delay = min(300.0, pow(2.0, Double(min(item.attempts, 8))) * 2) - if !force && item.attempts > 0 && Date().timeIntervalSince(item.updatedAt) < delay { continue } + if !force && item.attempts > 0 && Date().timeIntervalSince(item.updatedAt) < delay { + continue + } guard let target = item.target else { continue } do { let secret = try credentials.read(for: target.id) diff --git a/extensions/capture/Sources/Capture/CaptureStore.swift b/extensions/capture/Sources/Capture/CaptureStore.swift index 5c71ffb..1980775 100644 --- a/extensions/capture/Sources/Capture/CaptureStore.swift +++ b/extensions/capture/Sources/Capture/CaptureStore.swift @@ -9,7 +9,8 @@ private final class SQLiteConnection: @unchecked Sendable { init(url: URL) throws { var database: OpaquePointer? let status = sqlite3_open_v2( - url.path, &database, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX, nil + url.path, &database, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX, + nil ) guard status == SQLITE_OK, let database else { if let database { sqlite3_close(database) } @@ -213,7 +214,7 @@ public actor CaptureStore { public func updateWebAddress(_ id: UUID, address: String) throws -> CaptureRecord { guard var record = try record(id), record.state == .confirmed, var target = record.target, - let url = URL(string: address.trimmingCharacters(in: .whitespacesAndNewlines)) + let url = URL(string: address.trimmingCharacters(in: .whitespacesAndNewlines)) else { throw PeerError.invalid("请输入有效的 Web 地址,且仅为已收入记录配置地址。") } // The display address can change; the frozen database destination and receipt cannot. target.webURL = url @@ -240,7 +241,8 @@ public actor CaptureStore { private func save(_ record: CaptureRecord) throws { let encoded = try CaptureJSON.encode(record) - try database.statement("INSERT OR REPLACE INTO records(id,body) VALUES (?,?)") { statement in + try database.statement("INSERT OR REPLACE INTO records(id,body) VALUES (?,?)") { + statement in sqlite3_bind_text(statement, 1, record.id.uuidString, -1, transient) let result = encoded.withUnsafeBytes { bytes in sqlite3_bind_blob(statement, 2, bytes.baseAddress, Int32(bytes.count), transient) diff --git a/extensions/capture/Sources/Capture/Models.swift b/extensions/capture/Sources/Capture/Models.swift index 2612afa..26ca56d 100644 --- a/extensions/capture/Sources/Capture/Models.swift +++ b/extensions/capture/Sources/Capture/Models.swift @@ -91,7 +91,9 @@ public struct CapturePayload: Codable, Sendable { throw PeerError.invalid("先写下想法,或加入一份材料。") } guard materials.count <= 128 else { throw PeerError.invalid("材料数量超过 128 项。") } - for (value, limit) in [(origin?.applicationID, 1024), (origin?.title, 4096), (origin?.url, 8192)] { + for (value, limit) in [ + (origin?.applicationID, 1024), (origin?.title, 4096), (origin?.url, 8192), + ] { guard (value?.unicodeScalars.count ?? 0) <= limit else { throw PeerError.invalid("来源信息过长,请缩短后收入。") } @@ -108,7 +110,8 @@ public struct CapturePayload: Codable, Sendable { } case .image: guard let bytes = material.data, !bytes.isEmpty, - ["image/png", "image/jpeg", "image/gif", "image/webp"].contains(material.mimeType), + ["image/png", "image/jpeg", "image/gif", "image/webp"].contains( + material.mimeType), material.text == nil else { throw PeerError.invalid("图片为空或格式不支持。") @@ -119,7 +122,8 @@ public struct CapturePayload: Codable, Sendable { CGImageSourceGetStatus(source) == .statusComplete, let identifier = CGImageSourceGetType(source) as String?, UTType(identifier)?.preferredMIMEType == material.mimeType, - let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], + let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) + as? [CFString: Any], let width = properties[kCGImagePropertyPixelWidth] as? Int, let height = properties[kCGImagePropertyPixelHeight] as? Int, width > 0, height > 0, width <= 89_478_485 / height, @@ -140,7 +144,8 @@ public struct CaptureResult: Codable, Sendable, Equatable { 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, availability: Availability) + { self.submissionID = submissionID self.rootBlockID = rootBlockID let formatter = ISO8601DateFormatter() diff --git a/extensions/capture/Sources/CaptureMac/BrandAssets.swift b/extensions/capture/Sources/CaptureMac/BrandAssets.swift index 1a3f3c4..44b0dd7 100644 --- a/extensions/capture/Sources/CaptureMac/BrandAssets.swift +++ b/extensions/capture/Sources/CaptureMac/BrandAssets.swift @@ -14,16 +14,21 @@ enum BrandAssets { private static let resources: Bundle = { // SwiftPM's generated accessor searches beside the executable; a macOS app packages resources in Contents/Resources. if Bundle.main.bundleURL.pathExtension == "app" { - guard let url = Bundle.main.url(forResource: "InKCreCapture_CaptureMac", withExtension: "bundle"), - let bundle = Bundle(url: url) else { preconditionFailure("Missing packaged InKCre resources") } + guard + let url = Bundle.main.url( + forResource: "InKCreCapture_CaptureMac", withExtension: "bundle"), + let bundle = Bundle(url: url) + else { preconditionFailure("Missing packaged InKCre resources") } return bundle } return Bundle.module }() private static func image(_ name: String, size: CGFloat) -> NSImage { - guard let url = resources.url(forResource: name, withExtension: "png", subdirectory: "Brand"), - let image = NSImage(contentsOf: url) else { preconditionFailure("Missing brand resource: \(name)") } + guard + let url = resources.url(forResource: name, withExtension: "png", subdirectory: "Brand"), + let image = NSImage(contentsOf: url) + else { preconditionFailure("Missing brand resource: \(name)") } image.size = NSSize(width: size, height: size) return image } diff --git a/extensions/capture/Sources/CaptureMac/CaptureEditor.swift b/extensions/capture/Sources/CaptureMac/CaptureEditor.swift index 09ae65b..7de0bf0 100644 --- a/extensions/capture/Sources/CaptureMac/CaptureEditor.swift +++ b/extensions/capture/Sources/CaptureMac/CaptureEditor.swift @@ -53,17 +53,29 @@ struct CaptureEditor: View { ScrollView { VStack(alignment: .leading, spacing: 12) { Text("本次材料").font(.subheadline).foregroundStyle(.secondary) - ForEach(Array((model.draft.payload?.materials ?? []).enumerated()), id: \.element.id) { index, material in - if !isManual || material.id != model.draft.payload?.materials.first(where: { $0.kind == .text })?.id { + ForEach( + Array((model.draft.payload?.materials ?? []).enumerated()), + id: \.element.id + ) { index, material in + if !isManual + || material.id + != model.draft.payload?.materials.first(where: { + $0.kind == .text + })?.id + { VStack(alignment: .leading, spacing: 6) { HStack { - Text("材料 \(index + 1)").font(.caption).foregroundStyle(.secondary) + Text("材料 \(index + 1)").font(.caption).foregroundStyle( + .secondary) Spacer() - Button("移除", systemImage: "xmark") { model.requestRemoveMaterial(material.id) } - .accessibilityLabel("移除第 \(index + 1) 项材料") + Button("移除", systemImage: "xmark") { + model.requestRemoveMaterial(material.id) + } + .accessibilityLabel("移除第 \(index + 1) 项材料") } if material.kind == .text { - Text(material.text ?? "").textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading) + Text(material.text ?? "").textSelection(.enabled).frame( + maxWidth: .infinity, alignment: .leading) } else { CaptureImagePreview(material: material) } @@ -82,15 +94,19 @@ struct CaptureEditor: View { } if !isManual || showsAnnotation || !model.annotation.isEmpty { DisclosureGroup("补充想法(可选)", isExpanded: $showsAnnotation) { - TextEditor(text: Binding(get: { model.annotation }, set: { model.annotation = $0 })) - .focused($focusedField, equals: .annotation) - .frame(height: 70).accessibilityLabel("用户附注,与原文分别保存") + TextEditor( + text: Binding(get: { model.annotation }, set: { model.annotation = $0 }) + ) + .focused($focusedField, equals: .annotation) + .frame(height: 70).accessibilityLabel("用户附注,与原文分别保存") } } Divider() if let record = model.submittedRecord { HStack { - Label("上次采集:\(record.statusLabel)", systemImage: record.state == .confirmed ? "checkmark.circle" : "tray") + Label( + "上次采集:\(record.statusLabel)", + systemImage: record.state == .confirmed ? "checkmark.circle" : "tray") Spacer() Button("查看记录") { model.showRecord(record) } }.font(.callout) @@ -109,8 +125,10 @@ struct CaptureEditor: View { if isQuickCapture { Button("取消本次") { model.cancelCapture() } } else { - Button("删除草稿", systemImage: "trash", role: .destructive) { model.requestDiscard(model.draft) } - .disabled(!model.hasContent) + Button("删除草稿", systemImage: "trash", role: .destructive) { + model.requestDiscard(model.draft) + } + .disabled(!model.hasContent) } Spacer() Button("导出材料") { model.export(model.draft) }.disabled(!model.hasContent) @@ -118,21 +136,31 @@ struct CaptureEditor: View { .buttonStyle(.borderedProminent).disabled(!model.hasContent || model.submitting) } }.padding().disabled(model.submitting) - .onAppear { showsAnnotation = !model.annotation.isEmpty; focusEditor() } - .onChange(of: model.draft.id) { showsAnnotation = !model.annotation.isEmpty; focusEditor() } - .onChange(of: showsAnnotation) { _, expanded in if expanded { focusedField = .annotation } } + .onAppear { + showsAnnotation = !model.annotation.isEmpty + focusEditor() + } + .onChange(of: model.draft.id) { + showsAnnotation = !model.annotation.isEmpty + focusEditor() + } + .onChange(of: showsAnnotation) { _, expanded in + if expanded { focusedField = .annotation } + } .onChange(of: model.submitting) { _, busy in if !busy { focusEditor() } } } private var standaloneImage: Capture.Material? { guard let payload = model.draft.payload, payload.materials.count == 1, - payload.materials[0].kind == .image, payload.origin == nil else { return nil } + payload.materials[0].kind == .image, payload.origin == nil + else { return nil } return payload.materials[0] } private var additionalMaterials: Bool { let materials = model.draft.payload?.materials ?? [] - return materials.contains { $0.kind == .image } || materials.filter { $0.kind == .text }.count > 1 + return materials.contains { $0.kind == .image } + || materials.filter { $0.kind == .text }.count > 1 } private func focusEditor() { diff --git a/extensions/capture/Sources/CaptureMac/CaptureExtension.swift b/extensions/capture/Sources/CaptureMac/CaptureExtension.swift index e6c9b33..0243926 100644 --- a/extensions/capture/Sources/CaptureMac/CaptureExtension.swift +++ b/extensions/capture/Sources/CaptureMac/CaptureExtension.swift @@ -14,7 +14,9 @@ final class CapturePanel: NSPanel { } @MainActor -public final class CaptureExtension: NSObject, NativeExtension, NSWindowDelegate, NSUserInterfaceValidations { +public final class CaptureExtension: NSObject, NativeExtension, NSWindowDelegate, + NSUserInterfaceValidations +{ public let descriptor = CaptureClient.descriptor private let context: PeerHostContext public init(context: PeerHostContext) { @@ -62,9 +64,11 @@ public final class CaptureExtension: NSObject, NativeExtension, NSWindowDelegate public func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool { guard canChangeCaptureContext, let model, !model.submitting, - model.shortcuts.recording == nil else { return false } + model.shortcuts.recording == nil + else { return false } if item.action == #selector(performShortcut(_:)), let item = item as? NSMenuItem, - let action = ShortcutAction(rawValue: UInt32(item.tag)) { + let action = ShortcutAction(rawValue: UInt32(item.tag)) + { return canPerform(action, in: NSApp.keyWindow) } return model.error == nil && model.removalRequest == nil @@ -106,7 +110,9 @@ public final class CaptureExtension: NSObject, NativeExtension, NSWindowDelegate model.dismissQuickCapture = { [weak self] in self?.hide() } model.showMain = { [weak self] in self?.showMain() } model.showQuick = { [weak self] in self?.showQuick() } - model.editConnection = { [weak self] target, completion in self?.editConnection?(target, completion) } + model.editConnection = { [weak self] target, completion in + self?.editConnection?(target, completion) + } model.takeScreenshot = { [weak self] in self?.screenshot() } model.shortcuts.isCaptureWindow = { [weak panel, weak mainWindow] window in window === panel || window === mainWindow @@ -157,22 +163,27 @@ public final class CaptureExtension: NSObject, NativeExtension, NSWindowDelegate } menu.addItem(.separator()) menu.addItem( - withTitle: "退出 InKCre", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") + withTitle: "退出 InKCre", action: #selector(NSApplication.terminate(_:)), + keyEquivalent: "q") item.menu = menu statusItem = item let commands = NSMenuItem(title: "采集", action: nil, keyEquivalent: "") let capture = NSMenu(title: "采集") - capture.addItem(withTitle: "新想法", action: #selector(newThought), keyEquivalent: "n").target = self + capture.addItem(withTitle: "新想法", action: #selector(newThought), keyEquivalent: "n") + .target = self for action in ShortcutAction.allCases { - let item = NSMenuItem(title: action.title, action: #selector(performShortcut(_:)), keyEquivalent: "") + let item = NSMenuItem( + title: action.title, action: #selector(performShortcut(_:)), keyEquivalent: "") item.target = self item.tag = Int(action.rawValue) capture.addItem(item) } capture.addItem(.separator()) - capture.addItem(withTitle: "采集记录", action: #selector(showQueue), keyEquivalent: "").target = self - capture.addItem(withTitle: "采集设置…", action: #selector(showSettings), keyEquivalent: "").target = self + capture.addItem(withTitle: "采集记录", action: #selector(showQueue), keyEquivalent: "").target = + self + capture.addItem(withTitle: "采集设置…", action: #selector(showSettings), keyEquivalent: "") + .target = self commands.submenu = capture NSApp.mainMenu?.insertItem(commands, at: 1) commandItem = commands @@ -186,9 +197,12 @@ public final class CaptureExtension: NSObject, NativeExtension, NSWindowDelegate } private func canPerform(_ action: ShortcutAction, in window: NSWindow?) -> Bool { - guard canChangeCaptureContext, let model, model.shortcuts.recording == nil else { return false } + guard canChangeCaptureContext, let model, model.shortcuts.recording == nil else { + return false + } let quick = window === panel && model.editorOwner == .quick - let editor = quick || (window === mainWindow && model.pane == .capture && model.editorOwner == .main) + let editor = + quick || (window === mainWindow && model.pane == .capture && model.editorOwner == .main) return model.canPerform(action, isEditor: editor, isQuickPanel: quick) } @@ -217,7 +231,9 @@ public final class CaptureExtension: NSObject, NativeExtension, NSWindowDelegate let bundleID = application.bundleIdentifier selectionTask?.cancel() selectionTask = Task { - let snapshot = await Task.detached { SelectedText.read(pid: pid, applicationID: bundleID) }.value + let snapshot = await Task.detached { + SelectedText.read(pid: pid, applicationID: bundleID) + }.value guard model.selectionRequest == request, NSWorkspace.shared.frontmostApplication?.processIdentifier == pid else { return } @@ -233,14 +249,17 @@ public final class CaptureExtension: NSObject, NativeExtension, NSWindowDelegate else { return } if let text { guard - await Task.detached(operation: { SelectedText.isCurrent(pid: pid, snapshot: snapshot) }) - .value, + await Task.detached(operation: { + SelectedText.isCurrent(pid: pid, snapshot: snapshot) + }) + .value, model.selectionRequest == request, NSWorkspace.shared.frontmostApplication?.processIdentifier == pid else { return } await model.receive( CapturePayload( - entry: .selection, origin: snapshot.origin, materials: [Material(text: text)]), + entry: .selection, origin: snapshot.origin, + materials: [Material(text: text)]), request: request ) } else { @@ -303,7 +322,8 @@ public final class CaptureExtension: NSObject, NativeExtension, NSWindowDelegate model.perform { await model.receive( CapturePayload( - entry: .screenshot, materials: [Material(image: data, mimeType: "image/png")]), + entry: .screenshot, + materials: [Material(image: data, mimeType: "image/png")]), request: request ) } diff --git a/extensions/capture/Sources/CaptureMac/CaptureImagePreview.swift b/extensions/capture/Sources/CaptureMac/CaptureImagePreview.swift index e0a27f4..b5a834b 100644 --- a/extensions/capture/Sources/CaptureMac/CaptureImagePreview.swift +++ b/extensions/capture/Sources/CaptureMac/CaptureImagePreview.swift @@ -13,8 +13,9 @@ struct CaptureImagePreview: View { var body: some View { VStack(alignment: .leading, spacing: 4) { Button { - do { previewURL = try Self.makePreviewFile(material) } - catch { issue = error.localizedDescription } + do { previewURL = try Self.makePreviewFile(material) } catch { + issue = error.localizedDescription + } } label: { VStack(alignment: .leading, spacing: 4) { Label("查看原图", systemImage: "eye").font(.caption).foregroundStyle(.tint) @@ -40,11 +41,14 @@ struct CaptureImagePreview: View { static func makePreviewFile(_ material: Capture.Material) throws -> URL { guard let data = material.data, let mime = material.mimeType, - let suffix = UTType(mimeType: mime)?.preferredFilenameExtension + let suffix = UTType(mimeType: mime)?.preferredFilenameExtension else { throw CocoaError(.fileReadCorruptFile) } let url = FileManager.default.temporaryDirectory - .appendingPathComponent("inkcre-preview-\(UUID().uuidString)").appendingPathExtension(suffix) - guard FileManager.default.createFile(atPath: url.path, contents: data, attributes: [.posixPermissions: 0o600]) + .appendingPathComponent("inkcre-preview-\(UUID().uuidString)").appendingPathExtension( + suffix) + guard + FileManager.default.createFile( + atPath: url.path, contents: data, attributes: [.posixPermissions: 0o600]) else { throw CocoaError(.fileWriteUnknown) } return url } diff --git a/extensions/capture/Sources/CaptureMac/CaptureModel.swift b/extensions/capture/Sources/CaptureMac/CaptureModel.swift index 53999c9..2951e52 100644 --- a/extensions/capture/Sources/CaptureMac/CaptureModel.swift +++ b/extensions/capture/Sources/CaptureMac/CaptureModel.swift @@ -78,10 +78,13 @@ final class CaptureModel: ObservableObject { private var actions: [UUID: Task] = [:] private var stopping = false - init(directory: URL, identity: String, connections: ConnectionSettings? = nil, - client: any CaptureTransport = CaptureClient()) throws { + init( + directory: URL, identity: String, connections: ConnectionSettings? = nil, + client: any CaptureTransport = CaptureClient() + ) throws { self.connections = - connections ?? ConnectionSettings(url: directory.appendingPathComponent("connection.json")) + connections + ?? ConnectionSettings(url: directory.appendingPathComponent("connection.json")) shortcuts = try GlobalShortcuts() store = try CaptureStore(url: directory.appendingPathComponent("capture.sqlite")) credentials = CredentialStore(service: "\(identity).connections") @@ -307,18 +310,23 @@ final class CaptureModel: ObservableObject { guard let connection else { let draftID = draft.id let owner = editorOwner - editConnection?(nil, { [weak self] in - guard let self, !self.stopping, self.draft.id == draftID else { return } - self.editorOwner = owner - self.captureNotice = self.connection == nil - ? "连接信息库后即可收入,也可以继续编辑。" - : "连接已配置,请确认内容后点击收入。" - self.revealEditor() - }) + editConnection?( + nil, + { [weak self] in + guard let self, !self.stopping, self.draft.id == draftID else { return } + self.editorOwner = owner + self.captureNotice = + self.connection == nil + ? "连接信息库后即可收入,也可以继续编辑。" + : "连接已配置,请确认内容后点击收入。" + self.revealEditor() + }) return } // An input method owns Return until its marked text is committed. - if let editor = NSApp.keyWindow?.firstResponder as? NSTextView, editor.hasMarkedText() { return } + if let editor = NSApp.keyWindow?.firstResponder as? NSTextView, editor.hasMarkedText() { + return + } submitting = true saveTask?.cancel() perform { [self] in @@ -455,8 +463,8 @@ final class CaptureModel: ObservableObject { func updatePassword(_ password: String, for record: CaptureRecord) async throws { guard !password.isEmpty, - let current = try await store.record(record.id), - let target = current.target, current.state != .draft, current.state != .confirmed + let current = try await store.record(record.id), + let target = current.target, current.state != .draft, current.state != .confirmed else { throw PeerError.invalid("这条记录已变更,请关闭后重新查看。") } try credentials.save(password, for: target.id) try await store.retry(current.id) @@ -464,7 +472,9 @@ final class CaptureModel: ObservableObject { perform { [self] in await sendPending(force: true) } } - func updateWebAddress(_ address: String, for record: CaptureRecord) async throws -> CaptureRecord { + func updateWebAddress(_ address: String, for record: CaptureRecord) async throws + -> CaptureRecord + { let updated = try await store.updateWebAddress(record.id, address: address) try await refresh() return updated @@ -516,7 +526,11 @@ final class CaptureModel: ObservableObject { checkingConnection = true let revision = connectionRevision connectionCheck = Task { - defer { if !Task.isCancelled && connectionRevision == revision { checkingConnection = false } } + defer { + if !Task.isCancelled && connectionRevision == revision { + checkingConnection = false + } + } do { try await CaptureClient().checkConnection(target, secret: secret) guard !Task.isCancelled, connectionRevision == revision else { return } @@ -530,7 +544,9 @@ final class CaptureModel: ObservableObject { } func resultURL(for record: CaptureRecord) -> URL? { - guard let result = record.result, result.availability == .available, let target = record.target else { return nil } + guard let result = record.result, result.availability == .available, + let target = record.target + else { return nil } let base = target.webURL ?? (connection?.peerID == target.peerID ? connection?.webURL : nil) guard var url = base else { return nil } for part in ["info-base", "list", "blocks", String(result.rootBlockID), "content"] { diff --git a/extensions/capture/Sources/CaptureMac/CaptureRecordSettings.swift b/extensions/capture/Sources/CaptureMac/CaptureRecordSettings.swift index 17a4ca3..1708403 100644 --- a/extensions/capture/Sources/CaptureMac/CaptureRecordSettings.swift +++ b/extensions/capture/Sources/CaptureMac/CaptureRecordSettings.swift @@ -29,7 +29,8 @@ struct CaptureRecordSettings: View { Text("密码保存到原连接的 Keychain 项,并继续收入这条记录。数据库目标和当前使用的信息库不会改变。") .font(.caption).foregroundStyle(.secondary) } else { - TextField("Web 地址,例如 https://inkcre.example.com", text: $value).focused($inputFocused) + TextField("Web 地址,例如 https://inkcre.example.com", text: $value).focused( + $inputFocused) Text("填写该信息库对应的 InKCre Web 地址,用于打开已收入内容。此操作不会更换当前连接。") .font(.caption).foregroundStyle(.secondary) } diff --git a/extensions/capture/Sources/CaptureMac/CaptureRecordsView.swift b/extensions/capture/Sources/CaptureMac/CaptureRecordsView.swift index e4e2f4e..fa32e45 100644 --- a/extensions/capture/Sources/CaptureMac/CaptureRecordsView.swift +++ b/extensions/capture/Sources/CaptureMac/CaptureRecordsView.swift @@ -10,9 +10,11 @@ struct CaptureRecordsView: View { var body: some View { Group { if model.records.isEmpty { - ContentUnavailableView("还没有采集记录", systemImage: "tray", - description: Text("草稿、待收入和已收入的记录会显示在这里。")) - .frame(maxWidth: .infinity, maxHeight: .infinity) + ContentUnavailableView( + "还没有采集记录", systemImage: "tray", + description: Text("草稿、待收入和已收入的记录会显示在这里。") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) } else { ScrollViewReader { proxy in ScrollView { @@ -22,15 +24,24 @@ struct CaptureRecordsView: View { HStack { Text(record.statusLabel).font(.headline) Spacer() - Text(record.updatedAt, format: .dateTime.month().day().hour().minute()).font(.caption).foregroundStyle(.secondary) + Text( + record.updatedAt, + format: .dateTime.month().day().hour().minute() + ).font(.caption).foregroundStyle(.secondary) } - if let text = record.payload?.materials.first(where: { $0.kind == .text })?.text { + if let text = record.payload?.materials.first(where: { + $0.kind == .text + })?.text { Text(text).lineLimit(3) - } else if let material = record.payload?.materials.first(where: { $0.kind == .image }), - material.data != nil { + } else if let material = record.payload?.materials.first( + where: { $0.kind == .image }), + material.data != nil + { CaptureImagePreview(material: material, height: 100) } - if let annotation = record.payload?.annotation, !annotation.isEmpty { + if let annotation = record.payload?.annotation, + !annotation.isEmpty + { Text(annotation).lineLimit(3).foregroundStyle(.secondary) } if let payload = record.payload, record.state != .draft { @@ -38,40 +49,61 @@ struct CaptureRecordsView: View { CaptureRecordMaterials(payload: payload) } } - if let message = record.message { Text(message).font(.caption).foregroundStyle(.secondary) } - if let target = record.target { Text(target.name).font(.caption).foregroundStyle(.secondary) } - if let result = record.result { Text("Block \(result.rootBlockID)").font(.caption).textSelection(.enabled) } + if let message = record.message { + Text(message).font(.caption).foregroundStyle(.secondary) + } + if let target = record.target { + Text(target.name).font(.caption).foregroundStyle(.secondary) + } + if let result = record.result { + Text("Block \(result.rootBlockID)").font(.caption) + .textSelection(.enabled) + } HStack { if record.state == .draft { Button("继续编辑") { model.resume(record) } } else if record.state == .confirmed { if record.result?.availability == .deleted { - Text("共享内容已删除").font(.caption).foregroundStyle(.secondary) + Text("共享内容已删除").font(.caption).foregroundStyle( + .secondary) } else if model.resultURL(for: record) == nil { Button("配置 Web 地址…") { webRecord = record } } else { Button("打开") { model.openResult(record) } } } else { - Button(record.state == .cancelled ? "继续收入" : "立即重试") { model.retry(record) } + Button(record.state == .cancelled ? "继续收入" : "立即重试") { + model.retry(record) + } if record.state != .cancelled { Button("取消收入") { model.cancel(record) } } Button("更新密码…") { passwordRecord = record } } - Button("删除记录", role: .destructive) { model.requestDiscard(record) } - if record.payload != nil { Button("导出材料") { model.export(record) } } + Button("删除记录", role: .destructive) { + model.requestDiscard(record) + } + if record.payload != nil { + Button("导出材料") { model.export(record) } + } } } .padding(8) - .background(model.selectedRecordID == record.id ? Color.accentColor.opacity(0.08) : Color.clear) + .background( + model.selectedRecordID == record.id + ? Color.accentColor.opacity(0.08) : Color.clear + ) .id(record.id) Divider() } }.padding() } - .onAppear { if let id = model.selectedRecordID { proxy.scrollTo(id, anchor: .top) } } - .onChange(of: model.selectedRecordID) { _, id in if let id { proxy.scrollTo(id, anchor: .top) } } + .onAppear { + if let id = model.selectedRecordID { proxy.scrollTo(id, anchor: .top) } + } + .onChange(of: model.selectedRecordID) { _, id in + if let id { proxy.scrollTo(id, anchor: .top) } + } } } }.disabled(model.submitting) diff --git a/extensions/capture/Sources/CaptureMac/CaptureSettingsView.swift b/extensions/capture/Sources/CaptureMac/CaptureSettingsView.swift index d9ca02b..dd5fd85 100644 --- a/extensions/capture/Sources/CaptureMac/CaptureSettingsView.swift +++ b/extensions/capture/Sources/CaptureMac/CaptureSettingsView.swift @@ -1,6 +1,6 @@ import AppKit -import SwiftUI @preconcurrency import ApplicationServices +import SwiftUI struct CaptureSettingsView: View { @ObservedObject var model: CaptureModel @@ -16,13 +16,18 @@ struct CaptureSettingsView: View { LabeledContent("读取选区", value: selectionAllowed ? "已允许" : "尚未允许") if !selectionAllowed { Button("允许读取选区(辅助功能)") { - let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary + let options = + [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] + as CFDictionary _ = AXIsProcessTrustedWithOptions(options) } } LabeledContent("屏幕录制", value: screenAllowed ? "已允许" : "尚未允许") if !screenAllowed { - Button("请求屏幕录制权限") { model.requestScreenRecordingAccess(); refreshPermissions() } + Button("请求屏幕录制权限") { + model.requestScreenRecordingAccess() + refreshPermissions() + } } Button("打开屏幕录制权限设置") { model.openScreenRecordingSettings() } Text("选区与截图分别需要系统授权;输入和明确粘贴不需要这两项权限。") @@ -32,7 +37,9 @@ struct CaptureSettingsView: View { .formStyle(.grouped) .onDisappear { model.shortcuts.cancelRecording() } .onAppear { refreshPermissions() } - .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in refreshPermissions() } + .onReceive( + NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification) + ) { _ in refreshPermissions() } } private func refreshPermissions() { diff --git a/extensions/capture/Sources/CaptureMac/ClipboardReader.swift b/extensions/capture/Sources/CaptureMac/ClipboardReader.swift index 8b37c0e..909d7fb 100644 --- a/extensions/capture/Sources/CaptureMac/ClipboardReader.swift +++ b/extensions/capture/Sources/CaptureMac/ClipboardReader.swift @@ -9,7 +9,8 @@ enum ClipboardReader { var result: [Material] = [] let supported: [(NSPasteboard.PasteboardType, String)] = [ (.png, "image/png"), (.init(UTType.jpeg.identifier), "image/jpeg"), - (.init(UTType.gif.identifier), "image/gif"), (.init(UTType.webP.identifier), "image/webp"), + (.init(UTType.gif.identifier), "image/gif"), + (.init(UTType.webP.identifier), "image/webp"), ] for item in NSPasteboard.general.pasteboardItems ?? [] { var image: Material? @@ -19,17 +20,22 @@ enum ClipboardReader { break } } - if let image { result.append(image); continue } + if let image { + result.append(image) + continue + } if let data = item.data(forType: .tiff), - let bitmap = NSBitmapImageRep(data: data), - let png = bitmap.representation(using: .png, properties: [:]) { + let bitmap = NSBitmapImageRep(data: data), + let png = bitmap.representation(using: .png, properties: [:]) + { result.append(Material(image: png, mimeType: "image/png")) continue } if item.types.contains(.fileURL) { throw PeerError.invalid("首版不导入文件。请粘贴文字、链接或图片本身。") } - if let text = item.string(forType: .string) ?? item.string(forType: .URL), !text.isEmpty { + if let text = item.string(forType: .string) ?? item.string(forType: .URL), !text.isEmpty + { result.append(Material(text: text)) } } diff --git a/extensions/capture/Sources/CaptureMac/ConnectionView.swift b/extensions/capture/Sources/CaptureMac/ConnectionView.swift index 84ad790..98ba147 100644 --- a/extensions/capture/Sources/CaptureMac/ConnectionView.swift +++ b/extensions/capture/Sources/CaptureMac/ConnectionView.swift @@ -1,6 +1,6 @@ import AppKit -import SwiftUI @preconcurrency import ApplicationServices +import SwiftUI struct ConnectionView: View { @ObservedObject var model: CaptureModel @@ -11,15 +11,24 @@ struct ConnectionView: View { LabeledContent("当前能力", value: "收集:选区、粘贴、截图、想法") LabeledContent("信息库", value: model.connection?.name ?? "尚未配置") if let connection = model.connection { - LabeledContent("Peer 身份") { Text(connection.peerID.uuidString).textSelection(.enabled) } + LabeledContent("Peer 身份") { + Text(connection.peerID.uuidString).textSelection(.enabled) + } if model.checkingConnection { - HStack { ProgressView().controlSize(.small); Text("正在检查连接…") } + HStack { + ProgressView().controlSize(.small) + Text("正在检查连接…") + } } else if let issue = model.peerConnectionIssue { Text("最近检查未通过:\(issue)").foregroundStyle(.secondary) } else if let date = model.lastContactAt { - LabeledContent("最近检查通过") { Text(date, format: .dateTime.month().day().hour().minute().second()) } + LabeledContent("最近检查通过") { + Text(date, format: .dateTime.month().day().hour().minute().second()) + } Text("信息库写入连接可用").font(.caption).foregroundStyle(.secondary) - } else { Text("等待首次联络;已配置连接不代表在线。").foregroundStyle(.secondary) } + } else { + Text("等待首次联络;已配置连接不代表在线。").foregroundStyle(.secondary) + } } LabeledContent("本机待收入", value: "\(model.pendingCount) 项") Text("此 Mac 与其他 Peer 共享同一个信息库。关闭主窗口后仍会保留草稿、处理待办;退出 InKCre 才停止运行。") @@ -27,10 +36,15 @@ struct ConnectionView: View { } Section("连接共享信息库") { if let target = model.connection { - LabeledContent("PostgreSQL") { Text("\(target.host):\(target.port)/\(target.database)").textSelection(.enabled) } + LabeledContent("PostgreSQL") { + Text("\(target.host):\(target.port)/\(target.database)").textSelection( + .enabled) + } LabeledContent("运行角色", value: target.username) if let web = target.webURL { - LabeledContent("Web 地址") { Text(web.absoluteString).textSelection(.enabled) } + LabeledContent("Web 地址") { + Text(web.absoluteString).textSelection(.enabled) + } } } else { Text("连接信息库后可以收入。未连接时仍可采集、保留草稿和导出材料。") diff --git a/extensions/capture/Sources/CaptureMac/GlobalShortcuts.swift b/extensions/capture/Sources/CaptureMac/GlobalShortcuts.swift index 367dc28..6a1b2b0 100644 --- a/extensions/capture/Sources/CaptureMac/GlobalShortcuts.swift +++ b/extensions/capture/Sources/CaptureMac/GlobalShortcuts.swift @@ -1,8 +1,8 @@ import AppKit -import Carbon.HIToolbox import Capture -import PeerCore +import Carbon.HIToolbox import Combine +import PeerCore @MainActor final class GlobalShortcuts: ObservableObject { @@ -24,13 +24,13 @@ final class GlobalShortcuts: ObservableObject { if let data = defaults.data(forKey: Self.settingsKey) { bindings = try JSONDecoder().decode([ShortcutAction: RecordedShortcut].self, from: data) } else { - bindings = Dictionary(uniqueKeysWithValues: ShortcutAction.allCases.map { ($0, $0.defaultShortcut) }) + bindings = Dictionary( + uniqueKeysWithValues: ShortcutAction.allCases.map { ($0, $0.defaultShortcut) }) } var failures: [String] = [] for (action, shortcut) in bindings { - do { try validate(shortcut, for: action) } - catch { + do { try validate(shortcut, for: action) } catch { bindings[action] = nil failures.append("已停用无效的“\(action.title)”快捷键:\(error.localizedDescription)") } @@ -40,19 +40,24 @@ final class GlobalShortcuts: ObservableObject { func start() throws { guard handler == nil else { return } - var type = EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)) - let status = InstallEventHandler(GetApplicationEventTarget(), { _, event, context in - guard let event, let context else { return OSStatus(eventNotHandledErr) } - var identity = EventHotKeyID() - let status = GetEventParameter(event, EventParamName(kEventParamDirectObject), - EventParamType(typeEventHotKeyID), nil, - MemoryLayout.size, nil, &identity) - guard status == noErr, identity.signature == 0x494e4b43, - let action = ShortcutAction(rawValue: identity.id) else { return OSStatus(eventNotHandledErr) } - let owner = Unmanaged.fromOpaque(context).takeUnretainedValue() - MainActor.assumeIsolated { _ = owner.execute(action, in: nil) } - return noErr - }, 1, &type, Unmanaged.passUnretained(self).toOpaque(), &handler) + var type = EventTypeSpec( + eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)) + let status = InstallEventHandler( + GetApplicationEventTarget(), + { _, event, context in + guard let event, let context else { return OSStatus(eventNotHandledErr) } + var identity = EventHotKeyID() + let status = GetEventParameter( + event, EventParamName(kEventParamDirectObject), + EventParamType(typeEventHotKeyID), nil, + MemoryLayout.size, nil, &identity) + guard status == noErr, identity.signature == 0x494e_4b43, + let action = ShortcutAction(rawValue: identity.id) + else { return OSStatus(eventNotHandledErr) } + let owner = Unmanaged.fromOpaque(context).takeUnretainedValue() + MainActor.assumeIsolated { _ = owner.execute(action, in: nil) } + return noErr + }, 1, &type, Unmanaged.passUnretained(self).toOpaque(), &handler) guard status == noErr else { throw PeerError.needsAction("无法启用快捷键事件,请重新打开 InKCre。") } monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in let consumed = MainActor.assumeIsolated { @@ -70,9 +75,18 @@ final class GlobalShortcuts: ObservableObject { func stop() { for key in keys.values { UnregisterEventHotKey(key) } keys.removeAll() - if let handler { RemoveEventHandler(handler); self.handler = nil } - if let monitor { NSEvent.removeMonitor(monitor); self.monitor = nil } - if let resignObserver { NotificationCenter.default.removeObserver(resignObserver); self.resignObserver = nil } + if let handler { + RemoveEventHandler(handler) + self.handler = nil + } + if let monitor { + NSEvent.removeMonitor(monitor) + self.monitor = nil + } + if let resignObserver { + NotificationCenter.default.removeObserver(resignObserver) + self.resignObserver = nil + } recording = nil } @@ -82,9 +96,13 @@ final class GlobalShortcuts: ObservableObject { changed[action] = shortcut let data = try JSONEncoder().encode(changed) if handler != nil, recording == nil, action.isGlobal, - shortcut != bindings[action] || keys[action] == nil { - if let shortcut { try register(shortcut, for: action) } - else if let old = keys.removeValue(forKey: action) { UnregisterEventHotKey(old) } + shortcut != bindings[action] || keys[action] == nil + { + if let shortcut { + try register(shortcut, for: action) + } else if let old = keys.removeValue(forKey: action) { + UnregisterEventHotKey(old) + } } defaults.set(data, forKey: Self.settingsKey) bindings = changed @@ -93,8 +111,7 @@ final class GlobalShortcuts: ObservableObject { func update(_ shortcut: RecordedShortcut?, for action: ShortcutAction) { cancelRecording() - do { try set(shortcut, for: action) } - catch { message = error.localizedDescription } + do { try set(shortcut, for: action) } catch { message = error.localizedDescription } } func beginRecording(_ action: ShortcutAction) { @@ -114,7 +131,10 @@ final class GlobalShortcuts: ObservableObject { if let action = recording { guard !event.isARepeat else { return nil } let shortcut = RecordedShortcut(event: event) - if event.keyCode == kVK_Escape, shortcut.modifierBits == 0 { cancelRecording(); return nil } + if event.keyCode == kVK_Escape, shortcut.modifierBits == 0 { + cancelRecording() + return nil + } do { try validate(shortcut, for: action) cancelRecording() @@ -123,13 +143,17 @@ final class GlobalShortcuts: ObservableObject { return nil } guard isCaptureWindow?(event.window) == true, - NSApp.modalWindow == nil, - event.window?.attachedSheet == nil, - (NSApp.keyWindow?.firstResponder as? NSTextView)?.hasMarkedText() != true else { return event } + NSApp.modalWindow == nil, + event.window?.attachedSheet == nil, + (NSApp.keyWindow?.firstResponder as? NSTextView)?.hasMarkedText() != true + else { return event } let modifiers = event.modifierFlags.intersection(RecordedShortcut.allowedModifiers).rawValue - guard let action = ShortcutAction.allCases.first(where: { - !$0.isGlobal && bindings[$0]?.keyCode == event.keyCode && bindings[$0]?.modifierBits == modifiers - }) else { return event } + guard + let action = ShortcutAction.allCases.first(where: { + !$0.isGlobal && bindings[$0]?.keyCode == event.keyCode + && bindings[$0]?.modifierBits == modifiers + }) + else { return event } guard canPerform?(action, event.window) == true else { return event } if !event.isARepeat { _ = execute(action, in: event.window) } return nil @@ -137,25 +161,31 @@ final class GlobalShortcuts: ObservableObject { @discardableResult func execute(_ action: ShortcutAction, in window: NSWindow?) -> Bool { - guard recording == nil, canPerform?(action, window) == true, let perform else { return false } + guard recording == nil, canPerform?(action, window) == true, let perform else { + return false + } perform(action) return true } private func validate(_ shortcut: RecordedShortcut, for action: ShortcutAction) throws { try shortcut.validate(for: action) - if let other = ShortcutAction.allCases.first(where: { $0 != action && bindings[$0] == shortcut }) { + if let other = ShortcutAction.allCases.first(where: { + $0 != action && bindings[$0] == shortcut + }) { throw PeerError.invalid("\(shortcut.label) 已用于“\(other.title)”,请换一个组合。原设置未更改。") } } private func register(_ shortcut: RecordedShortcut, for action: ShortcutAction) throws { var reference: EventHotKeyRef? - let status = RegisterEventHotKey(UInt32(shortcut.keyCode), shortcut.carbonModifiers, - EventHotKeyID(signature: 0x494e4b43, id: action.rawValue), - GetApplicationEventTarget(), 0, &reference) + let status = RegisterEventHotKey( + UInt32(shortcut.keyCode), shortcut.carbonModifiers, + EventHotKeyID(signature: 0x494e_4b43, id: action.rawValue), + GetApplicationEventTarget(), 0, &reference) guard status == noErr, let reference else { - throw PeerError.needsAction("“\(action.title)”的 \(shortcut.label) 无法注册,可能已被系统或其他应用占用。请换一个组合。") + throw PeerError.needsAction( + "“\(action.title)”的 \(shortcut.label) 无法注册,可能已被系统或其他应用占用。请换一个组合。") } // Register the replacement first; failure must not remove the working shortcut. if let old = keys.updateValue(reference, forKey: action) { UnregisterEventHotKey(old) } @@ -166,8 +196,9 @@ final class GlobalShortcuts: ObservableObject { var failures: [String] = [] for action in ShortcutAction.allCases where action.isGlobal && keys[action] == nil { guard let shortcut = bindings[action] else { continue } - do { try register(shortcut, for: action) } - catch { failures.append(error.localizedDescription) } + do { try register(shortcut, for: action) } catch { + failures.append(error.localizedDescription) + } } if !failures.isEmpty { message = failures.joined(separator: "\n") } } diff --git a/extensions/capture/Sources/CaptureMac/KeyboardShortcut.swift b/extensions/capture/Sources/CaptureMac/KeyboardShortcut.swift index 53bd313..c5048b3 100644 --- a/extensions/capture/Sources/CaptureMac/KeyboardShortcut.swift +++ b/extensions/capture/Sources/CaptureMac/KeyboardShortcut.swift @@ -1,10 +1,11 @@ import AppKit -import Carbon.HIToolbox import Capture +import Carbon.HIToolbox import PeerCore enum ShortcutAction: UInt32, CaseIterable, Codable, Identifiable, Sendable { - case capture = 1, screenshot, paste, submit, hide + case capture = 1 + case screenshot, paste, submit, hide var id: UInt32 { rawValue } var isGlobal: Bool { self == .capture || self == .screenshot } var title: String { @@ -18,10 +19,17 @@ enum ShortcutAction: UInt32, CaseIterable, Codable, Identifiable, Sendable { } var defaultShortcut: RecordedShortcut { switch self { - case .capture: RecordedShortcut(keyCode: UInt16(kVK_ANSI_I), modifiers: [.control, .option], keyLabel: "I") - case .screenshot: RecordedShortcut(keyCode: UInt16(kVK_ANSI_S), modifiers: [.control, .option], keyLabel: "S") - case .paste: RecordedShortcut(keyCode: UInt16(kVK_ANSI_V), modifiers: [.command, .shift], keyLabel: "V") - case .submit: RecordedShortcut(keyCode: UInt16(kVK_Return), modifiers: .command, keyLabel: "↩") + case .capture: + RecordedShortcut( + keyCode: UInt16(kVK_ANSI_I), modifiers: [.control, .option], keyLabel: "I") + case .screenshot: + RecordedShortcut( + keyCode: UInt16(kVK_ANSI_S), modifiers: [.control, .option], keyLabel: "S") + case .paste: + RecordedShortcut( + keyCode: UInt16(kVK_ANSI_V), modifiers: [.command, .shift], keyLabel: "V") + case .submit: + RecordedShortcut(keyCode: UInt16(kVK_Return), modifiers: .command, keyLabel: "↩") case .hide: RecordedShortcut(keyCode: UInt16(kVK_Escape), modifiers: [], keyLabel: "Esc") } } @@ -40,13 +48,19 @@ struct RecordedShortcut: Codable, Equatable, Sendable { } init(event: NSEvent) { - let special: [Int: String] = [kVK_Return: "↩", kVK_Escape: "Esc", kVK_Tab: "⇥", kVK_Space: "空格", + let special: [Int: String] = [ + kVK_Return: "↩", kVK_Escape: "Esc", kVK_Tab: "⇥", kVK_Space: "空格", kVK_Delete: "⌫", kVK_ForwardDelete: "⌦", kVK_LeftArrow: "←", kVK_RightArrow: "→", kVK_UpArrow: "↑", kVK_DownArrow: "↓", kVK_Home: "↖", kVK_End: "↘", - kVK_PageUp: "⇞", kVK_PageDown: "⇟"] - let functionKeys = [kVK_F1, kVK_F2, kVK_F3, kVK_F4, kVK_F5, kVK_F6, kVK_F7, kVK_F8, kVK_F9, kVK_F10, - kVK_F11, kVK_F12, kVK_F13, kVK_F14, kVK_F15, kVK_F16, kVK_F17, kVK_F18, kVK_F19, kVK_F20] - let label = special[Int(event.keyCode)] + kVK_PageUp: "⇞", kVK_PageDown: "⇟", + ] + let functionKeys = [ + kVK_F1, kVK_F2, kVK_F3, kVK_F4, kVK_F5, kVK_F6, kVK_F7, kVK_F8, kVK_F9, kVK_F10, + kVK_F11, kVK_F12, kVK_F13, kVK_F14, kVK_F15, kVK_F16, kVK_F17, kVK_F18, kVK_F19, + kVK_F20, + ] + let label = + special[Int(event.keyCode)] ?? functionKeys.firstIndex(of: Int(event.keyCode)).map { "F\($0 + 1)" } ?? event.characters(byApplyingModifiers: [])?.uppercased() ?? "按键 \(event.keyCode)" self.init(keyCode: event.keyCode, modifiers: event.modifierFlags, keyLabel: label) @@ -58,35 +72,48 @@ struct RecordedShortcut: Codable, Equatable, Sendable { .filter { modifiers.contains($0.0) }.map(\.1).joined() + keyLabel } var menuKeyEquivalent: String { - let special = ["↩": "\r", "Esc": "\u{1b}", "⇥": "\t", "空格": " ", "⌫": "\u{8}", "⌦": "\u{7f}", - "←": "\u{f702}", "→": "\u{f703}", "↑": "\u{f700}", "↓": "\u{f701}", - "↖": "\u{f729}", "↘": "\u{f72b}", "⇞": "\u{f72c}", "⇟": "\u{f72d}"] + let special = [ + "↩": "\r", "Esc": "\u{1b}", "⇥": "\t", "空格": " ", "⌫": "\u{8}", "⌦": "\u{7f}", + "←": "\u{f702}", "→": "\u{f703}", "↑": "\u{f700}", "↓": "\u{f701}", + "↖": "\u{f729}", "↘": "\u{f72b}", "⇞": "\u{f72c}", "⇟": "\u{f72d}", + ] if let key = special[keyLabel] { return key } - if keyLabel.hasPrefix("F"), let number = Int(keyLabel.dropFirst()), (1...20).contains(number), - let scalar = UnicodeScalar(0xf703 + number) { return String(scalar) } + if keyLabel.hasPrefix("F"), let number = Int(keyLabel.dropFirst()), + (1...20).contains(number), + let scalar = UnicodeScalar(0xf703 + number) + { + return String(scalar) + } return keyLabel.lowercased() } var carbonModifiers: UInt32 { - [(NSEvent.ModifierFlags.control, controlKey), (.option, optionKey), (.shift, shiftKey), (.command, cmdKey)] - .reduce(0) { $0 | (modifiers.contains($1.0) ? UInt32($1.1) : 0) } + [ + (NSEvent.ModifierFlags.control, controlKey), (.option, optionKey), (.shift, shiftKey), + (.command, cmdKey), + ] + .reduce(0) { $0 | (modifiers.contains($1.0) ? UInt32($1.1) : 0) } } static func == (left: Self, right: Self) -> Bool { left.keyCode == right.keyCode && left.modifierBits == right.modifierBits } func validate(for action: ShortcutAction) throws { // Preserve the app's window, navigation and text-editing commands. - let commandKeys = [kVK_ANSI_Q, kVK_ANSI_W, kVK_ANSI_H, kVK_ANSI_M, kVK_ANSI_N, - kVK_ANSI_Comma, kVK_ANSI_C, kVK_ANSI_V, kVK_ANSI_X, kVK_ANSI_A, kVK_ANSI_Z] - let reserved = modifiers == .command && commandKeys.contains(Int(keyCode)) + let commandKeys = [ + kVK_ANSI_Q, kVK_ANSI_W, kVK_ANSI_H, kVK_ANSI_M, kVK_ANSI_N, + kVK_ANSI_Comma, kVK_ANSI_C, kVK_ANSI_V, kVK_ANSI_X, kVK_ANSI_A, kVK_ANSI_Z, + ] + let reserved = + modifiers == .command && commandKeys.contains(Int(keyCode)) || modifiers == [.command, .shift] && [kVK_ANSI_Z, kVK_ANSI_W].contains(Int(keyCode)) || modifiers == [.command, .option] && [kVK_ANSI_H, kVK_ANSI_S].contains(Int(keyCode)) || modifiers == [.command, .control] && keyCode == kVK_ANSI_F guard !reserved else { throw PeerError.invalid("\(label) 是应用的标准命令,请使用其他组合。原设置未更改。") } let plainEscape = action == .hide && keyCode == kVK_Escape && modifierBits == 0 guard keyCode <= 127, !keyLabel.isEmpty, - modifierBits & ~Self.allowedModifiers.rawValue == 0, - plainEscape || !modifiers.intersection([.command, .control, .option]).isEmpty else { + modifierBits & ~Self.allowedModifiers.rawValue == 0, + plainEscape || !modifiers.intersection([.command, .control, .option]).isEmpty + else { throw PeerError.invalid("请同时按住 ⌘、⌃ 或 ⌥,避免影响正常输入。") } } diff --git a/extensions/capture/Sources/CaptureMac/PeerMainView.swift b/extensions/capture/Sources/CaptureMac/PeerMainView.swift index 4708306..a1a7b38 100644 --- a/extensions/capture/Sources/CaptureMac/PeerMainView.swift +++ b/extensions/capture/Sources/CaptureMac/PeerMainView.swift @@ -8,18 +8,24 @@ struct PeerMainView: View { NavigationSplitView(columnVisibility: $model.columnVisibility) { VStack(spacing: 0) { HStack(spacing: 10) { - Image(nsImage: colorScheme == .dark ? BrandAssets.darkMark : BrandAssets.lightMark) - .resizable().frame(width: 32, height: 32).accessibilityHidden(true) + Image( + nsImage: colorScheme == .dark ? BrandAssets.darkMark : BrandAssets.lightMark + ) + .resizable().frame(width: 32, height: 32).accessibilityHidden(true) VStack(alignment: .leading) { Text("InKCre").font(.headline) Text("Mac Peer").font(.caption).foregroundStyle(.secondary) } Spacer() }.padding() - List(selection: Binding(get: { Optional(model.pane) }, set: { - guard let value = $0 else { return } - model.pane = value - })) { + List( + selection: Binding( + get: { Optional(model.pane) }, + set: { + guard let value = $0 else { return } + model.pane = value + }) + ) { Label("采集", systemImage: "square.and.pencil").tag(CaptureModel.Pane.capture) Label("记录", systemImage: "tray.full").tag(CaptureModel.Pane.queue) Label("Peer 与连接", systemImage: "network").tag(CaptureModel.Pane.peer) @@ -41,7 +47,9 @@ struct PeerMainView: View { } actions: { Button("在主窗口继续") { model.continueInMain() } } - } else { CaptureEditor(model: model) } + } else { + CaptureEditor(model: model) + } case .queue: CaptureRecordsView(model: model) case .peer: ConnectionView(model: model) } @@ -61,8 +69,10 @@ struct PeerMainView: View { .help("框选截图") .disabled(model.submitting) } - Button("快捷采集", systemImage: "rectangle.and.pencil.and.ellipsis") { model.showQuick?() } - .help("打开快捷采集面板") + Button("快捷采集", systemImage: "rectangle.and.pencil.and.ellipsis") { + model.showQuick?() + } + .help("打开快捷采集面板") } } .modifier(CaptureAlerts(model: model, owner: .main)) @@ -127,15 +137,18 @@ private struct CaptureAlerts: ViewModifier { } func body(content: Content) -> some View { - content.alert(presentedAlert?.title ?? "需要处理", isPresented: Binding( - get: { presentedAlert != nil && model.editorOwner == owner }, - set: { - if !$0 && model.editorOwner == owner { - model.error = nil - model.removalRequest = nil + content.alert( + presentedAlert?.title ?? "需要处理", + isPresented: Binding( + get: { presentedAlert != nil && model.editorOwner == owner }, + set: { + if !$0 && model.editorOwner == owner { + model.error = nil + model.removalRequest = nil + } } - } - ), presenting: presentedAlert) { alert in + ), presenting: presentedAlert + ) { alert in switch alert { case .error: Button("知道了", role: .cancel) { model.error = nil } @@ -143,6 +156,8 @@ private struct CaptureAlerts: ViewModifier { Button(request.actionTitle, role: .destructive) { model.confirmRemoval(request) } Button("取消", role: .cancel) { model.removalRequest = nil } } - } message: { alert in Text(alert.message) } + } message: { alert in + Text(alert.message) + } } } diff --git a/extensions/capture/Sources/CaptureMac/PeerMainWindow.swift b/extensions/capture/Sources/CaptureMac/PeerMainWindow.swift index 73c593e..5b04307 100644 --- a/extensions/capture/Sources/CaptureMac/PeerMainWindow.swift +++ b/extensions/capture/Sources/CaptureMac/PeerMainWindow.swift @@ -12,16 +12,19 @@ final class PeerMainWindow: NSWindow { } override func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool { - guard item.action == #selector(toggleSidebar(_:)) else { return super.validateUserInterfaceItem(item) } + guard item.action == #selector(toggleSidebar(_:)) else { + return super.validateUserInterfaceItem(item) + } (item as? NSMenuItem)?.title = model.columnVisibility == .detailOnly ? "显示侧栏" : "隐藏侧栏" return attachedSheet == nil } init(model: CaptureModel) { self.model = model - super.init(contentRect: NSRect(x: 0, y: 0, width: 1000, height: 720), - styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], - backing: .buffered, defer: false) + super.init( + contentRect: NSRect(x: 0, y: 0, width: 1000, height: 720), + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, defer: false) title = "InKCre" toolbarStyle = .unified minSize = NSSize(width: 760, height: 520) diff --git a/extensions/capture/Sources/CaptureMac/RegionCapture.swift b/extensions/capture/Sources/CaptureMac/RegionCapture.swift index 9d2d5b5..aec2f03 100644 --- a/extensions/capture/Sources/CaptureMac/RegionCapture.swift +++ b/extensions/capture/Sources/CaptureMac/RegionCapture.swift @@ -23,7 +23,8 @@ final class RegionCapture { init( hasPermission: @escaping () -> Bool = CGPreflightScreenCaptureAccess, - freezeScreens: @escaping () async throws -> [FrozenScreenFrame] = RegionCapture.freezeScreens, + freezeScreens: @escaping () async throws -> [FrozenScreenFrame] = RegionCapture + .freezeScreens, overlay: RegionSelectionOverlay = RegionSelectionOverlay() ) { self.hasPermission = hasPermission @@ -50,20 +51,26 @@ final class RegionCapture { case .cancelled: finish(.success(nil)) case .region(let selection): - guard let frame = frames.first(where: { $0.displayID == selection.displayID }) else { + guard let frame = frames.first(where: { $0.displayID == selection.displayID }) + else { throw PeerError.invalid("找不到所选画面,请重新截图。") } let data = try await Task.detached(priority: .userInitiated) { try frame.png(in: selection.pointsRect) }.value guard !Task.isCancelled else { return } - try CapturePayload(entry: .screenshot, materials: [Material(image: data, mimeType: "image/png")]).validate() + try CapturePayload( + entry: .screenshot, + materials: [Material(image: data, mimeType: "image/png")] + ).validate() finish(.success(data)) } } catch { guard !Task.isCancelled else { return } let failure = error as NSError - if failure.domain == SCStreamErrorDomain, failure.code == SCStreamError.Code.userDeclined.rawValue { + if failure.domain == SCStreamErrorDomain, + failure.code == SCStreamError.Code.userDeclined.rawValue + { finish(.failure(ScreenRecordingPermissionError())) } else { finish(.failure(error)) @@ -88,23 +95,35 @@ final class RegionCapture { } private static func freezeScreens() async throws -> [FrozenScreenFrame] { - let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) - let own = content.applications.filter { $0.processID == ProcessInfo.processInfo.processIdentifier } + let content = try await SCShareableContent.excludingDesktopWindows( + false, onScreenWindowsOnly: true) + let own = content.applications.filter { + $0.processID == ProcessInfo.processInfo.processIdentifier + } var frames: [FrozenScreenFrame] = [] for screen in NSScreen.screens { try Task.checkCancellation() - guard let number = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber, - let display = content.displays.first(where: { $0.displayID == number.uint32Value }) else { + guard + let number = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] + as? NSNumber, + let display = content.displays.first(where: { $0.displayID == number.uint32Value }) + else { throw PeerError.invalid("屏幕配置已改变,请重新截图。") } - let filter = SCContentFilter(display: display, excludingApplications: own, exceptingWindows: []) + let filter = SCContentFilter( + display: display, excludingApplications: own, exceptingWindows: []) let configuration = SCStreamConfiguration() - configuration.width = max(1, Int((filter.contentRect.width * CGFloat(filter.pointPixelScale)).rounded())) - configuration.height = max(1, Int((filter.contentRect.height * CGFloat(filter.pointPixelScale)).rounded())) + configuration.width = max( + 1, Int((filter.contentRect.width * CGFloat(filter.pointPixelScale)).rounded())) + configuration.height = max( + 1, Int((filter.contentRect.height * CGFloat(filter.pointPixelScale)).rounded())) configuration.showsCursor = false configuration.captureResolution = .best - let image = try await SCScreenshotManager.captureImage(contentFilter: filter, configuration: configuration) - frames.append(FrozenScreenFrame(displayID: display.displayID, pointsRect: display.frame, image: image)) + let image = try await SCScreenshotManager.captureImage( + contentFilter: filter, configuration: configuration) + frames.append( + FrozenScreenFrame( + displayID: display.displayID, pointsRect: display.frame, image: image)) } return frames } diff --git a/extensions/capture/Sources/CaptureMac/SelectedText.swift b/extensions/capture/Sources/CaptureMac/SelectedText.swift index f1db9fb..8599373 100644 --- a/extensions/capture/Sources/CaptureMac/SelectedText.swift +++ b/extensions/capture/Sources/CaptureMac/SelectedText.swift @@ -4,8 +4,8 @@ import AppKit import ApplicationServices import Capture -import PeerCore import Carbon.HIToolbox +import PeerCore struct SelectionSnapshot: Sendable { var text: String? @@ -28,7 +28,9 @@ enum SelectedText { var snapshot = SelectionSnapshot(origin: CaptureOrigin(applicationID: applicationID)) let application = AXUIElementCreateApplication(pid) AXUIElementSetMessagingTimeout(application, 0.12) - guard let focused = element(application, kAXFocusedUIElementAttribute) else { return snapshot } + guard let focused = element(application, kAXFocusedUIElementAttribute) else { + return snapshot + } AXUIElementSetMessagingTimeout(focused, 0.12) snapshot.secure = string(focused, kAXSubroleAttribute) == kAXSecureTextFieldSubrole guard !snapshot.secure else { return snapshot } @@ -41,7 +43,8 @@ enum SelectedText { snapshot.text = string(focused, kAXSelectedTextAttribute).flatMap { $0.isEmpty ? nil : $0 } var selectedRange: CFRange? if let value = attribute(focused, kAXSelectedTextRangeAttribute), - CFGetTypeID(value) == AXValueGetTypeID() { + CFGetTypeID(value) == AXValueGetTypeID() + { var range = CFRange() if AXValueGetValue(unsafeDowncast(value, to: AXValue.self), .cfRange, &range) { snapshot.rangeLength = range.length @@ -50,7 +53,8 @@ enum SelectedText { } // A delayed AX reply must not be attached to a different focused element. guard let current = element(application, kAXFocusedUIElementAttribute), - CFEqual(current, focused) else { + CFEqual(current, focused) + else { return SelectionSnapshot(origin: CaptureOrigin(applicationID: applicationID)) } snapshot.focus = SelectionFocus(element: focused, window: window, range: selectedRange) @@ -62,26 +66,34 @@ enum SelectedText { let application = AXUIElementCreateApplication(pid) AXUIElementSetMessagingTimeout(application, 0.12) guard let current = element(application, kAXFocusedUIElementAttribute), - CFEqual(current, focus.element) else { return false } + CFEqual(current, focus.element) + else { return false } AXUIElementSetMessagingTimeout(current, 0.12) if let window = focus.window { guard let currentWindow = element(application, kAXFocusedWindowAttribute), - CFEqual(window, currentWindow) else { return false } + CFEqual(window, currentWindow) + else { return false } } if let expected = focus.range { guard let value = attribute(current, kAXSelectedTextRangeAttribute), - CFGetTypeID(value) == AXValueGetTypeID() else { return false } + CFGetTypeID(value) == AXValueGetTypeID() + else { return false } var actual = CFRange() guard AXValueGetValue(unsafeDowncast(value, to: AXValue.self), .cfRange, &actual), - actual.location == expected.location, actual.length == expected.length else { return false } + actual.location == expected.location, actual.length == expected.length + else { return false } + } + if let text = snapshot.text, string(current, kAXSelectedTextAttribute) != text { + return false } - if let text = snapshot.text, string(current, kAXSelectedTextAttribute) != text { return false } return string(current, kAXSubroleAttribute) != kAXSecureTextFieldSubrole } private static func attribute(_ element: AXUIElement, _ name: String) -> CFTypeRef? { var value: CFTypeRef? - guard AXUIElementCopyAttributeValue(element, name as CFString, &value) == .success else { return nil } + guard AXUIElementCopyAttributeValue(element, name as CFString, &value) == .success else { + return nil + } return value } @@ -92,16 +104,21 @@ enum SelectedText { } private static func element(_ parent: AXUIElement, _ name: String) -> AXUIElement? { - guard let value = attribute(parent, name), CFGetTypeID(value) == AXUIElementGetTypeID() else { return nil } + guard let value = attribute(parent, name), CFGetTypeID(value) == AXUIElementGetTypeID() + else { return nil } return unsafeDowncast(value, to: AXUIElement.self) } @MainActor - static func copyFallback(pid: pid_t, snapshot selection: SelectionSnapshot, isActive: () -> Bool) async -> String? { + static func copyFallback( + pid: pid_t, snapshot selection: SelectionSnapshot, isActive: () -> Bool + ) async -> String? { let expectedLength = selection.rangeLength guard expectedLength > 0, !IsSecureEventInputEnabled(), - isActive(), await Task.detached(operation: { isCurrent(pid: pid, snapshot: selection) }).value, - isActive() else { return nil } + isActive(), + await Task.detached(operation: { isCurrent(pid: pid, snapshot: selection) }).value, + isActive() + else { return nil } let board = NSPasteboard.general let originalCount = board.changeCount var snapshot: [[NSPasteboard.PasteboardType: Data]] = [] @@ -117,8 +134,11 @@ enum SelectedText { snapshot.append(representations) } guard board.changeCount == originalCount, isActive(), - let down = CGEvent(keyboardEventSource: nil, virtualKey: CGKeyCode(kVK_ANSI_C), keyDown: true), - let up = CGEvent(keyboardEventSource: nil, virtualKey: CGKeyCode(kVK_ANSI_C), keyDown: false) else { return nil } + let down = CGEvent( + keyboardEventSource: nil, virtualKey: CGKeyCode(kVK_ANSI_C), keyDown: true), + let up = CGEvent( + keyboardEventSource: nil, virtualKey: CGKeyCode(kVK_ANSI_C), keyDown: false) + else { return nil } down.flags = .maskCommand up.flags = .maskCommand down.postToPid(pid) @@ -128,7 +148,8 @@ enum SelectedText { guard isActive() else { return nil } if board.changeCount != originalCount { let copiedCount = board.changeCount - guard let text = board.string(forType: .string), text.utf16.count == expectedLength else { return nil } + guard let text = board.string(forType: .string), text.utf16.count == expectedLength + else { return nil } // Restore only the copy we observed; a later user copy always wins. if board.changeCount == copiedCount { let items = snapshot.map { representations in @@ -139,8 +160,11 @@ enum SelectedText { board.clearContents() if !items.isEmpty { board.writeObjects(items) } } - guard await Task.detached(operation: { isCurrent(pid: pid, snapshot: selection) }).value, - isActive() else { return nil } + guard + await Task.detached(operation: { isCurrent(pid: pid, snapshot: selection) }) + .value, + isActive() + else { return nil } return text } } diff --git a/extensions/capture/Sources/CaptureMac/ShortcutSettingsView.swift b/extensions/capture/Sources/CaptureMac/ShortcutSettingsView.swift index 839abf1..7ed899c 100644 --- a/extensions/capture/Sources/CaptureMac/ShortcutSettingsView.swift +++ b/extensions/capture/Sources/CaptureMac/ShortcutSettingsView.swift @@ -12,12 +12,17 @@ struct ShortcutSettingsView: View { .font(.caption).foregroundStyle(.secondary) } Spacer() - Button(shortcuts.recording == action ? "按下快捷键…" : shortcuts.bindings[action]?.label ?? "未设置") { + Button( + shortcuts.recording == action + ? "按下快捷键…" : shortcuts.bindings[action]?.label ?? "未设置" + ) { shortcuts.beginRecording(action) } .frame(minWidth: 110) .accessibilityLabel("\(action.title)快捷键") - .accessibilityValue(shortcuts.recording == action ? "正在录入" : shortcuts.bindings[action]?.label ?? "未设置") + .accessibilityValue( + shortcuts.recording == action + ? "正在录入" : shortcuts.bindings[action]?.label ?? "未设置") Button("清除") { shortcuts.update(nil, for: action) } .disabled(shortcuts.bindings[action] == nil) .accessibilityLabel("清除\(action.title)快捷键") diff --git a/scripts/check.sh b/scripts/check.sh new file mode 100755 index 0000000..901683f --- /dev/null +++ b/scripts/check.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." + +for script in scripts/*.sh; do + bash -n "$script" +done +git ls-files --cached --others --exclude-standard -z -- \ + Package.swift 'Sources/**/*.swift' 'Tests/**/*.swift' \ + 'Packages/**/*.swift' 'extensions/**/*.swift' \ + ':!extensions/capture/Sources/CaptureMac/Vendor/**' \ + | xargs -0 swift format lint --strict +swift test