mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[eric] voice: focus re-arms the fn tap so apps like Wispr that register later cannot silently eat the dictation key (ENG-317)
This commit is contained in:
@@ -5,6 +5,8 @@ import CoreGraphics
|
||||
import Foundation
|
||||
|
||||
var fnDown = false
|
||||
var tapRef: CFMachPort?
|
||||
var srcRef: CFRunLoopSource?
|
||||
|
||||
let callback: CGEventTapCallBack = { _, type, event, _ in
|
||||
if type == .flagsChanged {
|
||||
@@ -24,20 +26,50 @@ let callback: CGEventTapCallBack = { _, type, event, _ in
|
||||
return Unmanaged.passUnretained(event)
|
||||
}
|
||||
|
||||
var tapRef: CFMachPort?
|
||||
let mask = (CGEventMask(1) << CGEventType.flagsChanged.rawValue)
|
||||
guard let tap = CGEvent.tapCreate(
|
||||
tap: .cgSessionEventTap, place: .headInsertEventTap, options: .listenOnly,
|
||||
eventsOfInterest: mask, callback: callback, userInfo: nil
|
||||
) else {
|
||||
|
||||
// New tap first, old tap down after: a failed re-arm keeps the working tap instead of going deaf.
|
||||
// The fnDown de-dupe above makes the brief two-tap overlap print nothing twice.
|
||||
func armTap() -> Bool {
|
||||
guard let tap = CGEvent.tapCreate(
|
||||
tap: .cgSessionEventTap, place: .headInsertEventTap, options: .listenOnly,
|
||||
eventsOfInterest: mask, callback: callback, userInfo: nil
|
||||
) else { return false }
|
||||
let src = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0)
|
||||
CFRunLoopAddSource(CFRunLoopGetMain(), src, .commonModes)
|
||||
CGEvent.tapEnable(tap: tap, enable: true)
|
||||
if let old = tapRef { CGEvent.tapEnable(tap: old, enable: false); CFMachPortInvalidate(old) }
|
||||
if let oldSrc = srcRef { CFRunLoopRemoveSource(CFRunLoopGetMain(), oldSrc, .commonModes) }
|
||||
tapRef = tap
|
||||
srcRef = src
|
||||
return true
|
||||
}
|
||||
|
||||
guard armTap() else {
|
||||
print("e tap-failed")
|
||||
fflush(stdout)
|
||||
exit(1)
|
||||
}
|
||||
tapRef = tap
|
||||
let src = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0)
|
||||
CFRunLoopAddSource(CFRunLoopGetCurrent(), src, .commonModes)
|
||||
CGEvent.tapEnable(tap: tap, enable: true)
|
||||
|
||||
// The parent pokes "r\n" when the app gains focus: another app's tap registered after ours sits
|
||||
// AHEAD of ours (head-insert) and can eat fn before we see it, with no disable event to catch, so
|
||||
// re-arming is the only way to win the key back (ENG-317). stdin EOF means the parent is gone;
|
||||
// exiting then stops a crashed OpenSwarm from stranding a process holding a global keyboard tap.
|
||||
DispatchQueue.global().async {
|
||||
while let line = readLine(strippingNewline: true) {
|
||||
if line == "r" {
|
||||
CFRunLoopPerformBlock(CFRunLoopGetMain(), CFRunLoopMode.commonModes.rawValue) {
|
||||
if !armTap() {
|
||||
print("e rearm-failed")
|
||||
fflush(stdout)
|
||||
}
|
||||
}
|
||||
CFRunLoopWakeUp(CFRunLoopGetMain())
|
||||
}
|
||||
}
|
||||
exit(0)
|
||||
}
|
||||
|
||||
print("r")
|
||||
fflush(stdout)
|
||||
CFRunLoopRun()
|
||||
|
||||
+13
-2
@@ -155,10 +155,21 @@ function installVoiceHotkey(getMainWindow) {
|
||||
}
|
||||
} catch (_) { /* a machine where ps is restricted must still arm the watcher */ }
|
||||
};
|
||||
// Focus re-arms the watcher's tap: a tap another app registered after ours head-inserts AHEAD of
|
||||
// ours and can eat fn with no disable event delivered, so this is the only recovery (ENG-317).
|
||||
let lastFnPokeMs = 0;
|
||||
const pokeFnWatcher = () => {
|
||||
if (!fnProc || !fnProc.stdin || !fnProc.stdin.writable) return;
|
||||
const now = Date.now();
|
||||
if (now - lastFnPokeMs < 1000) return;
|
||||
lastFnPokeMs = now;
|
||||
try { fnProc.stdin.write('r\n'); } catch (_) {}
|
||||
};
|
||||
const startFnWatcherWith = (bin) => {
|
||||
sweepStrayFnWatchers(bin);
|
||||
try {
|
||||
fnProc = spawn(bin, [], { stdio: ['ignore', 'pipe', 'ignore'] });
|
||||
// stdin stays open on purpose: "r\n" re-arms the tap, and EOF tells an orphaned watcher to die.
|
||||
fnProc = spawn(bin, [], { stdio: ['pipe', 'pipe', 'ignore'] });
|
||||
} catch (e) {
|
||||
console.log('[voice] fn watcher spawn failed:', e && e.message);
|
||||
fnProc = null;
|
||||
@@ -279,7 +290,7 @@ function installVoiceHotkey(getMainWindow) {
|
||||
tryStartNativeTap();
|
||||
startFnWatcher();
|
||||
registerVoiceShortcut();
|
||||
app.on('browser-window-focus', unregisterFallbackShortcut);
|
||||
app.on('browser-window-focus', () => { unregisterFallbackShortcut(); pokeFnWatcher(); });
|
||||
app.on('browser-window-blur', registerVoiceShortcut);
|
||||
|
||||
// The focused-window relay matches the FALLBACK chord: special primaries (fn, Ctrl+Win) are
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// Run: node --test electron/voiceHotkeyRearm.test.js
|
||||
//
|
||||
// ENG-317: an event tap another app registers AFTER ours head-inserts AHEAD of ours and can eat fn
|
||||
// with no disable event delivered, so the watcher's existing timeout re-enable never fires and fn
|
||||
// goes silently dead (reproduced with a real adversary tap: watcher got ZERO bytes). The fix is a
|
||||
// focus-time "r\n" poke that re-arms the tap, head-inserting us back in front (proven live: starved
|
||||
// under the adversary, one poke, fn flowed again). These pin the wire and the protocol.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { spawnSync, spawn } = require('node:child_process');
|
||||
|
||||
const swiftSrc = fs.readFileSync(path.join(__dirname, 'native', 'fn-watcher.swift'), 'utf8');
|
||||
const hotkeySrc = fs.readFileSync(path.join(__dirname, 'voiceHotkey.js'), 'utf8');
|
||||
|
||||
test('the watcher understands the re-arm poke and dies on parent loss', () => {
|
||||
assert.match(swiftSrc, /if line == "r"/, 'the stdin re-arm command is the whole ENG-317 fix');
|
||||
assert.match(swiftSrc, /while let line = readLine/, 'stdin must be read line-wise');
|
||||
assert.match(swiftSrc, /exit\(0\)/, 'stdin EOF must exit, or a crashed parent strands a global tap');
|
||||
const armBody = swiftSrc.slice(swiftSrc.indexOf('func armTap'), swiftSrc.indexOf('guard armTap'));
|
||||
assert.ok(armBody.indexOf('tapCreate') < armBody.indexOf('CFMachPortInvalidate'), 'new tap up BEFORE old tap down, or a failed re-arm goes deaf');
|
||||
});
|
||||
|
||||
test('electron pokes on focus through a piped stdin', () => {
|
||||
assert.match(hotkeySrc, /stdio: \['pipe', 'pipe', 'ignore'\]/, 'an ignored stdin is /dev/null, whose instant EOF would kill the watcher at birth');
|
||||
assert.match(hotkeySrc, /fnProc\.stdin\.write\('r\\n'\)/, 'the poke must reach the watcher');
|
||||
const focusLine = hotkeySrc.split('\n').find((l) => l.includes("app.on('browser-window-focus'"));
|
||||
assert.ok(focusLine && focusLine.includes('pokeFnWatcher'), 'focus is the moment we can win the tap back');
|
||||
});
|
||||
|
||||
test('live protocol: armed watcher survives pokes and exits on EOF', { skip: process.platform !== 'darwin' }, async () => {
|
||||
const bin = path.join(require('node:os').tmpdir(), `fn-watcher-rearm-test-${process.pid}`);
|
||||
const cc = spawnSync('swiftc', ['-O', '-o', bin, path.join(__dirname, 'native', 'fn-watcher.swift')], { timeout: 120000 });
|
||||
if (cc.error || cc.status !== 0) return; // no toolchain on this runner; source checks above still hold
|
||||
const p = spawn(bin, [], { stdio: ['pipe', 'pipe', 'ignore'] });
|
||||
let out = ''; p.stdout.on('data', (c) => { out += String(c); });
|
||||
let exitCode = null; p.on('exit', (c) => { exitCode = c; });
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
for (let waited = 0; !out.includes('r') && exitCode === null && waited < 4000; waited += 100) await sleep(100);
|
||||
try {
|
||||
// No boot marker = no grant, or a Gatekeeper-wedged machine hanging fresh binaries at
|
||||
// _dyld_start (both seen live); asserting against a process that never ran proves nothing.
|
||||
if (exitCode !== null || !out.includes('r')) return;
|
||||
p.stdin.write('r\n'); p.stdin.write('r\n');
|
||||
await sleep(500);
|
||||
assert.equal(exitCode, null, 'pokes must not kill the watcher');
|
||||
assert.ok(!out.split('\n').some((l) => l.startsWith('e')), `re-arm errored: ${out}`);
|
||||
p.stdin.end();
|
||||
for (let waited = 0; exitCode === null && waited < 5000; waited += 100) await sleep(100);
|
||||
assert.equal(exitCode, 0, 'EOF must exit cleanly; a survivor here is the orphan-tap leak');
|
||||
} finally {
|
||||
try { p.kill('SIGKILL'); } catch (_) {}
|
||||
try { fs.unlinkSync(bin); } catch (_) {}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user