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