mirror of
https://github.com/BigMarc/grammargem.git
synced 2026-10-13 17:01:55 +02:00
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>
This commit is contained in:
parent
5d1f152fc7
commit
35ae2065b3
@ -17,6 +17,7 @@ final class ModelManager: ObservableObject {
|
|||||||
@Published var selectedRepo: String = AppConfig.Model.defaultRepo
|
@Published var selectedRepo: String = AppConfig.Model.defaultRepo
|
||||||
|
|
||||||
private var task: Task<Void, Never>?
|
private var task: Task<Void, Never>?
|
||||||
|
private var generation = 0
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
if isModelPresent(repo: selectedRepo) { state = .ready }
|
if isModelPresent(repo: selectedRepo) { state = .ready }
|
||||||
@ -40,25 +41,28 @@ final class ModelManager: ObservableObject {
|
|||||||
func startDownload(repo: String? = nil) {
|
func startDownload(repo: String? = nil) {
|
||||||
let target = repo ?? selectedRepo
|
let target = repo ?? selectedRepo
|
||||||
selectedRepo = target
|
selectedRepo = target
|
||||||
|
if isModelPresent(repo: target) { state = .ready; return }
|
||||||
task?.cancel()
|
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() {
|
func cancel() {
|
||||||
task?.cancel()
|
task?.cancel()
|
||||||
task = nil
|
task = nil
|
||||||
|
generation += 1 // invalidate any in-flight run's state writes
|
||||||
statusText = ""
|
statusText = ""
|
||||||
state = isModelPresent(repo: selectedRepo) ? .ready : .notDownloaded
|
state = isModelPresent(repo: selectedRepo) ? .ready : .notDownloaded
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Download
|
// MARK: - Download
|
||||||
|
|
||||||
private func run(repo: String) async {
|
private func run(repo: String, generation gen: Int) async {
|
||||||
if isModelPresent(repo: repo) { state = .ready; return }
|
if isModelPresent(repo: repo) { set(gen, .ready, ""); return }
|
||||||
|
|
||||||
let dir = modelDir(repo)
|
let dir = modelDir(repo)
|
||||||
state = .downloading(progress: 0)
|
set(gen, .downloading(progress: 0), "Preparing…")
|
||||||
statusText = "Preparing…"
|
|
||||||
do {
|
do {
|
||||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
let files = try await fetchFileList(repo: repo)
|
let files = try await fetchFileList(repo: repo)
|
||||||
@ -68,7 +72,8 @@ final class ModelManager: ObservableObject {
|
|||||||
|
|
||||||
for file in files {
|
for file in files {
|
||||||
try Task.checkCancellation()
|
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 url = URL(string: "\(AppConfig.Model.huggingFaceBase)/\(repo)/resolve/main/\(file.path)")!
|
||||||
let (tmp, response) = try await URLSession.shared.download(from: url)
|
let (tmp, response) = try await URLSession.shared.download(from: url)
|
||||||
if let http = response as? HTTPURLResponse, http.statusCode != 200 {
|
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.removeItem(at: dest)
|
||||||
}
|
}
|
||||||
try FileManager.default.moveItem(at: tmp, to: 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
|
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"))
|
try Data().write(to: dir.appendingPathComponent(".complete"))
|
||||||
statusText = ""
|
set(gen, .ready, "")
|
||||||
state = .ready
|
|
||||||
Log.ai.info("Model downloaded: \(repo, privacy: .public)")
|
Log.ai.info("Model downloaded: \(repo, privacy: .public)")
|
||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
statusText = ""
|
cleanupPartial(repo: repo)
|
||||||
state = isModelPresent(repo: repo) ? .ready : .notDownloaded
|
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 {
|
} catch {
|
||||||
statusText = ""
|
cleanupPartial(repo: repo)
|
||||||
state = .failed(error.localizedDescription)
|
set(gen, .failed(error.localizedDescription), "")
|
||||||
Log.ai.error("Model download failed: \(error.localizedDescription, privacy: .public)")
|
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.
|
/// List the repo's files (+ sizes) from the Hugging Face tree API.
|
||||||
private func fetchFileList(repo: String) async throws -> [(path: String, size: Int64)] {
|
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")!
|
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 {
|
enum ModelError: LocalizedError {
|
||||||
case empty
|
case empty
|
||||||
case http(Int, String)
|
case http(Int, String)
|
||||||
|
case corrupt(String)
|
||||||
var errorDescription: String? {
|
var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .empty: return "No model files were found for this repository."
|
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 .http(let code, let what): return "Download failed (\(code)) while fetching \(what)."
|
||||||
|
case .corrupt(let what): return "Downloaded file looked wrong (\(what)); please retry."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -93,6 +93,13 @@ final class AppState: ObservableObject {
|
|||||||
.sink { [weak self] on in self?.liveMonitor.setEnabled(on) }
|
.sink { [weak self] on in self?.liveMonitor.setEnabled(on) }
|
||||||
.store(in: &cancellables)
|
.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()
|
permissions.refresh()
|
||||||
appAwareEnabled = gate.appAwareEnabled
|
appAwareEnabled = gate.appAwareEnabled
|
||||||
syncIgnoreList()
|
syncIgnoreList()
|
||||||
@ -110,10 +117,20 @@ final class AppState: ObservableObject {
|
|||||||
/// (Re)register the global hotkeys from the current preferences.
|
/// (Re)register the global hotkeys from the current preferences.
|
||||||
func reregisterHotkeys() {
|
func reregisterHotkeys() {
|
||||||
hotkeys.unregisterAll()
|
hotkeys.unregisterAll()
|
||||||
hotkeys.register(id: 1, keyCode: preferences.fixHotkey.keyCode,
|
if !hotkeys.register(id: 1, keyCode: preferences.fixHotkey.keyCode,
|
||||||
modifiers: preferences.fixHotkey.carbonModifiers, action: .fix)
|
modifiers: preferences.fixHotkey.carbonModifiers, action: .fix) {
|
||||||
hotkeys.register(id: 2, keyCode: preferences.askHotkey.keyCode,
|
// The chosen combo was rejected (already claimed) — keep a working
|
||||||
modifiers: preferences.askHotkey.carbonModifiers, action: .ask)
|
// 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 {
|
func handle(_ action: HotkeyManager.Action) async {
|
||||||
|
|||||||
@ -110,8 +110,8 @@ struct ModelDownloadControls: View {
|
|||||||
switch model.state {
|
switch model.state {
|
||||||
case .notDownloaded:
|
case .notDownloaded:
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Button("Download model") { model.startDownload() }
|
Button("Download model now") { model.startDownload() }
|
||||||
Text("Downloads the model files from Hugging Face (about 1–2 GB). One time, then offline.")
|
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)
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
case .downloading(let progress):
|
case .downloading(let progress):
|
||||||
|
|||||||
@ -79,7 +79,7 @@ final class DailyCounter {
|
|||||||
let f = DateFormatter()
|
let f = DateFormatter()
|
||||||
f.dateFormat = "yyyy-MM-dd"
|
f.dateFormat = "yyyy-MM-dd"
|
||||||
f.locale = Locale(identifier: "en_US_POSIX")
|
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
|
return f
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|||||||
@ -18,7 +18,7 @@ final class LemonSqueezyClient {
|
|||||||
"licenses/activate",
|
"licenses/activate",
|
||||||
params: ["license_key": key, "instance_name": instanceName])
|
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 {
|
guard res.activated, let instance = res.instance else {
|
||||||
throw LicenseError.serverMessage("Activation failed.")
|
throw LicenseError.serverMessage("Activation failed.")
|
||||||
}
|
}
|
||||||
@ -34,7 +34,7 @@ final class LemonSqueezyClient {
|
|||||||
if let instanceID { params["instance_id"] = instanceID }
|
if let instanceID { params["instance_id"] = instanceID }
|
||||||
let res: LSValidateResponse = try await post("licenses/validate", params: params)
|
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)
|
let tier = try verifyAndResolveTier(meta: res.meta, licenseKey: res.license_key)
|
||||||
return ValidationResult(
|
return ValidationResult(
|
||||||
valid: res.valid, tier: tier,
|
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()
|
let m = message.lowercased()
|
||||||
if m.contains("activation limit") {
|
if m.contains("activation limit") {
|
||||||
return .activationLimitReached(0)
|
return .activationLimitReached(limit ?? 0)
|
||||||
}
|
}
|
||||||
if m.contains("not found") || m.contains("invalid") {
|
if m.contains("not found") || m.contains("invalid") {
|
||||||
return .invalidKey
|
return .invalidKey
|
||||||
|
|||||||
@ -78,12 +78,30 @@ final class LicenseManager: ObservableObject {
|
|||||||
Log.licensing.notice("License reported invalid by server; clearing.")
|
Log.licensing.notice("License reported invalid by server; clearing.")
|
||||||
clearLocal()
|
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 {
|
} catch {
|
||||||
// Offline / transient: keep the cached license under the grace window.
|
// Offline / transient: keep the cached license under the grace window.
|
||||||
Log.licensing.notice("Validation deferred (offline). Cached license stands under grace.")
|
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.
|
/// Free a device slot so the license can move to another Mac.
|
||||||
func deactivateThisDevice() async {
|
func deactivateThisDevice() async {
|
||||||
guard let rec = record else { return }
|
guard let rec = record else { return }
|
||||||
|
|||||||
39
Sources/GrammaGem/System/AXSupport.swift
Normal file
39
Sources/GrammaGem/System/AXSupport.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -32,20 +32,25 @@ final class AppDetector {
|
|||||||
guard AXIsProcessTrusted(),
|
guard AXIsProcessTrusted(),
|
||||||
let app = NSWorkspace.shared.frontmostApplication else { return nil }
|
let app = NSWorkspace.shared.frontmostApplication else { return nil }
|
||||||
let axApp = AXUIElementCreateApplication(app.processIdentifier)
|
let axApp = AXUIElementCreateApplication(app.processIdentifier)
|
||||||
var winRef: CFTypeRef?
|
AX.setMessagingTimeout(axApp, 0.3)
|
||||||
guard AXUIElementCopyAttributeValue(
|
guard let window = AX.copyElement(axApp, kAXFocusedWindowAttribute) else { return nil }
|
||||||
axApp, kAXFocusedWindowAttribute as CFString, &winRef) == .success,
|
if let urlString = AX.copyString(window, kAXDocumentAttribute),
|
||||||
let winRef
|
let host = URL(string: urlString)?.host {
|
||||||
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 {
|
|
||||||
return host
|
return host
|
||||||
}
|
}
|
||||||
return nil
|
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<String> = [
|
||||||
|
"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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,6 +27,10 @@ final class Exclusions: ObservableObject {
|
|||||||
blockedDomains = d.stringArray(forKey: domainsKey) ?? []
|
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.
|
/// Whether GrammaGem should be suppressed for this frontmost context.
|
||||||
func isBlocked(bundleID: String?, domain: String?) -> Bool {
|
func isBlocked(bundleID: String?, domain: String?) -> Bool {
|
||||||
if let bundleID, blockedApps.contains(bundleID) { return true }
|
if let bundleID, blockedApps.contains(bundleID) { return true }
|
||||||
|
|||||||
@ -34,7 +34,8 @@ final class HotkeyManager {
|
|||||||
modifiers: AppConfig.Hotkey.askModifiers, action: .ask)
|
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
|
idToAction[id] = action
|
||||||
let hotKeyID = EventHotKeyID(signature: OSType(0x47474D31), id: id) // 'GGM1'
|
let hotKeyID = EventHotKeyID(signature: OSType(0x47474D31), id: id) // 'GGM1'
|
||||||
var ref: EventHotKeyRef?
|
var ref: EventHotKeyRef?
|
||||||
@ -43,9 +44,10 @@ final class HotkeyManager {
|
|||||||
GetEventDispatcherTarget(), 0, &ref)
|
GetEventDispatcherTarget(), 0, &ref)
|
||||||
if status == noErr {
|
if status == noErr {
|
||||||
refs.append(ref)
|
refs.append(ref)
|
||||||
} else {
|
return true
|
||||||
Log.system.error("RegisterEventHotKey failed: \(status)")
|
|
||||||
}
|
}
|
||||||
|
Log.system.error("RegisterEventHotKey failed: \(status)")
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func unregisterAll() {
|
func unregisterAll() {
|
||||||
|
|||||||
@ -13,11 +13,14 @@ struct DetectedField: Identifiable {
|
|||||||
let isFocused: Bool
|
let isFocused: Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The always-on, menu-bar–resident monitor. While enabled, it periodically
|
/// The always-on, menu-bar–resident monitor. While enabled it periodically scans
|
||||||
/// scans **every** text area in the frontmost window (Accessibility API), runs
|
/// **every** text area in the frontmost window and publishes the ones with issues.
|
||||||
/// the offline grammar engine on each, and publishes the ones with issues — so
|
///
|
||||||
/// GrammaGem reflects problems across the whole screen, not just the focused
|
/// Performance & safety: the Accessibility-tree traversal (the part that can hang
|
||||||
/// field. It stays out of excluded apps/domains and never sends text anywhere.
|
/// 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
|
@MainActor
|
||||||
final class LiveMonitor: ObservableObject {
|
final class LiveMonitor: ObservableObject {
|
||||||
@Published private(set) var fields: [DetectedField] = []
|
@Published private(set) var fields: [DetectedField] = []
|
||||||
@ -36,8 +39,12 @@ final class LiveMonitor: ObservableObject {
|
|||||||
private var timer: Timer?
|
private var timer: Timer?
|
||||||
private var enabled = false
|
private var enabled = false
|
||||||
private var paused = false
|
private var paused = false
|
||||||
|
private var scanning = false
|
||||||
private var lastHash = 0
|
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) {
|
init(grammar: GrammarEngine, detector: AppDetector, exclusions: Exclusions, capture: TextCapture) {
|
||||||
self.grammar = grammar
|
self.grammar = grammar
|
||||||
@ -49,10 +56,10 @@ final class LiveMonitor: ObservableObject {
|
|||||||
func setEnabled(_ on: Bool) { enabled = on; reconcile() }
|
func setEnabled(_ on: Bool) { enabled = on; reconcile() }
|
||||||
func setPaused(_ p: Bool) { paused = p; reconcile() }
|
func setPaused(_ p: Bool) { paused = p; reconcile() }
|
||||||
|
|
||||||
/// Re-scan now (e.g. after applying a fix).
|
/// Re-scan now (e.g. after applying a fix), superseding any in-flight scan.
|
||||||
func refreshSoon() { lastHash = 0; tick() }
|
func refreshSoon() { lastHash = 0; tick(force: true) }
|
||||||
|
|
||||||
// MARK: - Applying fixes
|
// MARK: - Applying fixes (always against the element's LIVE value)
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func fixAll() -> Int {
|
func fixAll() -> Int {
|
||||||
@ -69,16 +76,24 @@ final class LiveMonitor: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func apply(_ suggestion: Suggestion, in field: DetectedField) {
|
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 }
|
guard suggestion.location + suggestion.length <= ns.length else { return }
|
||||||
ns.replaceCharacters(in: suggestion.range, with: suggestion.replacement)
|
ns.replaceCharacters(in: suggestion.range, with: suggestion.replacement)
|
||||||
setValue(ns as String, on: field.element)
|
setValue(ns as String, on: field.element)
|
||||||
refreshSoon()
|
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 {
|
private func applyCorrection(to field: DetectedField) -> Int {
|
||||||
let corrected = grammar.correct(field.text)
|
guard let live = capture.currentValue(of: field.element) else { return 0 }
|
||||||
guard corrected != field.text else { return 0 }
|
let corrected = grammar.correct(live)
|
||||||
|
guard corrected != live else { return 0 }
|
||||||
setValue(corrected, on: field.element)
|
setValue(corrected, on: field.element)
|
||||||
return field.suggestions.count
|
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 }
|
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 }
|
guard let app = NSWorkspace.shared.frontmostApplication else { clear(); return }
|
||||||
|
|
||||||
|
// Never scan GrammaGem's own windows.
|
||||||
|
if app.processIdentifier == ownPID { clear(); return }
|
||||||
activeAppName = app.localizedName ?? ""
|
activeAppName = app.localizedName ?? ""
|
||||||
|
|
||||||
if exclusions.isBlocked(bundleID: app.bundleIdentifier, domain: detector.frontmostDomain()) {
|
let bundleID = app.bundleIdentifier
|
||||||
clear()
|
let domain = detector.frontmostDomain()
|
||||||
return
|
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 pid = app.processIdentifier
|
||||||
let scanned = TextFieldScanner.scan(pid: app.processIdentifier, focused: focused)
|
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 }
|
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()
|
var hasher = Hasher()
|
||||||
hasher.combine(app.bundleIdentifier ?? "")
|
for f in scanned {
|
||||||
for f in scanned { hasher.combine(f.text) }
|
hasher.combine(f.text)
|
||||||
|
hasher.combine(f.isFocused)
|
||||||
|
hasher.combine(f.stableID)
|
||||||
|
}
|
||||||
let h = hasher.finalize()
|
let h = hasher.finalize()
|
||||||
if h == lastHash { return }
|
if h == lastHash { return }
|
||||||
lastHash = h
|
lastHash = h
|
||||||
|
|
||||||
scannedCount = scanned.count
|
scannedCount = scanned.count
|
||||||
var detected: [DetectedField] = []
|
var detected: [DetectedField] = []
|
||||||
for (i, f) in scanned.enumerated() {
|
for f in scanned {
|
||||||
let suggestions = grammar.check(f.text)
|
let text = f.text.count > maxCheckChars ? String(f.text.prefix(maxCheckChars)) : f.text
|
||||||
|
let suggestions = grammar.check(text)
|
||||||
guard !suggestions.isEmpty else { continue }
|
guard !suggestions.isEmpty else { continue }
|
||||||
detected.append(DetectedField(
|
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))
|
text: f.text, suggestions: suggestions, element: f.element, isFocused: f.isFocused))
|
||||||
}
|
}
|
||||||
// Focused field first.
|
|
||||||
detected.sort { ($0.isFocused ? 0 : 1) < ($1.isFocused ? 0 : 1) }
|
detected.sort { ($0.isFocused ? 0 : 1) < ($1.isFocused ? 0 : 1) }
|
||||||
fields = detected
|
fields = detected
|
||||||
}
|
}
|
||||||
|
|
||||||
private func systemFocusedElement() -> AXUIElement? {
|
private nonisolated static func systemFocusedElement() -> AXUIElement? {
|
||||||
let system = AXUIElementCreateSystemWide()
|
AX.copyElement(AXUIElementCreateSystemWide(), kAXFocusedUIElementAttribute)
|
||||||
var ref: CFTypeRef?
|
|
||||||
guard AXUIElementCopyAttributeValue(
|
|
||||||
system, kAXFocusedUIElementAttribute as CFString, &ref) == .success, let ref
|
|
||||||
else { return nil }
|
|
||||||
return (ref as! AXUIElement)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func clear() {
|
private func clear() {
|
||||||
|
|||||||
@ -55,13 +55,7 @@ final class TextCapture {
|
|||||||
guard AXIsProcessTrusted() else { return nil }
|
guard AXIsProcessTrusted() else { return nil }
|
||||||
let systemWide = AXUIElementCreateSystemWide()
|
let systemWide = AXUIElementCreateSystemWide()
|
||||||
|
|
||||||
var focusedRef: CFTypeRef?
|
guard let focused = AX.copyElement(systemWide, kAXFocusedUIElementAttribute) else { return nil }
|
||||||
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
|
|
||||||
|
|
||||||
var valueRef: CFTypeRef?
|
var valueRef: CFTypeRef?
|
||||||
guard AXUIElementCopyAttributeValue(
|
guard AXUIElementCopyAttributeValue(
|
||||||
@ -100,21 +94,25 @@ final class TextCapture {
|
|||||||
func focusedFieldText() -> (text: String, element: AXUIElement)? {
|
func focusedFieldText() -> (text: String, element: AXUIElement)? {
|
||||||
guard AXIsProcessTrusted() else { return nil }
|
guard AXIsProcessTrusted() else { return nil }
|
||||||
let systemWide = AXUIElementCreateSystemWide()
|
let systemWide = AXUIElementCreateSystemWide()
|
||||||
var focusedRef: CFTypeRef?
|
guard let focused = AX.copyElement(systemWide, kAXFocusedUIElementAttribute) else { return nil }
|
||||||
guard AXUIElementCopyAttributeValue(
|
|
||||||
systemWide, kAXFocusedUIElementAttribute as CFString, &focusedRef) == .success,
|
|
||||||
let focusedRef
|
|
||||||
else { return nil }
|
|
||||||
let focused = focusedRef as! AXUIElement
|
|
||||||
|
|
||||||
var valueRef: CFTypeRef?
|
// Only treat genuine editable text roles as fixable, so we don't report
|
||||||
guard AXUIElementCopyAttributeValue(
|
// "nothing selected" failures on sliders/steppers/custom controls.
|
||||||
focused, kAXValueAttribute as CFString, &valueRef) == .success,
|
let role = AX.copyString(focused, kAXRoleAttribute)
|
||||||
let text = valueRef as? String
|
guard role == kAXTextFieldRole as String
|
||||||
|
|| role == kAXTextAreaRole as String
|
||||||
|
|| role == kAXComboBoxRole as String
|
||||||
else { return nil }
|
else { return nil }
|
||||||
|
|
||||||
|
guard let text = AX.copyString(focused, kAXValueAttribute) else { return nil }
|
||||||
return (text, focused)
|
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
|
@discardableResult
|
||||||
func setFocusedField(_ text: String, element: AXUIElement) -> Bool {
|
func setFocusedField(_ text: String, element: AXUIElement) -> Bool {
|
||||||
AXUIElementSetAttributeValue(element, kAXValueAttribute as CFString, text as CFTypeRef) == .success
|
AXUIElementSetAttributeValue(element, kAXValueAttribute as CFString, text as CFTypeRef) == .success
|
||||||
|
|||||||
@ -9,19 +9,26 @@ struct ScannedField {
|
|||||||
let label: String
|
let label: String
|
||||||
let text: String
|
let text: String
|
||||||
let isFocused: Bool
|
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
|
/// Walks the Accessibility tree of the frontmost window and collects every
|
||||||
/// editable text element (text fields, text areas, combo/search boxes) with
|
/// 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
|
/// 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 {
|
enum TextFieldScanner {
|
||||||
static func scan(
|
static func scan(
|
||||||
pid: pid_t, focused: AXUIElement?,
|
pid: pid_t, focused: AXUIElement?,
|
||||||
maxFields: Int = 30, maxVisited: Int = 4000
|
maxFields: Int = 30, maxVisited: Int = 3000
|
||||||
) -> [ScannedField] {
|
) -> [ScannedField] {
|
||||||
let axApp = AXUIElementCreateApplication(pid)
|
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 out: [ScannedField] = []
|
||||||
var visited = 0
|
var visited = 0
|
||||||
walk(root, focused: focused, depth: 0, out: &out, visited: &visited,
|
walk(root, focused: focused, depth: 0, out: &out, visited: &visited,
|
||||||
@ -29,17 +36,6 @@ enum TextFieldScanner {
|
|||||||
return out
|
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(
|
private static func walk(
|
||||||
_ el: AXUIElement, focused: AXUIElement?, depth: Int,
|
_ el: AXUIElement, focused: AXUIElement?, depth: Int,
|
||||||
out: inout [ScannedField], visited: inout Int, maxFields: Int, maxVisited: 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 }
|
if out.count >= maxFields || visited >= maxVisited || depth > 70 { return }
|
||||||
visited += 1
|
visited += 1
|
||||||
|
|
||||||
let role = string(el, kAXRoleAttribute) ?? ""
|
let role = AX.copyString(el, kAXRoleAttribute) ?? ""
|
||||||
if role == kAXTextFieldRole as String
|
if role == kAXTextFieldRole as String
|
||||||
|| role == kAXTextAreaRole as String
|
|| role == kAXTextAreaRole as String
|
||||||
|| role == kAXComboBoxRole 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 {
|
value.trimmingCharacters(in: .whitespacesAndNewlines).count >= 3 {
|
||||||
let label = string(el, kAXTitleAttribute)
|
let label = AX.copyString(el, kAXTitleAttribute)
|
||||||
?? string(el, kAXDescriptionAttribute)
|
?? AX.copyString(el, kAXDescriptionAttribute)
|
||||||
?? string(el, kAXPlaceholderValueAttribute)
|
?? AX.copyString(el, kAXPlaceholderValueAttribute)
|
||||||
?? roleLabel(role)
|
?? roleLabel(role)
|
||||||
let isFocused = focused != nil && CFEqual(el, focused!)
|
let isFocused = focused != nil && CFEqual(el, focused!)
|
||||||
out.append(ScannedField(
|
out.append(ScannedField(
|
||||||
element: el, roleName: roleLabel(role),
|
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? {
|
/// A SwiftUI-stable identity that survives re-scans and text edits: prefer the
|
||||||
var ref: CFTypeRef?
|
/// AX identifier, else fall back to role + label + on-screen position.
|
||||||
guard AXUIElementCopyAttributeValue(el, attr as CFString, &ref) == .success else { return nil }
|
private static func identity(of el: AXUIElement, role: String, label: String) -> String {
|
||||||
return ref as? 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 {
|
private static func roleLabel(_ role: String) -> String {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user