mirror of
https://github.com/carey314/mio-plugin-clipper.git
synced 2026-08-10 07:44:32 +00:00
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>
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
|
|
}
|
|
}
|
|
}
|