276d2bb6-619d-4888-ba11-8a6.../Sources/data/TextSource.swift
徐翔宇 4e22618364 v0.1.0: initial 划词侠 — AI Text Actions plugin for MioIsland
Quick AI actions on clipboard or selected text. Four actions:
- 翻译 (中/英/日)
- 总结 (3-bullet Chinese)
- 改语气 (正式/口语/友好/严谨)
- 邮件回复

Powered by DeepSeek with Server-Sent Events streaming for
typewriter feedback (~600ms first byte). Two source modes:
clipboard (no permission) and selected-text via NSAccessibility
(graceful fallback to clipboard + permission banner if denied).

Five-state UI machine: idle → input → loading (cancellable) →
result → missing-config. Last 20 actions persisted to UserDefaults.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 20:21:17 +08:00

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