grammargem/Sources/GrammaGem/System/TextCapture.swift
BigMarc 35ae2065b3 Fix 22 audited bugs: stop hangs/crashes/text-corruption; auto-install model
Stability (the "super buggy" feel):
- Move the Accessibility-tree traversal OFF the main thread with a per-message
  timeout, so a busy/wedged target app can't beachball or hang GrammaGem.
- Replace every unchecked `as! AXUIElement` force-cast with a CFTypeID-checked
  safe cast (AXSupport), eliminating crashes from misbehaving AX servers.
- Never scan GrammaGem's own windows (self-PID exclusion).
- Generation tokens + in-flight guard so superseded scans/downloads can't clobber state.

Text safety:
- Live fixes now read the element's CURRENT value before writing (and only apply a
  span edit when the field still matches the snapshot), so edits made since the last
  scan are never overwritten. Cap per-field check length.
- Stable per-field identity (AX id / position) so the Live Check list no longer churns.
- Include focus + identity in change-detection so tabbing updates the focused field.

Other audited fixes:
- Page blocker: conservative skip when a browser's URL can't be read.
- Model download: validate on-disk size (reject HTML/truncated 200s), clean up
  partials on cancel/failure, handle URLError.cancelled.
- Licensing: clear a server-confirmed-invalid license instead of honoring grace;
  show the real device limit instead of "0 Macs".
- Hotkey re-register falls back to the default if a chosen combo is taken.
- Daily free-tier counter tracks the live timezone.
- "Fix focused field" ignores non-text controls.

Feature: auto-install the on-device model in the background on first launch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 16:08:35 +02:00

165 lines
6.3 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()
guard let focused = AX.copyElement(systemWide, kAXFocusedUIElementAttribute) 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 }
let systemWide = AXUIElementCreateSystemWide()
guard let focused = AX.copyElement(systemWide, kAXFocusedUIElementAttribute) else { return nil }
// 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)
}
}