128 lines
4.6 KiB
Swift
128 lines
4.6 KiB
Swift
import AppKit
|
|
import ApplicationServices
|
|
import Carbon
|
|
import Foundation
|
|
import MacToolsPluginKit
|
|
|
|
struct SimulatedCopySelectedTextCapture: SelectedTextCapturing {
|
|
let strategyID: SelectedTextCaptureStrategyID = .simulatedCopy
|
|
private let localization: PluginLocalization
|
|
|
|
init(localization: PluginLocalization = PluginLocalization(bundle: .main)) {
|
|
self.localization = localization
|
|
}
|
|
|
|
func capture(context: SelectedTextCaptureContext) async -> SelectedTextCaptureResult {
|
|
guard AccessibilityCheck.isTrusted() else {
|
|
return failure(
|
|
context: context,
|
|
reason: localization.string("capture.error.permissionRequired", defaultValue: "需要辅助功能授权")
|
|
)
|
|
}
|
|
|
|
// When a shortcut such as Option-D triggers capture, the user may still be holding modifiers.
|
|
// Injecting Command-C immediately would combine physical and synthetic modifiers (for example,
|
|
// Command-Option-C) and fail to copy, so wait until the modifiers are released.
|
|
await waitForModifierKeysToClear()
|
|
|
|
let pasteboard = NSPasteboard.general
|
|
let snapshot = PasteboardSnapshot.capture(from: pasteboard)
|
|
|
|
pasteboard.clearContents()
|
|
let clearedChangeCount = pasteboard.changeCount
|
|
guard sendCommandC() else {
|
|
guard snapshot.restore(to: pasteboard) else {
|
|
return failure(
|
|
context: context,
|
|
reason: localization.string("capture.error.restorePasteboardFailed", defaultValue: "无法恢复剪贴板")
|
|
)
|
|
}
|
|
return failure(
|
|
context: context,
|
|
reason: localization.string("capture.error.simulatedCopyFailed", defaultValue: "模拟复制失败")
|
|
)
|
|
}
|
|
|
|
await waitForPasteboardChange(from: clearedChangeCount, in: pasteboard)
|
|
let text = pasteboard.string(forType: .string)
|
|
guard snapshot.restore(to: pasteboard) else {
|
|
return failure(
|
|
context: context,
|
|
reason: localization.string("capture.error.restorePasteboardFailed", defaultValue: "无法恢复剪贴板")
|
|
)
|
|
}
|
|
|
|
guard let text, !text.isEmpty else {
|
|
return failure(
|
|
context: context,
|
|
reason: localization.string("capture.error.missingSelection", defaultValue: "未找到选中文本")
|
|
)
|
|
}
|
|
|
|
return SelectedTextCaptureResult(
|
|
text: text,
|
|
strategyID: strategyID,
|
|
isEditable: false,
|
|
sourceApplicationBundleID: context.frontmostApplicationBundleID,
|
|
failureReason: nil
|
|
)
|
|
}
|
|
|
|
private func waitForPasteboardChange(from clearedChangeCount: Int, in pasteboard: NSPasteboard) async {
|
|
let deadline = Date().addingTimeInterval(0.8)
|
|
while pasteboard.changeCount == clearedChangeCount && Date() < deadline {
|
|
try? await Task.sleep(nanoseconds: 25_000_000)
|
|
}
|
|
|
|
if pasteboard.changeCount != clearedChangeCount {
|
|
try? await Task.sleep(nanoseconds: 30_000_000)
|
|
}
|
|
}
|
|
|
|
private func waitForModifierKeysToClear() async {
|
|
let trackedModifiers: CGEventFlags = [.maskCommand, .maskAlternate, .maskControl, .maskShift]
|
|
let deadline = Date().addingTimeInterval(0.3)
|
|
|
|
while Date() < deadline {
|
|
let current = CGEventSource.flagsState(.combinedSessionState)
|
|
if current.intersection(trackedModifiers).isEmpty {
|
|
return
|
|
}
|
|
|
|
try? await Task.sleep(nanoseconds: 15_000_000)
|
|
}
|
|
}
|
|
|
|
private func sendCommandC() -> Bool {
|
|
guard let source = CGEventSource(stateID: .combinedSessionState),
|
|
let keyDown = CGEvent(
|
|
keyboardEventSource: source,
|
|
virtualKey: CGKeyCode(kVK_ANSI_C),
|
|
keyDown: true
|
|
),
|
|
let keyUp = CGEvent(
|
|
keyboardEventSource: source,
|
|
virtualKey: CGKeyCode(kVK_ANSI_C),
|
|
keyDown: false
|
|
) else {
|
|
return false
|
|
}
|
|
|
|
keyDown.flags = .maskCommand
|
|
keyUp.flags = .maskCommand
|
|
keyDown.post(tap: .cghidEventTap)
|
|
keyUp.post(tap: .cghidEventTap)
|
|
return true
|
|
}
|
|
|
|
private func failure(context: SelectedTextCaptureContext, reason: String) -> SelectedTextCaptureResult {
|
|
SelectedTextCaptureResult(
|
|
text: nil,
|
|
strategyID: strategyID,
|
|
isEditable: false,
|
|
sourceApplicationBundleID: context.frontmostApplicationBundleID,
|
|
failureReason: reason
|
|
)
|
|
}
|
|
}
|