diff --git a/electron/voiceHotkey.js b/electron/voiceHotkey.js index d51fa872..7c4720c9 100644 --- a/electron/voiceHotkey.js +++ b/electron/voiceHotkey.js @@ -168,16 +168,21 @@ function installVoiceHotkey(getMainWindow) { // ---- fn/Globe primary tier (macOS): the native watcher, since no JS tap can see keycode 63 ---- let fnProc = null; let quitReaperWired = false; - const startFnWatcher = (noPrompt) => { - if (process.platform !== 'darwin' || combo.special !== 'fn' || fnProc) return; + // `asked` runs once the Input Monitoring request is actually in flight (or provably never will + // be). Anything that touches Accessibility must wait for it; see rdar://7381305. + const startFnWatcher = (noPrompt, asked) => { + const done = () => { if (typeof asked === 'function') asked(); }; + if (process.platform !== 'darwin' || combo.special !== 'fn' || fnProc) { done(); return; } resolveFnWatcherBinary((bin) => { if (!bin) { console.log('[voice] no fn watcher binary, legacy hotkey stays primary'); notifyPrimaryUnusable('no-watcher-binary'); + done(); return; } - if (combo.special !== 'fn' || fnProc) return; // rebound or raced while compiling + if (combo.special !== 'fn' || fnProc) { done(); return; } startFnWatcherWith(bin, noPrompt === true); + done(); }); }; // Kill fn-watchers left by a previous OpenSwarm that died badly. will-quit is the ONLY thing that @@ -361,13 +366,21 @@ function installVoiceHotkey(getMainWindow) { // been called, IOHIDRequestAccess stops raising the Input Monitoring dialog entirely. Electron's // isTrustedAccessibilityClient IS that call, and tryStartNativeTap makes it, so asking for the // tap first is what silently ate the fn prompt. Ask for Input Monitoring FIRST, always. - startFnWatcher(); - const tapOk = tryStartNativeTap(); - // Ctrl+Win is the Windows fn-equivalent and ONLY this tap can see it, so a tap that never loads - // is the same dead key as a deaf fn watcher. Off macOS there is no TCC to blame, which is - // exactly why it would otherwise fail with nothing said at all. - if (tapOk === false && combo.special === 'ctrlmeta') notifyPrimaryUnusable('native-tap-unavailable'); - registerVoiceShortcut(); + // It has to be a CALLBACK: the dev path compiles the watcher first, so a plain statement order + // still let the Accessibility check win on wall-clock and the prompt stayed dead. + const armTapTier = () => { + const tapOk = tryStartNativeTap(); + // Ctrl+Win is the Windows fn-equivalent and ONLY this tap can see it, so a tap that never + // loads is the same dead key as a deaf fn watcher. Off macOS there is no TCC to blame, which + // is exactly why it would otherwise fail with nothing said at all. + if (tapOk === false && combo.special === 'ctrlmeta') notifyPrimaryUnusable('native-tap-unavailable'); + registerVoiceShortcut(); + }; + let tapArmed = false; + const armTapOnce = () => { if (!tapArmed) { tapArmed = true; armTapTier(); } }; + startFnWatcher(false, armTapOnce); + // A wedged swiftc must never cost the user their chord tier. + setTimeout(armTapOnce, 8000); }; try { if (fs.existsSync(path.join(app.getPath('userData'), 'dictation-used'))) armNativeTiers(); diff --git a/electron/voiceHotkeyProbe.test.js b/electron/voiceHotkeyProbe.test.js index 10762279..879316a8 100644 --- a/electron/voiceHotkeyProbe.test.js +++ b/electron/voiceHotkeyProbe.test.js @@ -127,11 +127,21 @@ test('the intent path spawns WITHOUT --no-prompt, or nothing is ever asked', () // Electron's isTrustedAccessibilityClient IS that call, so arming the uiohook tap before the fn // watcher silently ate the one prompt that can fix a dead fn key. Order is the whole fix. -test('Input Monitoring is requested BEFORE any Accessibility check', () => { - const arm = hotkeySrc.split('const armNativeTiers = () => {')[1].split('};')[0]; - const fnAt = arm.indexOf('startFnWatcher()'); - const axAt = arm.indexOf('tryStartNativeTap()'); - assert.ok(fnAt > -1 && axAt > -1, 'both tiers must still be armed'); - assert.ok(fnAt < axAt, - 'rdar://7381305: an AX check first kills the Input Monitoring dialog, so fn must ask first'); +test('the Accessibility check waits for the Input Monitoring request, by callback not by line order', () => { + // Statement order is NOT enough: the dev path compiles the watcher asynchronously, so a plain + // "fn first" still let the AX check win on wall-clock and the prompt stayed dead (seen live). + const arm = hotkeySrc.split('const armNativeTiers = () => {')[1].split('\n };')[0]; + assert.match(arm, /startFnWatcher\(false, armTapOnce\)/, + 'the tap tier must be handed to the watcher as a completion callback'); + const tier = arm.split('const armTapTier = () => {')[1].split(' };')[0]; + assert.match(tier, /tryStartNativeTap\(\)/, + 'the AX-touching call belongs inside the deferred tier, never inline ahead of the request'); + assert.match(arm, /setTimeout\(armTapOnce, \d+\)/, 'a wedged compile must not cost the chord tier'); +}); + +test('the watcher signals when the request is in flight', () => { + assert.match(hotkeySrc, /const startFnWatcher = \(noPrompt, asked\) =>/); + const body = hotkeySrc.split('const startFnWatcher = (noPrompt, asked) =>')[1].slice(0, 900); + assert.ok((body.match(/done\(\)/g) || []).length >= 4, + 'every exit path must signal, or a failure strands the Accessibility tier forever'); });