grammargem/Sources/GrammarGem/System/TextCapture.swift
BigMarc 201fe04d8a feat: backend-neutral AI seam, Ollama backend, extension bridge + trust/correctness fixes
Wave 1 (correctness/trust):
- Fix paid large-model dead path: engine reads the persisted selected repo via a
  thread-safe ModelManager.activeRepo(), so download/load/isReady now agree.
- Lemon Squeezy store/product check fails closed in release builds — the
  placeholder-ID 'any key unlocks paid' bypass can no longer ship.
- Replace force-unwrapped Hugging Face URLs with safe URLComponents construction.
- Never read/replace/scan secure (password) fields via AX.isSecureTextField.

Wave 2 (compounding seam):
- Extract AIPromptKit (prompts/temperature/maxTokens/cleanup) out of MLXEngine so
  every backend produces identical prompts and output.
- AIEngine gains protectedTerms (personal dictionary now actually injected into the
  LLM), warmup(), and runStreaming(); add makeAIEngine() factory.
- Honor WritingMode.lengthCap for maxTokens; warm the model on launch/after download.
- Guard custom modes against empty prompts and duplicate names.

Wave 3: OllamaEngine — a URLSession-only AIEngine backend (cross-platform, opt-in
via GRAMMARGEM_AI_BACKEND=ollama).

Wave 4: LocalEngineServer — loopback-only, token-authed HTTP bridge (Network.framework,
no new deps), off by default, exposing Harper + the LLM + the entitlement gate to a
browser extension.

Tests: 6 new unit tests for the AIPrompts seam and custom-mode guard (19 pass).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 20:21:29 +02:00

171 lines
6.6 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 GrammarGem 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
case secureField
var errorDescription: String? {
switch self {
case .noSelection: return "Select some text first, then press the hotkey."
case .notPermitted: return "GrammarGem needs Accessibility permission to read and replace text."
case .secureField: return "GrammarGem never reads password fields."
}
}
}
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.
/// Refuses outright if the focused element is a password field.
func capture() throws -> Capture {
let focused = AXIsProcessTrusted() ? AX.focusedElement() : nil
if let focused, AX.isSecureTextField(focused) {
// Bail before the clipboard fallback too — never synthesize ⌘C into a
// password field.
throw CaptureError.secureField
}
if let ax = captureViaAccessibility(focused) {
return ax
}
if let clip = try captureViaClipboard() {
return clip
}
throw CaptureError.noSelection
}
private func captureViaAccessibility(_ focused: AXUIElement?) -> Capture? {
guard let focused else { return nil }
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: - Focused field (whole-value read/write, for live monitoring + "fix all")
/// The full text value of the focused element (not just the selection).
func focusedFieldText() -> (text: String, element: AXUIElement)? {
guard AXIsProcessTrusted() else { return nil }
guard let focused = AX.focusedElement() else { return nil }
guard !AX.isSecureTextField(focused) else { return nil } // never fix a password field
// Only treat genuine editable text roles as fixable, so we don't report
// "nothing selected" failures on sliders/steppers/custom controls.
let role = AX.copyString(focused, kAXRoleAttribute)
guard role == kAXTextFieldRole as String
|| role == kAXTextAreaRole as String
|| role == kAXComboBoxRole as String
else { return nil }
guard let text = AX.copyString(focused, kAXValueAttribute) else { return nil }
return (text, focused)
}
/// Re-read an element's current value (used to avoid writing stale text).
func currentValue(of element: AXUIElement) -> String? {
AX.copyString(element, kAXValueAttribute)
}
@discardableResult
func setFocusedField(_ text: String, element: AXUIElement) -> Bool {
AXUIElementSetAttributeValue(element, kAXValueAttribute as CFString, text as CFTypeRef) == .success
}
// 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)
}
}