diff --git a/electron/native/fn-watcher.swift b/electron/native/fn-watcher.swift index 4b8e62df..b7ba2c2b 100644 --- a/electron/native/fn-watcher.swift +++ b/electron/native/fn-watcher.swift @@ -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() diff --git a/electron/voiceHotkey.js b/electron/voiceHotkey.js index 5def232f..ac149c62 100644 --- a/electron/voiceHotkey.js +++ b/electron/voiceHotkey.js @@ -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 diff --git a/electron/voiceHotkeyRearm.test.js b/electron/voiceHotkeyRearm.test.js new file mode 100644 index 00000000..68288baf --- /dev/null +++ b/electron/voiceHotkeyRearm.test.js @@ -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 (_) {} + } +});