From db4a5a55e797a38625c94bf41b1897e34e3b1348 Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:20:11 +0200 Subject: [PATCH] Cache emoji caret session state --- scripts/build-native.mjs | 2 +- scripts/test-emoji-caret-session.mjs | 95 ++++++++++++++++ src/main/main.ts | 1 + src/native/ax-caret-query.swift | 60 ++++++++-- src/native/emoji-caret-session-cache.swift | 35 ++++++ src/native/emoji-trigger-monitor.swift | 121 ++++++++++++--------- 6 files changed, 255 insertions(+), 59 deletions(-) create mode 100644 scripts/test-emoji-caret-session.mjs create mode 100644 src/native/emoji-caret-session-cache.swift diff --git a/scripts/build-native.mjs b/scripts/build-native.mjs index 35f3172f..e72912de 100644 --- a/scripts/build-native.mjs +++ b/scripts/build-native.mjs @@ -28,7 +28,7 @@ const swift = [ ['dist/native/menu-item-search', 'src/native/menu-item-search.swift', '-framework AppKit -framework ApplicationServices'], ['dist/native/emoji-trigger-monitor', - 'src/native/emoji-trigger-monitor.swift src/native/ax-caret-query.swift', + 'src/native/emoji-trigger-monitor.swift src/native/ax-caret-query.swift src/native/emoji-caret-session-cache.swift', '-framework AppKit -framework ApplicationServices'], ['dist/native/hotkey-hold-monitor', 'src/native/hotkey-hold-monitor.swift', '-framework CoreGraphics -framework AppKit -framework Carbon'], diff --git a/scripts/test-emoji-caret-session.mjs b/scripts/test-emoji-caret-session.mjs new file mode 100644 index 00000000..a531fbeb --- /dev/null +++ b/scripts/test-emoji-caret-session.mjs @@ -0,0 +1,95 @@ +#!/usr/bin/env node + +import assert from 'assert/strict'; +import { execFileSync } from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +function canRunSwiftTests() { + if (process.platform !== 'darwin') return false; + try { + execFileSync('swiftc', ['--version'], { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +if (!canRunSwiftTests()) { + console.log('[emoji-caret-session] skipped: Swift compiler is not available on this platform'); + process.exit(0); +} + +const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'supercmd-emoji-caret-session-')); +const testSource = path.join(tempDir, 'main.swift'); +const testBinary = path.join(tempDir, 'emoji-caret-session-tests'); + +fs.writeFileSync(testSource, ` +import Foundation +@preconcurrency import ApplicationServices + +func makeSnapshot(pid: pid_t, bundleId: String = "com.example.Editor") -> AXCaretSessionSnapshot { + AXCaretSessionSnapshot( + context: AXCaretApplicationContext(pid: pid, bundleIdentifier: bundleId), + focusedElement: nil, + caret: AXCaretRect(x: 20, y: 40, w: 1, h: 18, tier: "unit") + ) +} + +func expect(_ condition: @autoclosure () -> Bool, _ message: String) { + if !condition() { + fputs("failed: " + message + "\\n", stderr) + exit(1) + } +} + +var cache = EmojiCaretSessionCache() +expect(!cache.isActive, "new cache starts empty") + +cache.store(makeSnapshot(pid: 42)) +expect(cache.isActive, "store activates the cache") +switch cache.validate(eventTargetPID: 42) { +case .valid(let snapshot): + expect(snapshot.context.pid == 42, "matching event PID reuses the snapshot") +default: + expect(false, "matching event PID reuses the snapshot") +} + +switch cache.validate(eventTargetPID: nil) { +case .valid(let snapshot): + expect(snapshot.context.pid == 42, "missing event PID does not force an AX requery") +default: + expect(false, "missing event PID does not force an AX requery") +} + +switch cache.validate(eventTargetPID: 43) { +case .invalidated: + break +default: + expect(false, "PID changes invalidate the cache") +} +expect(!cache.isActive, "PID invalidation clears the cache") + +cache.store(makeSnapshot(pid: 99)) +cache.invalidate() +expect(!cache.isActive, "failed rect/dismiss invalidation clears the cache") + +print("emoji caret session cache tests passed") +`); + +try { + execFileSync('swiftc', [ + '-o', testBinary, + 'src/native/ax-caret-query.swift', + 'src/native/emoji-caret-session-cache.swift', + testSource, + '-framework', 'AppKit', + '-framework', 'ApplicationServices', + ], { stdio: 'inherit' }); + const output = execFileSync(testBinary, { encoding: 'utf8' }).trim(); + assert.equal(output, 'emoji caret session cache tests passed'); + console.log(`[emoji-caret-session] ${output}`); +} finally { + fs.rmSync(tempDir, { recursive: true, force: true }); +} diff --git a/src/main/main.ts b/src/main/main.ts index cf510437..2bea364c 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -10244,6 +10244,7 @@ function startEmojiTriggerMonitor(triggerPrefix = ':'): void { '-O', '-o', binaryPath, path.join(nativeDir, 'emoji-trigger-monitor.swift'), path.join(nativeDir, 'ax-caret-query.swift'), + path.join(nativeDir, 'emoji-caret-session-cache.swift'), '-framework', 'AppKit', '-framework', 'ApplicationServices', ]); diff --git a/src/native/ax-caret-query.swift b/src/native/ax-caret-query.swift index 2bfdabbf..82b4c0bd 100644 --- a/src/native/ax-caret-query.swift +++ b/src/native/ax-caret-query.swift @@ -34,6 +34,17 @@ public struct AXCaretRect { public let tier: String } +public struct AXCaretApplicationContext { + public let pid: pid_t + public let bundleIdentifier: String +} + +public struct AXCaretSessionSnapshot { + public let context: AXCaretApplicationContext + public let focusedElement: AXUIElement? + public let caret: AXCaretRect +} + /// Three-way result so callers can distinguish the security-sensitive case /// from an ordinary AX gap. A plain `AXCaretRect?` cannot express this: /// both "secure field" and "no rect available" would be nil, and the caller @@ -50,6 +61,12 @@ public enum AXCaretResult { case noRect } +public enum AXCaretSessionResult { + case secureField + case snapshot(AXCaretSessionSnapshot) + case noRect(AXCaretApplicationContext?) +} + public enum AXCaretQuery { // PIDs we've already nudged. Avoid re-setting AX opt-in attributes every keystroke. nonisolated(unsafe) private static var nudgedPIDs: Set = [] @@ -66,11 +83,28 @@ public enum AXCaretQuery { /// Query the caret state for the frontmost app's focused text element. public static func current() -> AXCaretResult { + switch currentSession() { + case .secureField: + return .secureField + case .snapshot(let snapshot): + return .rect(snapshot.caret) + case .noRect: + return .noRect + } + } + + /// Query the caret state and return the process/focused element needed to + /// safely cache an active emoji search session. + public static func currentSession() -> AXCaretSessionResult { guard let frontApp = NSWorkspace.shared.frontmostApplication else { dbg("no frontmost app") - return .noRect + return .noRect(nil) } let pid = frontApp.processIdentifier + let context = AXCaretApplicationContext( + pid: pid, + bundleIdentifier: frontApp.bundleIdentifier ?? "" + ) let appElement = AXUIElementCreateApplication(pid) let justNudged = nudgeChromiumAX(appElement: appElement, pid: pid) @@ -93,9 +127,9 @@ public enum AXCaretQuery { } guard focusErr == .success, let focused = focusedRaw else { dbg("kAXFocusedUIElementAttribute failed: err=\(focusErr.rawValue)") - return .noRect + return .noRect(context) } - guard CFGetTypeID(focused) == AXUIElementGetTypeID() else { return .noRect } + guard CFGetTypeID(focused) == AXUIElementGetTypeID() else { return .noRect(context) } var element = focused as! AXUIElement let role = copyString(element, kAXRoleAttribute as CFString) ?? "" @@ -121,15 +155,27 @@ public enum AXCaretQuery { } if let r = caretRectViaTextMarker(element: element), isPlausible(r) { - return .rect(AXCaretRect(x: r.x, y: r.y, w: r.w, h: r.h, tier: "textMarker")) + return .snapshot(AXCaretSessionSnapshot( + context: context, + focusedElement: element, + caret: AXCaretRect(x: r.x, y: r.y, w: r.w, h: r.h, tier: "textMarker") + )) } if let r = caretRectViaRange(element: element), isPlausible(r) { - return .rect(AXCaretRect(x: r.x, y: r.y, w: r.w, h: r.h, tier: "boundsForRange")) + return .snapshot(AXCaretSessionSnapshot( + context: context, + focusedElement: element, + caret: AXCaretRect(x: r.x, y: r.y, w: r.w, h: r.h, tier: "boundsForRange") + )) } if let r = caretRectViaElementFrame(element: element) { - return .rect(AXCaretRect(x: r.x, y: r.y, w: r.w, h: r.h, tier: "elementFrame")) + return .snapshot(AXCaretSessionSnapshot( + context: context, + focusedElement: element, + caret: AXCaretRect(x: r.x, y: r.y, w: r.w, h: r.h, tier: "elementFrame") + )) } - return .noRect + return .noRect(context) } // MARK: - Descend focus tree to find the true text leaf diff --git a/src/native/emoji-caret-session-cache.swift b/src/native/emoji-caret-session-cache.swift new file mode 100644 index 00000000..5612ef33 --- /dev/null +++ b/src/native/emoji-caret-session-cache.swift @@ -0,0 +1,35 @@ +import Foundation +@preconcurrency import ApplicationServices + +public enum EmojiCaretSessionValidation { + case empty + case valid(AXCaretSessionSnapshot) + case invalidated +} + +public struct EmojiCaretSessionCache { + private var snapshot: AXCaretSessionSnapshot? + + public init() {} + + public var isActive: Bool { + snapshot != nil + } + + public mutating func store(_ nextSnapshot: AXCaretSessionSnapshot) { + snapshot = nextSnapshot + } + + public mutating func invalidate() { + snapshot = nil + } + + public mutating func validate(eventTargetPID: pid_t?) -> EmojiCaretSessionValidation { + guard let current = snapshot else { return .empty } + if let targetPID = eventTargetPID, targetPID > 0, targetPID != current.context.pid { + snapshot = nil + return .invalidated + } + return .valid(current) + } +} diff --git a/src/native/emoji-trigger-monitor.swift b/src/native/emoji-trigger-monitor.swift index e2f27dd7..a2e47e84 100644 --- a/src/native/emoji-trigger-monitor.swift +++ b/src/native/emoji-trigger-monitor.swift @@ -21,6 +21,7 @@ var currentQuery = "" var interceptEnabled = false var prefixBuffer = "" // rolling window of recent chars, length ≤ triggerPrefixLen var eventTapRef: CFMachPort? +var caretSessionCache = EmojiCaretSessionCache() // MARK: - JSON output @@ -33,38 +34,68 @@ func emit(_ obj: [String: Any]) { // MARK: - Caret rect + secure-field guard -// Included on every `query` event so the host process can decide whether the -// frontmost app is on the user's exclusion list. Read here (rather than in the -// host) because lsappinfo lookups in the host are stale for keystroke-driven -// flows where the launcher window was never brought forward. -func currentFrontmostBundleId() -> String { - return NSWorkspace.shared.frontmostApplication?.bundleIdentifier ?? "" +func resetTriggerState() { + triggerActive = false + currentQuery = "" + interceptEnabled = false + prefixBuffer = "" + caretSessionCache.invalidate() } -func emitQuery(_ query: String) { - let bundleId = currentFrontmostBundleId() - switch AXCaretQuery.current() { +func dismissTrigger(emitDismiss: Bool = true) { + resetTriggerState() + if emitDismiss { emit(["type": "dismiss"]) } +} + +func eventTargetPID(from event: CGEvent) -> pid_t? { + let rawPID = event.getIntegerValueField(.eventTargetUnixProcessID) + return rawPID > 0 ? pid_t(rawPID) : nil +} + +func sessionValidationPID(from event: CGEvent) -> pid_t? { + if let targetPID = eventTargetPID(from: event) { return targetPID } + guard caretSessionCache.isActive else { return nil } + return NSWorkspace.shared.frontmostApplication?.processIdentifier +} + +func emitQueryPayload(_ query: String, bundleId: String, caret: AXCaretRect?) { + var payload: [String: Any] = [ + "type": "query", + "value": query, + "prefixLen": triggerPrefixLen, + "bundleId": bundleId, + ] + if let caret { + payload["caret"] = ["x": caret.x, "y": caret.y, "w": caret.w, "h": caret.h, "tier": caret.tier] + } + emit(payload) +} + +func emitQuery(_ query: String, eventTargetPID: pid_t?) { + switch caretSessionCache.validate(eventTargetPID: eventTargetPID) { + case .valid(let snapshot): + emitQueryPayload(query, bundleId: snapshot.context.bundleIdentifier, caret: snapshot.caret) + return + case .invalidated: + dismissTrigger() + return + case .empty: + break + } + + switch AXCaretQuery.currentSession() { case .secureField: - triggerActive = false - currentQuery = "" - interceptEnabled = false - prefixBuffer = "" - emit(["type": "dismiss"]) - case .rect(let caret): - emit([ - "type": "query", - "value": query, - "prefixLen": triggerPrefixLen, - "bundleId": bundleId, - "caret": ["x": caret.x, "y": caret.y, "w": caret.w, "h": caret.h, "tier": caret.tier], - ]) - case .noRect: - emit([ - "type": "query", - "value": query, - "prefixLen": triggerPrefixLen, - "bundleId": bundleId, - ]) + dismissTrigger() + case .snapshot(let snapshot): + if let targetPID = eventTargetPID, targetPID > 0, targetPID != snapshot.context.pid { + dismissTrigger() + return + } + caretSessionCache.store(snapshot) + emitQueryPayload(query, bundleId: snapshot.context.bundleIdentifier, caret: snapshot.caret) + case .noRect(let context): + caretSessionCache.invalidate() + emitQueryPayload(query, bundleId: context?.bundleIdentifier ?? "", caret: nil) } } @@ -131,9 +162,7 @@ func run() { // a partial prefix that could accidentally fire on the next keystroke. if hasCmd || hasCtrl { if triggerActive { - triggerActive = false - currentQuery = "" - emit(["type": "dismiss"]) + dismissTrigger() } prefixBuffer = "" // clear regardless of triggerActive (fix A) return Unmanaged.passUnretained(event) @@ -146,10 +175,7 @@ func run() { // otherwise silently extend the query with extended chars (ü, ©, etc.) // on non-US layouts, which is unintuitive (fix B). if hasAlt && triggerActive { - triggerActive = false - currentQuery = "" - prefixBuffer = "" - emit(["type": "dismiss"]) + dismissTrigger() return Unmanaged.passUnretained(event) } @@ -159,7 +185,7 @@ func run() { switch keyCode { case 36: emit(["type": "nav", "key": "enter"]); return nil case 53: - triggerActive = false; currentQuery = ""; prefixBuffer = "" + resetTriggerState() emit(["type": "nav", "key": "escape"]) return nil case 48: emit(["type": "nav", "key": "tab"]); return nil @@ -175,13 +201,10 @@ func run() { if triggerActive { if currentQuery.isEmpty { // Backspaced through the entire query back into the prefix — dismiss. - triggerActive = false - interceptEnabled = false - prefixBuffer = "" - emit(["type": "dismiss"]) + dismissTrigger() } else { currentQuery.removeLast() - emitQuery(currentQuery) + emitQuery(currentQuery, eventTargetPID: sessionValidationPID(from: event)) } } else { // Update prefix buffer for non-trigger backspace. @@ -194,8 +217,7 @@ func run() { let chars = extractTypedChars(from: event) if chars.isEmpty { if triggerActive { - triggerActive = false; currentQuery = ""; prefixBuffer = "" - emit(["type": "dismiss"]) + dismissTrigger() } return Unmanaged.passUnretained(event) } @@ -206,15 +228,13 @@ func run() { if isEmojiQueryChar(char) { currentQuery.append(char) if currentQuery.count > 30 { - triggerActive = false; currentQuery = ""; prefixBuffer = "" - emit(["type": "dismiss"]) + dismissTrigger() } else { - emitQuery(currentQuery) + emitQuery(currentQuery, eventTargetPID: sessionValidationPID(from: event)) } } else { // Non-query char (space, punctuation, …) → dismiss trigger. - triggerActive = false; currentQuery = ""; prefixBuffer = "" - emit(["type": "dismiss"]) + dismissTrigger() // Feed this char into the prefix buffer in case it starts the // next trigger (e.g. when trigger prefix contains this char). feedPrefixBuffer(char) @@ -268,8 +288,7 @@ func run() { interceptEnabled = json["enabled"] as? Bool ?? false case "dismiss": if triggerActive { - triggerActive = false; currentQuery = ""; interceptEnabled = false; prefixBuffer = "" - emit(["type": "dismiss"]) + dismissTrigger() } default: break }