From 89f762114029189be650eaabc22c96443bde11cd Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 21 Aug 2026 14:45:26 -0700 Subject: [PATCH] [eric] dictation: a denied watcher stays listed in the Input Monitoring pane instead of vanishing (ENG-360) --- electron/native/fn-watcher.swift | 9 +++++---- electron/voiceHotkey.js | 3 +++ electron/voiceHotkeyProbe.test.js | 16 ++++++++++------ electron/voiceHotkeyRearm.test.js | 11 +++++++---- 4 files changed, 25 insertions(+), 14 deletions(-) diff --git a/electron/native/fn-watcher.swift b/electron/native/fn-watcher.swift index e3a4e9c7..91312599 100644 --- a/electron/native/fn-watcher.swift +++ b/electron/native/fn-watcher.swift @@ -19,13 +19,14 @@ if !hidGranted && !isProbe { print(hidGranted ? "p granted" : "p denied") fflush(stdout) -// Without the grant the tap is DEAF, not absent: tapCreate happily returns a port that never -// delivers an event, which is exactly how a dead fn key passed for a live one. Refuse to run in -// that state so that "watcher alive" means "fn works" and nothing downstream has to guess. +// Denied is REPORTED and then we keep going on purpose. Exiting here looks tidier, but a running +// tap is the only thing that makes macOS list the app under Input Monitoring, so quitting removes +// the very switch the user is being sent to flip (caught the hard way: "it just takes me to +// Settings and nothing else"). The ambiguity that started all this is already gone, because the +// parent trusts the reported permission and never the fact that this process is alive. if !hidGranted { print("e no-permission") fflush(stdout) - exit(0) } var fnDown = false diff --git a/electron/voiceHotkey.js b/electron/voiceHotkey.js index b8c8ce00..01b6e47b 100644 --- a/electron/voiceHotkey.js +++ b/electron/voiceHotkey.js @@ -245,6 +245,9 @@ function installVoiceHotkey(getMainWindow) { if (combo.special === 'fn') send(line === 'd' ? 'voice:hold-down' : 'voice:hold-up'); } else if (line.startsWith('e')) { console.log('[voice] fn watcher error:', line); + // The watcher deliberately stays alive when denied (it keeps the app listed in the + // Input Monitoring pane), so liveness proves nothing and this line is the signal. + if (line.includes('no-permission')) notifyPrimaryUnusable('input-monitoring-denied'); } } }); diff --git a/electron/voiceHotkeyProbe.test.js b/electron/voiceHotkeyProbe.test.js index 3f60de0a..e2a47f02 100644 --- a/electron/voiceHotkeyProbe.test.js +++ b/electron/voiceHotkeyProbe.test.js @@ -52,13 +52,17 @@ test('the watcher reports its permission instead of leaving it to be inferred', 'permission must be stated on stdout, not guessed from the process still being alive'); }); -test('a watcher without the grant refuses to run, so alive means working', () => { - const guard = swiftSrc.split('if !hidGranted {')[1] || ''; +test('a denied watcher reports it and STAYS ALIVE, or the Settings pane has nothing to flip', () => { + // Exiting on denial looked tidy and silently removed the only mechanism that lists the app under + // Input Monitoring, so "Open Settings" led to a pane with no OpenSwarm row in it. + const guard = swiftSrc.split('if !hidGranted {')[1].split('}')[0]; assert.match(guard, /e no-permission/); - assert.match(guard, /exit\(0\)/); - const guardAt = swiftSrc.indexOf('if !hidGranted {'); - assert.ok(guardAt > -1 && guardAt < swiftSrc.indexOf('guard armTap()'), - 'the refusal must come before the tap, or a deaf tap still gets created'); + assert.ok(!/exit\(/.test(guard), 'must not exit: a live tap is what keeps the app listed in the pane'); +}); + +test('denial is carried by the reported line, never by liveness', () => { + assert.match(hotkeySrc, /line\.includes\('no-permission'\)\) notifyPrimaryUnusable\('input-monitoring-denied'\)/, + 'with the process staying alive, the stdout report is the only honest signal'); }); test('the grant is REQUESTED on the intent path and never on the boot probe', () => { diff --git a/electron/voiceHotkeyRearm.test.js b/electron/voiceHotkeyRearm.test.js index 68288baf..a3d438db 100644 --- a/electron/voiceHotkeyRearm.test.js +++ b/electron/voiceHotkeyRearm.test.js @@ -37,11 +37,14 @@ test('live protocol: armed watcher survives pokes and exits on EOF', { skip: pro 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); + const ready = () => out.includes('t ok') || out.includes('p denied'); + for (let waited = 0; !ready() && 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; + // The re-arm protocol can only be exercised with the grant. This used to be inferred from the + // process still being alive, which is precisely the ambiguity the watcher now reports its way + // out of: ask it, do not guess. (A Gatekeeper-wedged machine hanging a fresh binary at + // _dyld_start still lands in the same skip, since it never reports at all.) + if (exitCode !== null || out.includes('p denied') || !out.includes('t ok')) return; p.stdin.write('r\n'); p.stdin.write('r\n'); await sleep(500); assert.equal(exitCode, null, 'pokes must not kill the watcher');