Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion scripts/build-native.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
95 changes: 95 additions & 0 deletions scripts/test-emoji-caret-session.mjs
Original file line number Diff line number Diff line change
@@ -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 });
}
1 change: 1 addition & 0 deletions src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
]);
Expand Down
60 changes: 53 additions & 7 deletions src/native/ax-caret-query.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<pid_t> = []
Expand All @@ -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)
Expand All @@ -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) ?? ""
Expand All @@ -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
Expand Down
35 changes: 35 additions & 0 deletions src/native/emoji-caret-session-cache.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading