276d2bb6-619d-4888-ba11-8a6.../Sources/data/TextSource.swift

107 lines
3.7 KiB
Swift
Raw Permalink Normal View History

//
// 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
}
}
}