[eric] dictation: the Accessibility check waits on the Input Monitoring request by callback, not line order (rdar://7381305)

This commit is contained in:
ciregenz
2026-08-21 15:52:59 -07:00
parent 07566a77e5
commit 628f6e57a0
2 changed files with 40 additions and 17 deletions
+23 -10
View File
@@ -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();
+17 -7
View File
@@ -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');
});