[eric] dictation: a denied watcher stays listed in the Input Monitoring pane instead of vanishing (ENG-360)

This commit is contained in:
ciregenz
2026-08-21 14:45:26 -07:00
parent 1947238dec
commit 89f7621140
4 changed files with 25 additions and 14 deletions
+5 -4
View File
@@ -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
+3
View File
@@ -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');
}
}
});
+10 -6
View File
@@ -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', () => {
+7 -4
View File
@@ -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');