diff --git a/electron/native/fn-watcher.swift b/electron/native/fn-watcher.swift index b7ba2c2b..da575bf2 100644 --- a/electron/native/fn-watcher.swift +++ b/electron/native/fn-watcher.swift @@ -3,6 +3,16 @@ // tap: needs the same Input Monitoring grant the app already requests, never swallows anything. import CoreGraphics import Foundation +import IOKit.hid + +// --no-prompt: the boot-time probe must never raise the Input Monitoring TCC prompt (ENG-341); +// IOHIDCheckAccess answers silently, so granted machines arm and everyone else exits clean. +if CommandLine.arguments.contains("--no-prompt") + && IOHIDCheckAccess(kIOHIDRequestTypeListenEvent) != kIOHIDAccessTypeGranted { + print("e no-permission") + fflush(stdout) + exit(0) +} var fnDown = false var tapRef: CFMachPort? diff --git a/electron/voiceHotkey.js b/electron/voiceHotkey.js index 7bde5108..fd6e23b3 100644 --- a/electron/voiceHotkey.js +++ b/electron/voiceHotkey.js @@ -139,12 +139,12 @@ 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 = () => { + const startFnWatcher = (noPrompt) => { if (process.platform !== 'darwin' || combo.special !== 'fn' || fnProc) return; resolveFnWatcherBinary((bin) => { if (!bin) { console.log('[voice] no fn watcher binary, legacy hotkey stays primary'); return; } if (combo.special !== 'fn' || fnProc) return; // rebound or raced while compiling - startFnWatcherWith(bin); + startFnWatcherWith(bin, noPrompt === true); }); }; // Kill fn-watchers left by a previous OpenSwarm that died badly. will-quit is the ONLY thing that @@ -170,11 +170,11 @@ function installVoiceHotkey(getMainWindow) { lastFnPokeMs = now; try { fnProc.stdin.write('r\n'); } catch (_) {} }; - const startFnWatcherWith = (bin) => { + const startFnWatcherWith = (bin, noPrompt) => { sweepStrayFnWatchers(bin); try { // stdin stays open on purpose: "r\n" re-arms the tap, and EOF tells an orphaned watcher to die. - fnProc = spawn(bin, [], { stdio: ['pipe', 'pipe', 'ignore'] }); + fnProc = spawn(bin, noPrompt ? ['--no-prompt'] : [], { stdio: ['pipe', 'pipe', 'ignore'] }); } catch (e) { console.log('[voice] fn watcher spawn failed:', e && e.message); fnProc = null; @@ -304,6 +304,10 @@ function installVoiceHotkey(getMainWindow) { }; try { if (fs.existsSync(path.join(app.getPath('userData'), 'dictation-used'))) armNativeTiers(); + // No marker yet: fn is the DEFAULT hotkey, and the watcher is the only thing that can see it, + // so probe it in --no-prompt mode. Machines with Input Monitoring already granted get a working + // fn immediately; ungranted machines exit silently and never see a boot-time TCC prompt (ENG-341). + else if (combo.special === 'fn') startFnWatcher(true); } catch (_) {} app.on('browser-window-focus', () => { unregisterFallbackShortcut(); pokeFnWatcher(); }); app.on('browser-window-blur', registerVoiceShortcut); @@ -326,6 +330,14 @@ function installVoiceHotkey(getMainWindow) { const installVoiceHoldRelay = (contents) => { contents.on('before-input-event', (event, input) => { if (input.type !== 'keyDown' || input.isAutoRepeat) return; + // A bare Fn keydown Chromium happens to deliver while focused is both dictation intent + // (full-arm the tiers, prompt lands with context) and a press that must WORK right now. + if (combo.special === 'fn' && !primaryProven() && (input.key === 'Fn' || input.code === 'Fn')) { + armNativeTiers(); + sendFallbackToggle(); + event.preventDefault(); + return; + } if (inputMatchesCombo(input)) { if (!primaryProven()) sendFallbackToggle(); event.preventDefault(); @@ -347,6 +359,7 @@ function installVoiceHotkey(getMainWindow) { fallbackCombo = combo.special ? parseCombo(LEGACY_COMBO) : combo; if (UiohookKeyRef && !combo.special) tapKeycode = uiohookKeycodeFor(combo.key, UiohookKeyRef); if (tiersArmed) startFnWatcher(); + else if (combo.special === 'fn') startFnWatcher(true); unregisterFallbackShortcut(); registerVoiceShortcut(); console.log('[voice] hotkey set to', combo.accel); diff --git a/electron/voiceHotkeyProbe.test.js b/electron/voiceHotkeyProbe.test.js new file mode 100644 index 00000000..7f875ce3 --- /dev/null +++ b/electron/voiceHotkeyProbe.test.js @@ -0,0 +1,40 @@ +// The fn dead-key deadlock (Haik, exp.16, ~99% of devices): the dictation-used marker was only +// written by a successful transcription, the fn watcher only started once the marker existed, and +// fn was the default hotkey, so a never-dictated install could never dictate. The fix is a boot +// probe that arms fn WITHOUT ever prompting (IOHIDCheckAccess preflight) plus intent arming. +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); + +const swiftSrc = fs.readFileSync(path.join(__dirname, 'native', 'fn-watcher.swift'), 'utf8'); +const hotkeySrc = fs.readFileSync(path.join(__dirname, 'voiceHotkey.js'), 'utf8'); + +test('the swift preflight refuses to prompt in --no-prompt mode', () => { + const preflight = swiftSrc.indexOf('--no-prompt'); + const firstTap = swiftSrc.indexOf('func armTap'); + assert.ok(preflight > -1, 'preflight missing'); + assert.ok(preflight < firstTap, 'preflight must run before any tap can raise the TCC prompt'); + assert.match(swiftSrc, /IOHIDCheckAccess\(kIOHIDRequestTypeListenEvent\)/); +}); + +test('boot probes fn silently when the marker is absent', () => { + const bootBlock = hotkeySrc.split("fs.existsSync(path.join(app.getPath('userData'), 'dictation-used'))")[1].slice(0, 400); + assert.match(bootBlock, /combo\.special === 'fn'.*startFnWatcher\(true\)/s, + 'a fresh install with the default fn hotkey must get the no-prompt probe at boot'); +}); + +test('rebinding to fn before tiers are armed also probes', () => { + assert.match(hotkeySrc, /else if \(combo\.special === 'fn'\) startFnWatcher\(true\);\n unregisterFallbackShortcut/); +}); + +test('a focused bare Fn keydown arms the tiers and toggles', () => { + const relay = hotkeySrc.split('installVoiceHoldRelay')[1].slice(0, 900); + assert.match(relay, /input\.key === 'Fn'/); + assert.match(relay, /armNativeTiers\(\)/); + assert.match(relay, /sendFallbackToggle\(\)/); +}); + +test('the probe flag reaches the spawn argv', () => { + assert.match(hotkeySrc, /spawn\(bin, noPrompt \? \['--no-prompt'\] : \[\]/); +});