mirror of
https://github.com/BigMarc/grammargem.git
synced 2026-10-13 17:01:55 +02:00
- Shortcuts screen now records custom global hotkeys (Fix + Ask), persisted and re-registered live via Preferences + Combine. - Add branded AppIcon.icns (gold nib on ink), wired into Info.plist + build.sh. - "Restore my clipboard after a fix" preference now drives the clipboard fallback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
141 lines
5.1 KiB
Swift
141 lines
5.1 KiB
Swift
import Foundation
|
|
import AppKit
|
|
import ApplicationServices
|
|
import Carbon.HIToolbox
|
|
|
|
/// Reads the user's selected text from the frontmost app and writes a corrected
|
|
/// value back — the mechanism that makes GrammaGem work *everywhere*.
|
|
///
|
|
/// Two paths, mirroring the spec (§4):
|
|
/// 1. **Accessibility API** (preferred): read/write `kAXSelectedTextAttribute`
|
|
/// on the focused element. Works in most native + many Electron apps.
|
|
/// 2. **Clipboard fallback** (universal): synthesize ⌘C, read the pasteboard,
|
|
/// write the result back, synthesize ⌘V, then restore the prior clipboard.
|
|
final class TextCapture {
|
|
enum Method { case accessibility, clipboard }
|
|
|
|
struct Capture {
|
|
let text: String
|
|
let method: Method
|
|
/// Held so the AX path can set the new value on the same element.
|
|
let focusedElement: AXUIElement?
|
|
}
|
|
|
|
enum CaptureError: LocalizedError {
|
|
case noSelection
|
|
case notPermitted
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .noSelection: return "Select some text first, then press the hotkey."
|
|
case .notPermitted: return "GrammaGem needs Accessibility permission to read and replace text."
|
|
}
|
|
}
|
|
}
|
|
|
|
private let pasteboard = NSPasteboard.general
|
|
|
|
/// When true (default), the clipboard-fallback path restores the user's
|
|
/// previous clipboard after pasting the correction. Driven by Preferences.
|
|
var restoreClipboard = true
|
|
|
|
// MARK: - Capture
|
|
|
|
/// Try AX first; on empty/unavailable, fall back to the clipboard path.
|
|
func capture() throws -> Capture {
|
|
if let ax = captureViaAccessibility() {
|
|
return ax
|
|
}
|
|
if let clip = try captureViaClipboard() {
|
|
return clip
|
|
}
|
|
throw CaptureError.noSelection
|
|
}
|
|
|
|
private func captureViaAccessibility() -> Capture? {
|
|
guard AXIsProcessTrusted() else { return nil }
|
|
let systemWide = AXUIElementCreateSystemWide()
|
|
|
|
var focusedRef: CFTypeRef?
|
|
guard AXUIElementCopyAttributeValue(
|
|
systemWide, kAXFocusedUIElementAttribute as CFString, &focusedRef) == .success,
|
|
let focusedRef
|
|
else { return nil }
|
|
// Force-cast through CFTypeRef to AXUIElement (same CF type).
|
|
let focused = focusedRef as! AXUIElement
|
|
|
|
var valueRef: CFTypeRef?
|
|
guard AXUIElementCopyAttributeValue(
|
|
focused, kAXSelectedTextAttribute as CFString, &valueRef) == .success,
|
|
let text = valueRef as? String, !text.isEmpty
|
|
else { return nil }
|
|
|
|
return Capture(text: text, method: .accessibility, focusedElement: focused)
|
|
}
|
|
|
|
private func captureViaClipboard() throws -> Capture? {
|
|
guard AXIsProcessTrusted() else { throw CaptureError.notPermitted }
|
|
let saved = pasteboard.string(forType: .string)
|
|
let beforeChange = pasteboard.changeCount
|
|
|
|
sendCommandKey(CGKeyCode(kVK_ANSI_C))
|
|
// Give the frontmost app a moment to put the selection on the pasteboard.
|
|
Thread.sleep(forTimeInterval: 0.12)
|
|
|
|
guard pasteboard.changeCount != beforeChange,
|
|
let copied = pasteboard.string(forType: .string), !copied.isEmpty
|
|
else {
|
|
// Restore and report nothing selected.
|
|
if let saved { writeClipboard(saved) }
|
|
return nil
|
|
}
|
|
|
|
// Stash the saved clipboard on the capture so replace() can restore it.
|
|
savedClipboard = saved
|
|
return Capture(text: copied, method: .clipboard, focusedElement: nil)
|
|
}
|
|
|
|
// MARK: - Replace
|
|
|
|
@discardableResult
|
|
func replace(_ newText: String, capture: Capture) -> Bool {
|
|
switch capture.method {
|
|
case .accessibility:
|
|
guard let element = capture.focusedElement else { return false }
|
|
let status = AXUIElementSetAttributeValue(
|
|
element, kAXSelectedTextAttribute as CFString, newText as CFTypeRef)
|
|
return status == .success
|
|
case .clipboard:
|
|
writeClipboard(newText)
|
|
sendCommandKey(CGKeyCode(kVK_ANSI_V))
|
|
Thread.sleep(forTimeInterval: 0.10)
|
|
// Restore the user's previous clipboard contents (if they want that).
|
|
if restoreClipboard, let saved = savedClipboard {
|
|
Thread.sleep(forTimeInterval: 0.05)
|
|
writeClipboard(saved)
|
|
}
|
|
savedClipboard = nil
|
|
return true
|
|
}
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private var savedClipboard: String?
|
|
|
|
private func writeClipboard(_ text: String) {
|
|
pasteboard.clearContents()
|
|
pasteboard.setString(text, forType: .string)
|
|
}
|
|
|
|
/// Synthesize a ⌘<key> keystroke via CGEvent (needs Accessibility / Input Monitoring).
|
|
private func sendCommandKey(_ key: CGKeyCode) {
|
|
let source = CGEventSource(stateID: .combinedSessionState)
|
|
let down = CGEvent(keyboardEventSource: source, virtualKey: key, keyDown: true)
|
|
let up = CGEvent(keyboardEventSource: source, virtualKey: key, keyDown: false)
|
|
down?.flags = .maskCommand
|
|
up?.flags = .maskCommand
|
|
down?.post(tap: .cghidEventTap)
|
|
up?.post(tap: .cghidEventTap)
|
|
}
|
|
}
|