mirror of
https://github.com/BigMarc/grammargem.git
synced 2026-10-13 17:01:55 +02:00
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>
102 lines
4.3 KiB
Swift
102 lines
4.3 KiB
Swift
import Foundation
|
|
import AppKit
|
|
import ApplicationServices
|
|
|
|
/// A text element discovered in the frontmost window via the Accessibility tree.
|
|
struct ScannedField {
|
|
let element: AXUIElement
|
|
let roleName: String
|
|
let label: String
|
|
let text: String
|
|
let isFocused: Bool
|
|
/// Stable-ish identity for SwiftUI: AX identifier if present, else role+label+position.
|
|
let stableID: String
|
|
}
|
|
|
|
/// Walks the Accessibility tree of the frontmost window and collects every
|
|
/// editable text element (text fields, text areas, combo/search boxes) with
|
|
/// content — so GrammarGem can check *all* the text on screen, not just the one
|
|
/// the cursor is in. Bounded so huge web/Electron trees can't stall the app, and
|
|
/// every cross-process message has a timeout so a wedged app can't hang the scan.
|
|
///
|
|
/// Safe to call off the main thread (AXUIElement APIs are thread-safe).
|
|
enum TextFieldScanner {
|
|
static func scan(
|
|
pid: pid_t, focused: AXUIElement?,
|
|
maxFields: Int = 30, maxVisited: Int = 3000
|
|
) -> [ScannedField] {
|
|
let axApp = AXUIElementCreateApplication(pid)
|
|
AX.setMessagingTimeout(axApp, 0.3)
|
|
guard let root = AX.copyElement(axApp, kAXFocusedWindowAttribute)
|
|
?? AX.copyElement(axApp, kAXMainWindowAttribute) else { return [] }
|
|
var out: [ScannedField] = []
|
|
var visited = 0
|
|
walk(root, focused: focused, depth: 0, out: &out, visited: &visited,
|
|
maxFields: maxFields, maxVisited: maxVisited)
|
|
return out
|
|
}
|
|
|
|
private static func walk(
|
|
_ el: AXUIElement, focused: AXUIElement?, depth: Int,
|
|
out: inout [ScannedField], visited: inout Int, maxFields: Int, maxVisited: Int
|
|
) {
|
|
if out.count >= maxFields || visited >= maxVisited || depth > 70 { return }
|
|
visited += 1
|
|
|
|
let role = AX.copyString(el, kAXRoleAttribute) ?? ""
|
|
if role == kAXTextFieldRole as String
|
|
|| role == kAXTextAreaRole as String
|
|
|| role == kAXComboBoxRole as String {
|
|
// Never surface password fields in the live monitor.
|
|
if !AX.isSecureTextField(el),
|
|
let value = AX.copyString(el, kAXValueAttribute),
|
|
value.trimmingCharacters(in: .whitespacesAndNewlines).count >= 3 {
|
|
let label = AX.copyString(el, kAXTitleAttribute)
|
|
?? AX.copyString(el, kAXDescriptionAttribute)
|
|
?? AX.copyString(el, kAXPlaceholderValueAttribute)
|
|
?? roleLabel(role)
|
|
let isFocused = focused != nil && CFEqual(el, focused!)
|
|
out.append(ScannedField(
|
|
element: el, roleName: roleLabel(role),
|
|
label: label, text: value, isFocused: isFocused,
|
|
stableID: identity(of: el, role: role, label: label)))
|
|
}
|
|
}
|
|
|
|
var childrenRef: CFTypeRef?
|
|
if AXUIElementCopyAttributeValue(el, kAXChildrenAttribute as CFString, &childrenRef) == .success,
|
|
let kids = childrenRef as? [AXUIElement] {
|
|
for k in kids {
|
|
if out.count >= maxFields || visited >= maxVisited { break }
|
|
walk(k, focused: focused, depth: depth + 1, out: &out,
|
|
visited: &visited, maxFields: maxFields, maxVisited: maxVisited)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A SwiftUI-stable identity that survives re-scans and text edits: prefer the
|
|
/// AX identifier, else fall back to role + label + on-screen position.
|
|
private static func identity(of el: AXUIElement, role: String, label: String) -> String {
|
|
if let axID = AX.copyString(el, kAXIdentifierAttribute), !axID.isEmpty {
|
|
return "id:\(axID)"
|
|
}
|
|
var posRef: CFTypeRef?
|
|
if AXUIElementCopyAttributeValue(el, kAXPositionAttribute as CFString, &posRef) == .success,
|
|
let posRef, CFGetTypeID(posRef) == AXValueGetTypeID() {
|
|
var point = CGPoint.zero
|
|
if AXValueGetValue(posRef as! AXValue, .cgPoint, &point) {
|
|
return "pos:\(role)|\(label)|\(Int(point.x)),\(Int(point.y))"
|
|
}
|
|
}
|
|
return "rl:\(role)|\(label)"
|
|
}
|
|
|
|
private static func roleLabel(_ role: String) -> String {
|
|
switch role {
|
|
case kAXTextAreaRole as String: return "Text area"
|
|
case kAXComboBoxRole as String: return "Combo box"
|
|
default: return "Text field"
|
|
}
|
|
}
|
|
}
|