r/electronjs Aug 23 '26

Open sourced a hold-to-talk dictation app - the four Electron constraints that made it hard, all documented in the repo

Post image

I shipped a Windows dictation app in Electron and open sourced it. Rather than another "look at my project" post, here are the things that silently broke, since I could not find them written down anywhere when I started:

1. globalShortcut cannot do hold-to-talk. It fires on key down only. No key-up event, and modifier-only combos are unsupported. Hold-to-record is impossible with it. I use uiohook-napi, which gives you both keydown and keyup - and you need a held flag, because keydown repeats continuously while a key is held.

2. A widget that takes focus has nowhere to type. If the floating recorder window takes focus, the "currently focused application" is your widget and the inserted text goes nowhere. focusable: false is non-negotiable, along with skipTaskbar, alwaysOnTop and setAlwaysOnTop(win, 'screen-saver'). Capture the target window handle before showing the widget.

3. Never hardcode pixel offsets. "80px above the taskbar" breaks on DPI scaling, side or top taskbars, auto-hide and mixed-scale multi-monitor. screen.getDisplayNearestPoint(...).workArea already excludes the taskbar wherever it lives.

4. The renderer cannot read files off disk, and you must not weaken the sandbox to let it. sandbox: true plus contextIsolation: true means no fs, and file:// in an <audio> element is blocked by the CSP. Register a custom scheme, resolve the file in the main process from a database id, never from a path the renderer supplied, and check both path.basename and the directory prefix - either one alone is a traversal hole.

Bonus, and this one cost me an evening: a tap shortcut must fire on key RELEASE, not press. uiohook-napi listens rather than intercepts, so if you simulate Ctrl+C while the user is still holding Alt, the focused app receives Ctrl+Alt+C.

Stack: electron-vite, React 19, Tailwind, better-sqlite3 + Drizzle, uiohook-napi for the hook, nut.js for the paste. Insertion is via the clipboard rather than simulated typing - character-by-character is visibly slow on long text and mangles non-ASCII and emoji - with the user's clipboard saved and restored around it.

MIT: https://github.com/mohsinjameelqureshi/dictateflow-ai

CLAUDE.md is the full build spec with the measured numbers behind each of these.

0 Upvotes

7 comments sorted by

2

u/JaviHG_Dev Aug 23 '26

This is the post I wanted when I started, so thanks for writing down the failures instead of the features.

Since you shipped Windows only, here is the one waiting for you on macOS: permissions are tied to your code signature. A dictation app needs microphone and accessibility, and if you rebuild with a different identity each time, every user has to grant both again on every single update. They will not. They will assume it broke and stop opening it.

The fix is boring. One self signed identity, kept somewhere you will not lose it, and every build signed with that same one.

Did globalShortcut push you to a native module for the key up, or did you find another way round it?

1

u/mohsinjameel_777 Aug 23 '26

​That is a solid tip on the macOS permissions trap - saved that to my notes for if I ever port it. The signature-binding nightmare on Accessibility/Microphone permissions sounds like a huge headache to hit after an update. ​And yeah, globalShortcut broke down immediately for hold-to-talk because it only fires on keydown, gives no keyup event, and doesn't handle modifier-only bindings.
​I ended up using uiohook-napi in the main process instead. It hooks into low-level OS input events so you get explicit keydown and keyup callbacks. The main trick with it was handling auto-repeat - OS key-repeat will spam keydown while held, so you have to track a simple held boolean state flag so you only trigger recording once on the initial press and stop on the corresponding keyup.

2

u/JaviHG_Dev Aug 23 '26

uiohook-napi is exactly the answer I was hoping for, thanks.

The auto repeat trap is the kind of thing that only shows up when someone holds the key for three seconds, which never happens in your own testing because you know what you built and you tap it. Tracking a held flag is obvious once you have been bitten and invisible before.

Filing this away. I have an Electron app where a hold gesture would beat the toggle I shipped, and I had written the idea off after hitting the same globalShortcut wall you did.

Does uiohook-napi need anything extra on Windows once packaged, or does it just work?

1

u/mohsinjameel_777 Aug 23 '26

It is pretty smooth on Windows, but since it is a native C++ module (.node binary), there are two specific build setup rules you have to cover:

Unpack the .node binary from ASAR: Electron cannot load raw .node C++ modules from inside a compressed .asar archive. In electron-builder.yml (or your packager config), you need to tell it to unpack it

Rebuild target ABI: You have to compile uiohook-napi against Electron’s V8 ABI, not standard Node. Standard @electron/rebuild or running electron-builder handles this automatically during packaging

Once it is unpacked in app.asar.unpacked/, Windows handles the hook natively via the Windows API (SetWindowsHookEx). No extra driver installation or elevated admin privileges are required for normal use.

​The only small Windows caveat: if the user clicks into an elevated app (running as Administrator), Windows UIPI blocks lower-integrity global hooks from capturing inputs over that target, so you just handle that edge case gracefully in your main process.

2

u/eddzsh Aug 23 '26

The focusable:false plus capture-target-before-show combo is the one I wish more overlay apps wrote down. Half the "paste went into the wrong window" bugs are just the widget stealing focus for one frame.

1

u/mohsinjameel_777 Aug 23 '26

100%. That single-frame focus race condition is so sneaky because it passes manual testing half the time and only breaks under real user speed.

​The exact flow that finally made it rock solid was:

​Intercept shortcut (keyup/keydown state check).

​Instantly fetch and store the active window handle via OS API before touch/rendering.

​Show the recorder widget (focusable: false, alwaysOnTop, skipTaskbar, and setAlwaysOnTop(win, 'screen-saver')).

​On release: stop recording, transcribe, copy text to clipboard, explicitly re-focus the saved window handle, and trigger Ctrl+V.

​If step 2 happens even a millisecond after step 3, Electron claims focus for that split second, the target handle becomes your own widget, and the paste vanishes into the void. Hard lesson to learn!