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

77 lines
3.7 KiB
Swift

import Foundation
import AppKit
import ApplicationServices
/// Safe Accessibility helpers shared across the System layer.
///
/// `AXUIElementCopyAttributeValue` returning `.success` only guarantees a
/// non-null `CFTypeRef` — NOT that it is an `AXUIElement`. A misbehaving AX
/// server (Electron/Java/custom) can return a different CF type, and a forced
/// `as!` cast then traps and crashes the whole app. These helpers cast safely.
enum AX {
/// Safely bridge a CFTypeRef to AXUIElement, returning nil on type mismatch.
static func element(_ ref: CFTypeRef?) -> AXUIElement? {
guard let ref, CFGetTypeID(ref) == AXUIElementGetTypeID() else { return nil }
return (ref as! AXUIElement) // safe: type id verified above
}
/// Copy an attribute as an `AXUIElement` (nil on failure/type mismatch).
static func copyElement(_ element: AXUIElement, _ attribute: String) -> AXUIElement? {
var ref: CFTypeRef?
guard AXUIElementCopyAttributeValue(element, attribute as CFString, &ref) == .success else {
return nil
}
return AX.element(ref)
}
/// Copy an attribute as a `String` (nil on failure/wrong type).
static func copyString(_ element: AXUIElement, _ attribute: String) -> String? {
var ref: CFTypeRef?
guard AXUIElementCopyAttributeValue(element, attribute as CFString, &ref) == .success else {
return nil
}
return ref as? String
}
/// Bound each cross-process AX message so a wedged target app can't hang us.
static func setMessagingTimeout(_ element: AXUIElement, _ seconds: Float) {
AXUIElementSetMessagingTimeout(element, seconds)
}
/// Whether an element is a secure text field (a password input). GrammarGem
/// must NEVER read or rewrite these — so a user who forgot to exclude a
/// password manager or login form can't have a decrypted password captured,
/// sent through the grammar/AI engines, and written back. This is enforced in
/// code rather than relying on the user's exclusion blocklist.
static func isSecureTextField(_ element: AXUIElement) -> Bool {
// AXSecureTextField is the subrole AppKit/most Electron inputs report for
// password fields. String-compared (the constant isn't always imported).
copyString(element, kAXSubroleAttribute) == "AXSecureTextField"
}
/// The currently-focused UI element across the system — robust to the
/// system-wide element failing.
///
/// On recent macOS, `AXUIElementCreateSystemWide()` returns
/// `kAXErrorCannotComplete` (-25204) for *every* query made by a background /
/// accessory process (which is exactly what a menu-bar app like GrammarGem is).
/// When that happens the system-wide focused-element read yields nil and the
/// app "sees" no text or text fields at all. Resolving the focused element
/// through the **frontmost application's** own AX element works reliably in
/// that context, so we prefer the system-wide read (one IPC hop, correct on
/// OSes where it works) and transparently fall back to the per-app read.
static func focusedElement() -> AXUIElement? {
let systemWide = AXUIElementCreateSystemWide()
setMessagingTimeout(systemWide, 0.5)
if let element = copyElement(systemWide, kAXFocusedUIElementAttribute as String) {
return element
}
guard let pid = NSWorkspace.shared.frontmostApplication?.processIdentifier else {
return nil
}
let axApp = AXUIElementCreateApplication(pid)
setMessagingTimeout(axApp, 0.5)
return copyElement(axApp, kAXFocusedUIElementAttribute as String)
}
}