mirror of
https://github.com/carey314/mio-plugin-clipper.git
synced 2026-08-10 07:44:32 +00:00
107 lines
3.7 KiB
Swift
107 lines
3.7 KiB
Swift
|
|
//
|
||
|
|
// TextSource.swift
|
||
|
|
// 划词侠 plugin
|
||
|
|
//
|
||
|
|
// Two source modes:
|
||
|
|
// .clipboard — NSPasteboard.general.string(forType:.string).
|
||
|
|
// No permissions needed.
|
||
|
|
// .selection — Try NSWorkspace + AXUIElement to read the focused
|
||
|
|
// element's selected-text. Requires Accessibility
|
||
|
|
// permission. If denied, return .permissionDenied so
|
||
|
|
// the UI can prompt + fall back to clipboard.
|
||
|
|
//
|
||
|
|
|
||
|
|
import AppKit
|
||
|
|
import ApplicationServices
|
||
|
|
|
||
|
|
enum TextSourceMode: String, CaseIterable, Identifiable {
|
||
|
|
case clipboard
|
||
|
|
case selection
|
||
|
|
var id: String { rawValue }
|
||
|
|
|
||
|
|
var label: String {
|
||
|
|
switch self {
|
||
|
|
case .clipboard: return "粘贴板"
|
||
|
|
case .selection: return "选中文本"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
enum TextSourceResult {
|
||
|
|
case ok(String)
|
||
|
|
case empty
|
||
|
|
case permissionDenied
|
||
|
|
case error(String)
|
||
|
|
}
|
||
|
|
|
||
|
|
enum TextSource {
|
||
|
|
|
||
|
|
/// Read the system pasteboard (.string type). No permissions.
|
||
|
|
static func readClipboard() -> TextSourceResult {
|
||
|
|
let pb = NSPasteboard.general
|
||
|
|
guard let s = pb.string(forType: .string) else {
|
||
|
|
return .empty
|
||
|
|
}
|
||
|
|
let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
|
|
if trimmed.isEmpty { return .empty }
|
||
|
|
return .ok(s)
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Best-effort read of the *focused* UI element's selected text via
|
||
|
|
/// AX. Requires Accessibility permission for Mio Island's host
|
||
|
|
/// process. Returns .permissionDenied if not granted (caller should
|
||
|
|
/// surface the banner), or .empty if granted-but-nothing-selected.
|
||
|
|
static func readSelectionViaAX() -> TextSourceResult {
|
||
|
|
// AXIsProcessTrustedWithOptions with the prompt option will pop
|
||
|
|
// the system "open System Settings" sheet on first call. We
|
||
|
|
// pass false so we don't trigger the prompt unexpectedly — the
|
||
|
|
// UI banner explicitly tells the user where to enable it.
|
||
|
|
let opts: NSDictionary = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: false]
|
||
|
|
guard AXIsProcessTrustedWithOptions(opts) else {
|
||
|
|
ClipperDebugLog.write("selection: AX not trusted")
|
||
|
|
return .permissionDenied
|
||
|
|
}
|
||
|
|
|
||
|
|
let systemWide = AXUIElementCreateSystemWide()
|
||
|
|
var focused: CFTypeRef?
|
||
|
|
let focusErr = AXUIElementCopyAttributeValue(
|
||
|
|
systemWide,
|
||
|
|
kAXFocusedUIElementAttribute as CFString,
|
||
|
|
&focused
|
||
|
|
)
|
||
|
|
guard focusErr == .success, let element = focused else {
|
||
|
|
ClipperDebugLog.write("selection: no focused element (err=\(focusErr.rawValue))")
|
||
|
|
return .empty
|
||
|
|
}
|
||
|
|
// swiftlint:disable:next force_cast
|
||
|
|
let axElement = element as! AXUIElement
|
||
|
|
|
||
|
|
var selValue: CFTypeRef?
|
||
|
|
let selErr = AXUIElementCopyAttributeValue(
|
||
|
|
axElement,
|
||
|
|
kAXSelectedTextAttribute as CFString,
|
||
|
|
&selValue
|
||
|
|
)
|
||
|
|
guard selErr == .success, let s = selValue as? String else {
|
||
|
|
ClipperDebugLog.write("selection: no selected text (err=\(selErr.rawValue))")
|
||
|
|
return .empty
|
||
|
|
}
|
||
|
|
let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
|
|
if trimmed.isEmpty { return .empty }
|
||
|
|
return .ok(s)
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Convenience: read whichever source is active, with fallback.
|
||
|
|
static func read(mode: TextSourceMode) -> TextSourceResult {
|
||
|
|
switch mode {
|
||
|
|
case .clipboard:
|
||
|
|
return readClipboard()
|
||
|
|
case .selection:
|
||
|
|
let r = readSelectionViaAX()
|
||
|
|
// If AX gave us empty (granted but nothing selected) we keep
|
||
|
|
// the empty result — caller decides whether to fall back.
|
||
|
|
return r
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|