diff --git a/Sources/GrammaGem/AI/ModelManager.swift b/Sources/GrammaGem/AI/ModelManager.swift index 0585109..fdcff5d 100644 --- a/Sources/GrammaGem/AI/ModelManager.swift +++ b/Sources/GrammaGem/AI/ModelManager.swift @@ -17,6 +17,7 @@ final class ModelManager: ObservableObject { @Published var selectedRepo: String = AppConfig.Model.defaultRepo private var task: Task? + private var generation = 0 init() { if isModelPresent(repo: selectedRepo) { state = .ready } @@ -40,25 +41,28 @@ final class ModelManager: ObservableObject { func startDownload(repo: String? = nil) { let target = repo ?? selectedRepo selectedRepo = target + if isModelPresent(repo: target) { state = .ready; return } task?.cancel() - task = Task { await self.run(repo: target) } + generation += 1 + let gen = generation + task = Task { await self.run(repo: target, generation: gen) } } func cancel() { task?.cancel() task = nil + generation += 1 // invalidate any in-flight run's state writes statusText = "" state = isModelPresent(repo: selectedRepo) ? .ready : .notDownloaded } // MARK: - Download - private func run(repo: String) async { - if isModelPresent(repo: repo) { state = .ready; return } + private func run(repo: String, generation gen: Int) async { + if isModelPresent(repo: repo) { set(gen, .ready, ""); return } let dir = modelDir(repo) - state = .downloading(progress: 0) - statusText = "Preparing…" + set(gen, .downloading(progress: 0), "Preparing…") do { try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) let files = try await fetchFileList(repo: repo) @@ -68,7 +72,8 @@ final class ModelManager: ObservableObject { for file in files { try Task.checkCancellation() - statusText = "Downloading \(file.path) (\(byteString(file.size)))…" + set(gen, .downloading(progress: min(1, Double(done) / Double(total))), + "Downloading \(file.path) (\(byteString(file.size)))…") let url = URL(string: "\(AppConfig.Model.huggingFaceBase)/\(repo)/resolve/main/\(file.path)")! let (tmp, response) = try await URLSession.shared.download(from: url) if let http = response as? HTTPURLResponse, http.statusCode != 200 { @@ -81,24 +86,48 @@ final class ModelManager: ObservableObject { try FileManager.default.removeItem(at: dest) } try FileManager.default.moveItem(at: tmp, to: dest) + + // Reject a wrong/truncated body (e.g. an HTML interstitial served + // with HTTP 200) by checking the on-disk size against the expected. + if file.size > 0 { + let actual = (try? FileManager.default.attributesOfItem(atPath: dest.path)[.size] as? Int64) ?? nil + if let actual, actual != file.size { + throw ModelError.corrupt(file.path) + } + } done += file.size - state = .downloading(progress: min(1, Double(done) / Double(total))) + set(gen, .downloading(progress: min(1, Double(done) / Double(total))), nil) } try Data().write(to: dir.appendingPathComponent(".complete")) - statusText = "" - state = .ready + set(gen, .ready, "") Log.ai.info("Model downloaded: \(repo, privacy: .public)") } catch is CancellationError { - statusText = "" - state = isModelPresent(repo: repo) ? .ready : .notDownloaded + cleanupPartial(repo: repo) + set(gen, isModelPresent(repo: repo) ? .ready : .notDownloaded, "") + } catch let urlError as URLError where urlError.code == .cancelled { + cleanupPartial(repo: repo) + set(gen, isModelPresent(repo: repo) ? .ready : .notDownloaded, "") } catch { - statusText = "" - state = .failed(error.localizedDescription) + cleanupPartial(repo: repo) + set(gen, .failed(error.localizedDescription), "") Log.ai.error("Model download failed: \(error.localizedDescription, privacy: .public)") } } + /// Apply a state/status update only if this run hasn't been superseded. + private func set(_ gen: Int, _ newState: State, _ status: String?) { + guard gen == generation else { return } + state = newState + if let status { statusText = status } + } + + /// Remove an incomplete model directory so partial files don't linger. + private func cleanupPartial(repo: String) { + guard !isModelPresent(repo: repo) else { return } + try? FileManager.default.removeItem(at: modelDir(repo)) + } + /// List the repo's files (+ sizes) from the Hugging Face tree API. private func fetchFileList(repo: String) async throws -> [(path: String, size: Int64)] { let url = URL(string: "\(AppConfig.Model.huggingFaceBase)/api/models/\(repo)/tree/main?recursive=1")! @@ -118,10 +147,12 @@ final class ModelManager: ObservableObject { enum ModelError: LocalizedError { case empty case http(Int, String) + case corrupt(String) var errorDescription: String? { switch self { case .empty: return "No model files were found for this repository." case .http(let code, let what): return "Download failed (\(code)) while fetching \(what)." + case .corrupt(let what): return "Downloaded file looked wrong (\(what)); please retry." } } } diff --git a/Sources/GrammaGem/App/AppState.swift b/Sources/GrammaGem/App/AppState.swift index e36b473..a123b7d 100644 --- a/Sources/GrammaGem/App/AppState.swift +++ b/Sources/GrammaGem/App/AppState.swift @@ -93,6 +93,13 @@ final class AppState: ObservableObject { .sink { [weak self] on in self?.liveMonitor.setEnabled(on) } .store(in: &cancellables) + // Auto-install the on-device AI model in the background on first use, so + // the AI features are ready without the user hunting for a button. The + // download is resumable/cancelable from the AI Model screen. + if !model.isModelPresent(repo: model.selectedRepo) { + model.startDownload() + } + permissions.refresh() appAwareEnabled = gate.appAwareEnabled syncIgnoreList() @@ -110,10 +117,20 @@ final class AppState: ObservableObject { /// (Re)register the global hotkeys from the current preferences. func reregisterHotkeys() { hotkeys.unregisterAll() - hotkeys.register(id: 1, keyCode: preferences.fixHotkey.keyCode, - modifiers: preferences.fixHotkey.carbonModifiers, action: .fix) - hotkeys.register(id: 2, keyCode: preferences.askHotkey.keyCode, - modifiers: preferences.askHotkey.carbonModifiers, action: .ask) + if !hotkeys.register(id: 1, keyCode: preferences.fixHotkey.keyCode, + modifiers: preferences.fixHotkey.carbonModifiers, action: .fix) { + // The chosen combo was rejected (already claimed) — keep a working + // shortcut by falling back to the default rather than leaving none. + hotkeys.register(id: 1, keyCode: AppConfig.Hotkey.defaultKeyCode, + modifiers: AppConfig.Hotkey.defaultModifiers, action: .fix) + lastStatus = "That Fix shortcut is already in use — kept the default (⌘;)." + } + if !hotkeys.register(id: 2, keyCode: preferences.askHotkey.keyCode, + modifiers: preferences.askHotkey.carbonModifiers, action: .ask) { + hotkeys.register(id: 2, keyCode: AppConfig.Hotkey.askKeyCode, + modifiers: AppConfig.Hotkey.askModifiers, action: .ask) + lastStatus = "That Ask shortcut is already in use — kept the default (⌘')." + } } func handle(_ action: HotkeyManager.Action) async { diff --git a/Sources/GrammaGem/App/OnboardingView.swift b/Sources/GrammaGem/App/OnboardingView.swift index 65a6286..22d7b0c 100644 --- a/Sources/GrammaGem/App/OnboardingView.swift +++ b/Sources/GrammaGem/App/OnboardingView.swift @@ -110,8 +110,8 @@ struct ModelDownloadControls: View { switch model.state { case .notDownloaded: VStack(alignment: .leading, spacing: 4) { - Button("Download model") { model.startDownload() } - Text("Downloads the model files from Hugging Face (about 1–2 GB). One time, then offline.") + Button("Download model now") { model.startDownload() } + Text("Installs automatically in the background on first launch (about 1–2 GB from Hugging Face). One time, then fully offline.") .font(.caption).foregroundStyle(.secondary) } case .downloading(let progress): diff --git a/Sources/GrammaGem/Entitlements/FeatureGate.swift b/Sources/GrammaGem/Entitlements/FeatureGate.swift index e4e3b89..981a02c 100644 --- a/Sources/GrammaGem/Entitlements/FeatureGate.swift +++ b/Sources/GrammaGem/Entitlements/FeatureGate.swift @@ -79,7 +79,7 @@ final class DailyCounter { let f = DateFormatter() f.dateFormat = "yyyy-MM-dd" f.locale = Locale(identifier: "en_US_POSIX") - f.timeZone = .current // local midnight reset + f.timeZone = .autoupdatingCurrent // tracks the live local zone (travel/TZ change) return f }() diff --git a/Sources/GrammaGem/Licensing/LemonSqueezyClient.swift b/Sources/GrammaGem/Licensing/LemonSqueezyClient.swift index 0c3da19..5b9a6b4 100644 --- a/Sources/GrammaGem/Licensing/LemonSqueezyClient.swift +++ b/Sources/GrammaGem/Licensing/LemonSqueezyClient.swift @@ -18,7 +18,7 @@ final class LemonSqueezyClient { "licenses/activate", params: ["license_key": key, "instance_name": instanceName]) - if let err = res.error { throw mapServerError(err) } + if let err = res.error { throw mapServerError(err, limit: res.license_key?.activation_limit) } guard res.activated, let instance = res.instance else { throw LicenseError.serverMessage("Activation failed.") } @@ -34,7 +34,7 @@ final class LemonSqueezyClient { if let instanceID { params["instance_id"] = instanceID } let res: LSValidateResponse = try await post("licenses/validate", params: params) - if let err = res.error { throw mapServerError(err) } + if let err = res.error { throw mapServerError(err, limit: res.license_key?.activation_limit) } let tier = try verifyAndResolveTier(meta: res.meta, licenseKey: res.license_key) return ValidationResult( valid: res.valid, tier: tier, @@ -84,10 +84,10 @@ final class LemonSqueezyClient { } } - private func mapServerError(_ message: String) -> LicenseError { + private func mapServerError(_ message: String, limit: Int? = nil) -> LicenseError { let m = message.lowercased() if m.contains("activation limit") { - return .activationLimitReached(0) + return .activationLimitReached(limit ?? 0) } if m.contains("not found") || m.contains("invalid") { return .invalidKey diff --git a/Sources/GrammaGem/Licensing/LicenseManager.swift b/Sources/GrammaGem/Licensing/LicenseManager.swift index 8508d02..5ec7371 100644 --- a/Sources/GrammaGem/Licensing/LicenseManager.swift +++ b/Sources/GrammaGem/Licensing/LicenseManager.swift @@ -78,12 +78,30 @@ final class LicenseManager: ObservableObject { Log.licensing.notice("License reported invalid by server; clearing.") clearLocal() } + } catch let e as LicenseError where Self.isServerConfirmedInvalid(e) { + // The server actively rejected the key (refunded/disabled/wrong product) + // — drop it now rather than honoring it for the 30-day grace window. + Log.licensing.notice("License rejected by server; clearing.") + clearLocal() } catch { // Offline / transient: keep the cached license under the grace window. Log.licensing.notice("Validation deferred (offline). Cached license stands under grace.") } } + /// Distinguish a server-confirmed-invalid license from a transient/offline failure. + private static func isServerConfirmedInvalid(_ error: LicenseError) -> Bool { + switch error { + case .invalidKey, .wrongProduct: + return true + case .serverMessage(let m): + let s = m.lowercased() + return ["disabled", "expired", "inactive", "revoked", "refund"].contains { s.contains($0) } + case .network, .activationLimitReached: + return false + } + } + /// Free a device slot so the license can move to another Mac. func deactivateThisDevice() async { guard let rec = record else { return } diff --git a/Sources/GrammaGem/System/AXSupport.swift b/Sources/GrammaGem/System/AXSupport.swift new file mode 100644 index 0000000..7dd5567 --- /dev/null +++ b/Sources/GrammaGem/System/AXSupport.swift @@ -0,0 +1,39 @@ +import Foundation +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) + } +} diff --git a/Sources/GrammaGem/System/AppDetector.swift b/Sources/GrammaGem/System/AppDetector.swift index 15df3d1..7d20436 100644 --- a/Sources/GrammaGem/System/AppDetector.swift +++ b/Sources/GrammaGem/System/AppDetector.swift @@ -32,20 +32,25 @@ final class AppDetector { guard AXIsProcessTrusted(), let app = NSWorkspace.shared.frontmostApplication else { return nil } let axApp = AXUIElementCreateApplication(app.processIdentifier) - var winRef: CFTypeRef? - guard AXUIElementCopyAttributeValue( - axApp, kAXFocusedWindowAttribute as CFString, &winRef) == .success, - let winRef - else { return nil } - let window = winRef as! AXUIElement - - var docRef: CFTypeRef? - if AXUIElementCopyAttributeValue( - window, kAXDocumentAttribute as CFString, &docRef) == .success, - let urlString = docRef as? String, - let host = URL(string: urlString)?.host { + AX.setMessagingTimeout(axApp, 0.3) + guard let window = AX.copyElement(axApp, kAXFocusedWindowAttribute) else { return nil } + if let urlString = AX.copyString(window, kAXDocumentAttribute), + let host = URL(string: urlString)?.host { return host } return nil } + + /// Known web browsers, where a nil domain means "URL undeterminable" (so the + /// page blocker should be conservative) rather than "not a web page". + private static let browserBundleIDs: Set = [ + "com.apple.Safari", "com.google.Chrome", "com.google.Chrome.canary", + "company.thebrowser.Browser", "com.microsoft.edgemac", "com.brave.Browser", + "org.mozilla.firefox", "com.operasoftware.Opera", "com.vivaldi.Vivaldi", + ] + + func isBrowser(_ bundleID: String?) -> Bool { + guard let bundleID else { return false } + return Self.browserBundleIDs.contains(bundleID) + } } diff --git a/Sources/GrammaGem/System/Exclusions.swift b/Sources/GrammaGem/System/Exclusions.swift index a0c91b1..4587ed7 100644 --- a/Sources/GrammaGem/System/Exclusions.swift +++ b/Sources/GrammaGem/System/Exclusions.swift @@ -27,6 +27,10 @@ final class Exclusions: ObservableObject { blockedDomains = d.stringArray(forKey: domainsKey) ?? [] } + /// True if the user has any domain rules (so we should be conservative when + /// a browser's URL can't be read). + var hasDomainRules: Bool { !blockedDomains.isEmpty } + /// Whether GrammaGem should be suppressed for this frontmost context. func isBlocked(bundleID: String?, domain: String?) -> Bool { if let bundleID, blockedApps.contains(bundleID) { return true } diff --git a/Sources/GrammaGem/System/HotkeyManager.swift b/Sources/GrammaGem/System/HotkeyManager.swift index 29d7ccb..c3287cf 100644 --- a/Sources/GrammaGem/System/HotkeyManager.swift +++ b/Sources/GrammaGem/System/HotkeyManager.swift @@ -34,7 +34,8 @@ final class HotkeyManager { modifiers: AppConfig.Hotkey.askModifiers, action: .ask) } - func register(id: UInt32, keyCode: UInt32, modifiers: UInt32, action: Action) { + @discardableResult + func register(id: UInt32, keyCode: UInt32, modifiers: UInt32, action: Action) -> Bool { idToAction[id] = action let hotKeyID = EventHotKeyID(signature: OSType(0x47474D31), id: id) // 'GGM1' var ref: EventHotKeyRef? @@ -43,9 +44,10 @@ final class HotkeyManager { GetEventDispatcherTarget(), 0, &ref) if status == noErr { refs.append(ref) - } else { - Log.system.error("RegisterEventHotKey failed: \(status)") + return true } + Log.system.error("RegisterEventHotKey failed: \(status)") + return false } func unregisterAll() { diff --git a/Sources/GrammaGem/System/LiveMonitor.swift b/Sources/GrammaGem/System/LiveMonitor.swift index cb717a1..8d9124c 100644 --- a/Sources/GrammaGem/System/LiveMonitor.swift +++ b/Sources/GrammaGem/System/LiveMonitor.swift @@ -13,11 +13,14 @@ struct DetectedField: Identifiable { let isFocused: Bool } -/// The always-on, menu-bar–resident monitor. While enabled, it periodically -/// scans **every** text area in the frontmost window (Accessibility API), runs -/// the offline grammar engine on each, and publishes the ones with issues — so -/// GrammaGem reflects problems across the whole screen, not just the focused -/// field. It stays out of excluded apps/domains and never sends text anywhere. +/// The always-on, menu-bar–resident monitor. While enabled it periodically scans +/// **every** text area in the frontmost window and publishes the ones with issues. +/// +/// Performance & safety: the Accessibility-tree traversal (the part that can hang +/// on a busy/wedged app) runs OFF the main thread with a per-message timeout; only +/// the bounded spell/grammar pass and publish happen on the main actor. It never +/// scans GrammaGem itself, respects the page blocker, and applies fixes against the +/// element's *live* value so it can't overwrite text typed since the last scan. @MainActor final class LiveMonitor: ObservableObject { @Published private(set) var fields: [DetectedField] = [] @@ -36,8 +39,12 @@ final class LiveMonitor: ObservableObject { private var timer: Timer? private var enabled = false private var paused = false + private var scanning = false private var lastHash = 0 - private let interval: TimeInterval = 1.4 + private var scanGeneration = 0 + private let interval: TimeInterval = 1.6 + private let maxCheckChars = 4000 + private let ownPID = ProcessInfo.processInfo.processIdentifier init(grammar: GrammarEngine, detector: AppDetector, exclusions: Exclusions, capture: TextCapture) { self.grammar = grammar @@ -49,10 +56,10 @@ final class LiveMonitor: ObservableObject { func setEnabled(_ on: Bool) { enabled = on; reconcile() } func setPaused(_ p: Bool) { paused = p; reconcile() } - /// Re-scan now (e.g. after applying a fix). - func refreshSoon() { lastHash = 0; tick() } + /// Re-scan now (e.g. after applying a fix), superseding any in-flight scan. + func refreshSoon() { lastHash = 0; tick(force: true) } - // MARK: - Applying fixes + // MARK: - Applying fixes (always against the element's LIVE value) @discardableResult func fixAll() -> Int { @@ -69,16 +76,24 @@ final class LiveMonitor: ObservableObject { } func apply(_ suggestion: Suggestion, in field: DetectedField) { - let ns = NSMutableString(string: field.text) + // Only apply a span edit if the field still matches the snapshot the + // suggestion offsets were computed against; otherwise re-scan instead of + // corrupting text the user has since changed. + guard let live = capture.currentValue(of: field.element) else { return } + guard live == field.text else { refreshSoon(); return } + let ns = NSMutableString(string: live) guard suggestion.location + suggestion.length <= ns.length else { return } ns.replaceCharacters(in: suggestion.range, with: suggestion.replacement) setValue(ns as String, on: field.element) refreshSoon() } + /// Correct the element's *current* value (never a stale snapshot), so any + /// keystrokes entered since the scan are preserved. private func applyCorrection(to field: DetectedField) -> Int { - let corrected = grammar.correct(field.text) - guard corrected != field.text else { return 0 } + guard let live = capture.currentValue(of: field.element) else { return 0 } + let corrected = grammar.correct(live) + guard corrected != live else { return 0 } setValue(corrected, on: field.element) return field.suggestions.count } @@ -107,49 +122,68 @@ final class LiveMonitor: ObservableObject { } } - private func tick() { + private func tick(force: Bool = false) { guard enabled, !paused, AXIsProcessTrusted() else { clear(); return } + if scanning && !force { return } // don't pile up scans guard let app = NSWorkspace.shared.frontmostApplication else { clear(); return } + + // Never scan GrammaGem's own windows. + if app.processIdentifier == ownPID { clear(); return } activeAppName = app.localizedName ?? "" - if exclusions.isBlocked(bundleID: app.bundleIdentifier, domain: detector.frontmostDomain()) { - clear() - return - } + let bundleID = app.bundleIdentifier + let domain = detector.frontmostDomain() + if exclusions.isBlocked(bundleID: bundleID, domain: domain) { clear(); return } + // Conservative: a blocked-domain user in a browser whose URL we can't read + // should not be monitored rather than risk leaking a sensitive page. + if domain == nil, detector.isBrowser(bundleID), exclusions.hasDomainRules { clear(); return } - let focused = systemFocusedElement() - let scanned = TextFieldScanner.scan(pid: app.processIdentifier, focused: focused) + let pid = app.processIdentifier + scanGeneration += 1 + let generation = scanGeneration + scanning = true + + // Heavy AX traversal off the main thread. + Task.detached(priority: .utility) { + let focused = LiveMonitor.systemFocusedElement() + let scanned = TextFieldScanner.scan(pid: pid, focused: focused) + await MainActor.run { self.ingest(scanned, generation: generation) } + } + } + + private func ingest(_ scanned: [ScannedField], generation: Int) { + scanning = false + guard generation == scanGeneration else { return } // superseded by a newer scan guard !scanned.isEmpty else { clear(); return } - // Skip re-checking if nothing on screen changed. + // Change detection includes focus + element identity so tabbing between + // same-text fields still updates the focused indicator. var hasher = Hasher() - hasher.combine(app.bundleIdentifier ?? "") - for f in scanned { hasher.combine(f.text) } + for f in scanned { + hasher.combine(f.text) + hasher.combine(f.isFocused) + hasher.combine(f.stableID) + } let h = hasher.finalize() if h == lastHash { return } lastHash = h scannedCount = scanned.count var detected: [DetectedField] = [] - for (i, f) in scanned.enumerated() { - let suggestions = grammar.check(f.text) + for f in scanned { + let text = f.text.count > maxCheckChars ? String(f.text.prefix(maxCheckChars)) : f.text + let suggestions = grammar.check(text) guard !suggestions.isEmpty else { continue } detected.append(DetectedField( - id: "\(f.roleName)#\(i)", label: f.label, roleName: f.roleName, + id: f.stableID, label: f.label, roleName: f.roleName, text: f.text, suggestions: suggestions, element: f.element, isFocused: f.isFocused)) } - // Focused field first. detected.sort { ($0.isFocused ? 0 : 1) < ($1.isFocused ? 0 : 1) } fields = detected } - private func systemFocusedElement() -> AXUIElement? { - let system = AXUIElementCreateSystemWide() - var ref: CFTypeRef? - guard AXUIElementCopyAttributeValue( - system, kAXFocusedUIElementAttribute as CFString, &ref) == .success, let ref - else { return nil } - return (ref as! AXUIElement) + private nonisolated static func systemFocusedElement() -> AXUIElement? { + AX.copyElement(AXUIElementCreateSystemWide(), kAXFocusedUIElementAttribute) } private func clear() { diff --git a/Sources/GrammaGem/System/TextCapture.swift b/Sources/GrammaGem/System/TextCapture.swift index 862d308..8c1a090 100644 --- a/Sources/GrammaGem/System/TextCapture.swift +++ b/Sources/GrammaGem/System/TextCapture.swift @@ -55,13 +55,7 @@ final class TextCapture { 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 + guard let focused = AX.copyElement(systemWide, kAXFocusedUIElementAttribute) else { return nil } var valueRef: CFTypeRef? guard AXUIElementCopyAttributeValue( @@ -100,21 +94,25 @@ final class TextCapture { func focusedFieldText() -> (text: String, element: AXUIElement)? { guard AXIsProcessTrusted() else { return nil } let systemWide = AXUIElementCreateSystemWide() - var focusedRef: CFTypeRef? - guard AXUIElementCopyAttributeValue( - systemWide, kAXFocusedUIElementAttribute as CFString, &focusedRef) == .success, - let focusedRef - else { return nil } - let focused = focusedRef as! AXUIElement + guard let focused = AX.copyElement(systemWide, kAXFocusedUIElementAttribute) else { return nil } - var valueRef: CFTypeRef? - guard AXUIElementCopyAttributeValue( - focused, kAXValueAttribute as CFString, &valueRef) == .success, - let text = valueRef as? String + // 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 diff --git a/Sources/GrammaGem/System/TextFieldScanner.swift b/Sources/GrammaGem/System/TextFieldScanner.swift index 68f6dca..e2c40fb 100644 --- a/Sources/GrammaGem/System/TextFieldScanner.swift +++ b/Sources/GrammaGem/System/TextFieldScanner.swift @@ -9,19 +9,26 @@ struct ScannedField { 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 GrammaGem 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. +/// 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 = 4000 + maxFields: Int = 30, maxVisited: Int = 3000 ) -> [ScannedField] { let axApp = AXUIElementCreateApplication(pid) - guard let root = window(of: axApp) ?? optional(axApp) else { return [] } + 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, @@ -29,17 +36,6 @@ enum TextFieldScanner { return out } - private static func optional(_ el: AXUIElement) -> AXUIElement? { el } - - private static func window(of axApp: AXUIElement) -> AXUIElement? { - for attr in [kAXFocusedWindowAttribute, kAXMainWindowAttribute] { - var ref: CFTypeRef? - if AXUIElementCopyAttributeValue(axApp, attr as CFString, &ref) == .success, - let ref { return (ref as! AXUIElement) } - } - return nil - } - private static func walk( _ el: AXUIElement, focused: AXUIElement?, depth: Int, out: inout [ScannedField], visited: inout Int, maxFields: Int, maxVisited: Int @@ -47,20 +43,21 @@ enum TextFieldScanner { if out.count >= maxFields || visited >= maxVisited || depth > 70 { return } visited += 1 - let role = string(el, kAXRoleAttribute) ?? "" + let role = AX.copyString(el, kAXRoleAttribute) ?? "" if role == kAXTextFieldRole as String || role == kAXTextAreaRole as String || role == kAXComboBoxRole as String { - if let value = string(el, kAXValueAttribute), + if let value = AX.copyString(el, kAXValueAttribute), value.trimmingCharacters(in: .whitespacesAndNewlines).count >= 3 { - let label = string(el, kAXTitleAttribute) - ?? string(el, kAXDescriptionAttribute) - ?? string(el, kAXPlaceholderValueAttribute) + 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)) + label: label, text: value, isFocused: isFocused, + stableID: identity(of: el, role: role, label: label))) } } @@ -75,10 +72,21 @@ enum TextFieldScanner { } } - private static func string(_ el: AXUIElement, _ attr: String) -> String? { - var ref: CFTypeRef? - guard AXUIElementCopyAttributeValue(el, attr as CFString, &ref) == .success else { return nil } - return ref as? String + /// 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 {