From 1947238dec79fceadf614b6f3c0cd75dd9a518e7 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 21 Aug 2026 14:30:31 -0700 Subject: [PATCH] [eric] dictation: using dictation asks for the fn grant every time, not once per launch (ENG-360) --- electron/voiceHotkey.js | 16 ++++++++++++++++ electron/voiceHotkeyProbe.test.js | 25 +++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/electron/voiceHotkey.js b/electron/voiceHotkey.js index 8bc93d06..b8c8ce00 100644 --- a/electron/voiceHotkey.js +++ b/electron/voiceHotkey.js @@ -482,15 +482,31 @@ function installVoiceHotkey(getMainWindow) { // but a running tap makes macOS list the app in that pane for the user to flip. ipcMain.handle('voice:request-hold-permission', () => { armNativeTiers(); + askForFnPermission(); if (process.platform === 'darwin' && !tapProven) { try { systemPreferences.isTrustedAccessibilityClient(true); } catch (_) {} } return tapProven; }); + // Deliberately re-asked on every dictation attempt, not once per launch: armNativeTiers is + // one-shot, so a user who was denied (or dismissed the prompt) could never be asked again, which + // is how a permission you are willing to grant turns into a key that just never works. + let lastFnAskMs = 0; + const askForFnPermission = () => { + if (process.platform !== 'darwin' || combo.special !== 'fn' || fnProc) return; + const now = Date.now(); + if (now - lastFnAskMs < 10_000) return; + lastFnAskMs = now; + unusableNotified = false; + startFnWatcher(); + }; + // Fires the real TCC mic prompt BEFORE the first capture: with the entitlement present but no // prior grant, getUserMedia would still fail once and burn the user's first dictation attempt. ipcMain.handle('voice:request-mic-access', async () => { armNativeTiers(); + // Using dictation IS the intent that earns the fn prompt, every time, not just the first. + askForFnPermission(); if (process.platform !== 'darwin') return true; try { if (systemPreferences.getMediaAccessStatus('microphone') === 'granted') return true; diff --git a/electron/voiceHotkeyProbe.test.js b/electron/voiceHotkeyProbe.test.js index c771deb1..3f60de0a 100644 --- a/electron/voiceHotkeyProbe.test.js +++ b/electron/voiceHotkeyProbe.test.js @@ -92,3 +92,28 @@ test('a key that starts working retracts its own warning', () => { assert.match(provenBlock, /lastHotkeyIssue = null/); assert.match(provenBlock, /voice:primary-usable/); }); + +// ---- Asking is part of the feature, not a one-shot side effect ---- +// Eric: "if the user clicks it, they should still ask for microphone permission and fn permissions". +// armNativeTiers is one-shot, so a denied (or dismissed) grant could never be re-requested and the +// key stayed dead for the life of the install even though the user was willing to grant it. + +test('using dictation re-asks for the fn grant, not just the first time', () => { + assert.match(hotkeySrc, /const askForFnPermission = \(\) => \{/); + const micHandler = hotkeySrc.split("ipcMain.handle('voice:request-mic-access'")[1].slice(0, 300); + assert.match(micHandler, /askForFnPermission\(\)/, + 'the mic prompt and the fn prompt are the same moment of intent'); + const holdHandler = hotkeySrc.split("ipcMain.handle('voice:request-hold-permission'")[1].slice(0, 300); + assert.match(holdHandler, /askForFnPermission\(\)/); +}); + +test('the re-ask is throttled so it cannot spawn a watcher per keystroke', () => { + const fn = hotkeySrc.split('const askForFnPermission')[1].slice(0, 400); + assert.match(fn, /lastFnAskMs/); + assert.match(fn, /return;/); +}); + +test('the intent path spawns WITHOUT --no-prompt, or nothing is ever asked', () => { + const fn = hotkeySrc.split('const askForFnPermission')[1].slice(0, 400); + assert.match(fn, /startFnWatcher\(\);/, 'a --no-prompt spawn here would silently never prompt'); +});