From fe29551d746db0e1c0cec5f8b512e1f807625b87 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 28 Jul 2026 15:01:59 -0700 Subject: [PATCH] [eric] voice: native trackpad haptics addon (NSHapticFeedbackManager, mouseclamp pattern) taps on dictation start/stop --- electron/main.js | 30 ++++++++++++ electron/native/haptics/binding.gyp | 14 ++++++ electron/native/haptics/haptics.mm | 48 +++++++++++++++++++ electron/native/haptics/package.json | 7 +++ electron/package.json | 7 +++ electron/preload.js | 1 + electron/scripts/build-haptics.sh | 28 +++++++++++ .../src/shared/voice/useVoiceDictation.ts | 2 + scripts/build-app.sh | 2 + 9 files changed, 139 insertions(+) create mode 100644 electron/native/haptics/binding.gyp create mode 100644 electron/native/haptics/haptics.mm create mode 100644 electron/native/haptics/package.json create mode 100755 electron/scripts/build-haptics.sh diff --git a/electron/main.js b/electron/main.js index f8921af0..9162432f 100644 --- a/electron/main.js +++ b/electron/main.js @@ -1777,6 +1777,35 @@ function installMacMouseClamp() { } } +// Trackpad haptic taps (macOS, Force Touch only): dictation start/stop feedback. Fail-open like +// mouseclamp; a missing addon or non-mac just makes 'haptic:perform' return false. +let hapticsAddon = null; +function installHaptics() { + if (process.platform !== 'darwin') return; + try { + const nodePath = isPackaged + ? path.join(process.resourcesPath, 'haptics', 'haptics.node') + : path.join(__dirname, 'build-staging', 'haptics', process.arch, 'haptics.node'); + if (!fs.existsSync(nodePath)) { + console.log('[haptics] addon not present, skipping:', nodePath); + return; + } + hapticsAddon = require(nodePath); + console.log('[haptics] addon loaded'); + } catch (e) { + console.log('[haptics] load failed (continuing):', e && e.message); + } +} +ipcMain.handle('haptic:perform', (event, pattern) => { + try { + if (!hapticsAddon) return false; + const p = pattern === 'alignment' ? 1 : pattern === 'level' ? 2 : 0; + return hapticsAddon.perform(p); + } catch (_) { + return false; + } +}); + app.whenReady().then(async () => { // We made it here, so any prior update swap finished. Drop a stale updating.lock // (the watchdog never deletes it) so a real crash later isn't silently swallowed. @@ -1788,6 +1817,7 @@ app.whenReady().then(async () => { // Off-window mouse-release crash dodge (macOS). Safe to call before windows exist. installMacMouseClamp(); + installHaptics(); // Voice dictation hotkey (F5 / Cmd-Ctrl+Shift+D). Native uiohook key tap = true keyboard // hold-to-talk on every platform; falls back to the old press-to-toggle when the tap can't run diff --git a/electron/native/haptics/binding.gyp b/electron/native/haptics/binding.gyp new file mode 100644 index 00000000..6a431329 --- /dev/null +++ b/electron/native/haptics/binding.gyp @@ -0,0 +1,14 @@ +{ + "targets": [ + { + "target_name": "haptics", + "sources": ["haptics.mm"], + "xcode_settings": { + "CLANG_ENABLE_OBJC_ARC": "NO", + "OTHER_CFLAGS": ["-fobjc-exceptions"] + }, + "libraries": ["-framework Cocoa"], + "conditions": [["OS!=\"mac\"", {"type": "none"}]] + } + ] +} diff --git a/electron/native/haptics/haptics.mm b/electron/native/haptics/haptics.mm new file mode 100644 index 00000000..13ffe6c9 --- /dev/null +++ b/electron/native/haptics/haptics.mm @@ -0,0 +1,48 @@ +// Trackpad haptic taps for dictation start/stop (and any future micro-feedback), the missing half +// of the WhisperFlow feel. NSHapticFeedbackManager only fires on Force Touch trackpads and only +// when macOS deems the app active; both are fine, this is garnish, never load-bearing. +// +// Fail-open everywhere: no trackpad, no permission, wrong thread, we return false and the app +// behaves exactly like today. macOS-only by binding.gyp condition. + +#include +#import + +static napi_value Perform(napi_env env, napi_callback_info info) { + bool ok = false; + @try { + size_t argc = 1; + napi_value argv[1]; + napi_get_cb_info(env, info, &argc, argv, NULL, NULL); + int32_t pattern = 0; + if (argc >= 1) napi_get_value_int32(env, argv[0], &pattern); + NSHapticFeedbackPattern p = NSHapticFeedbackPatternGeneric; + if (pattern == 1) p = NSHapticFeedbackPatternAlignment; + else if (pattern == 2) p = NSHapticFeedbackPatternLevelChange; + // Main-thread dispatch: AppKit feedback performers are main-thread creatures, and the IPC + // handler that calls us already runs there in Electron's browser process; the async hop is + // belt-and-suspenders for any future caller. + dispatch_async(dispatch_get_main_queue(), ^{ + @try { + [[NSHapticFeedbackManager defaultPerformer] + performFeedbackPattern:p + performanceTime:NSHapticFeedbackPerformanceTimeNow]; + } @catch (NSException *e) { /* garnish only */ } + }); + ok = true; + } @catch (NSException *e) { + ok = false; + } + napi_value result; + napi_get_boolean(env, ok, &result); + return result; +} + +static napi_value Init(napi_env env, napi_value exports) { + napi_value fn; + napi_create_function(env, "perform", NAPI_AUTO_LENGTH, Perform, NULL, &fn); + napi_set_named_property(env, exports, "perform", fn); + return exports; +} + +NAPI_MODULE(NODE_GYP_MODULE_NAME, Init) diff --git a/electron/native/haptics/package.json b/electron/native/haptics/package.json new file mode 100644 index 00000000..14c732ab --- /dev/null +++ b/electron/native/haptics/package.json @@ -0,0 +1,7 @@ +{ + "name": "haptics", + "version": "1.0.0", + "private": true, + "description": "macOS-only native addon: Force Touch trackpad haptic taps via NSHapticFeedbackManager for dictation start/stop feedback. See haptics.mm.", + "gypfile": true +} diff --git a/electron/package.json b/electron/package.json index 8f9408db..1790a811 100644 --- a/electron/package.json +++ b/electron/package.json @@ -75,6 +75,13 @@ "**/*" ] }, + { + "from": "build-staging/haptics/${arch}", + "to": "haptics", + "filter": [ + "**/*" + ] + }, { "from": "build-staging/python-env/${arch}", "to": "python-env", diff --git a/electron/preload.js b/electron/preload.js index 3613fadb..cedb54d6 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -77,6 +77,7 @@ contextBridge.exposeInMainWorld('openswarm', { // and request triggers the macOS Accessibility prompt when the tap is blocked on permission. voiceHoldCapable: () => ipcRenderer.invoke('voice:hold-capable'), voiceRequestHoldPermission: () => ipcRenderer.invoke('voice:request-hold-permission'), + haptic: (pattern) => ipcRenderer.invoke('haptic:perform', pattern), // Native-tap hold relay: real global key-down/key-up for the voice combo, focus-independent. onVoiceHold: (onDown, onUp) => { const down = () => onDown(); diff --git a/electron/scripts/build-haptics.sh b/electron/scripts/build-haptics.sh new file mode 100755 index 00000000..33389992 --- /dev/null +++ b/electron/scripts/build-haptics.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# Compile the macOS mouse-clamp native addon for one arch and stage it where +# electron-builder's extraResources picks it up (build-staging/haptics/). +# See electron/native/haptics/haptics.mm for what it fixes. +set -euo pipefail + +ARCH="${1:?usage: build-haptics.sh }" + +HERE="$(cd "$(dirname "$0")/.." && pwd)" # electron/ +# Derive the node-gyp header target from the actually-installed electron so a version bump (e.g. 42.0.0 -> 42.3.3) is auto-tracked instead of silently building against stale headers. Strip any +wvcus suffix; node-gyp wants a plain semver. +ELECTRON_TARGET="$(node -p "require('$HERE/node_modules/electron/package.json').version.split('+')[0]" 2>/dev/null || echo '42.3.3')" +SRC="$HERE/native/haptics" +OUT="$HERE/build-staging/haptics/$ARCH" +NODE_GYP="$HERE/node_modules/.bin/node-gyp" +[[ -x "$NODE_GYP" ]] || NODE_GYP="npx --yes node-gyp" # transitive dep usually, npx if not + +echo "[haptics] building for arch=$ARCH (electron $ELECTRON_TARGET)" +cd "$SRC" +rm -rf build +$NODE_GYP rebuild \ + --target="$ELECTRON_TARGET" \ + --arch="$ARCH" \ + --dist-url=https://electronjs.org/headers + +mkdir -p "$OUT" +cp "build/Release/haptics.node" "$OUT/haptics.node" +echo "[haptics] staged -> $OUT/haptics.node" +file "$OUT/haptics.node" diff --git a/frontend/src/shared/voice/useVoiceDictation.ts b/frontend/src/shared/voice/useVoiceDictation.ts index 160935df..23426ec2 100644 --- a/frontend/src/shared/voice/useVoiceDictation.ts +++ b/frontend/src/shared/voice/useVoiceDictation.ts @@ -119,6 +119,7 @@ export function useVoiceDictation() { recRef.current = { ctx, stream, node, source, chunks }; setState('recording'); playVoiceCue('start'); + void (window.openswarm as { haptic?: (p: string) => Promise } | undefined)?.haptic?.('generic'); // Warm the model the moment recording begins so transcription is instant on stop. void window.openswarm?.voiceWarmup?.(); } catch (err) { @@ -134,6 +135,7 @@ export function useVoiceDictation() { if (stateRef.current !== 'recording') return; const samples = teardown(); playVoiceCue('stop'); + void (window.openswarm as { haptic?: (p: string) => Promise } | undefined)?.haptic?.('alignment'); setState('transcribing'); try { if (!samples || samples.length < VOICE_SAMPLE_RATE * 0.2) { setState('idle'); return; } // < 0.2s = a misfire diff --git a/scripts/build-app.sh b/scripts/build-app.sh index 2cce1dc5..b39a1599 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -497,6 +497,8 @@ if [[ "$(uname)" == "Darwin" ]]; then echo "Building mouse-clamp native addon (arm64 + x64)..." bash scripts/build-mouseclamp.sh arm64 bash scripts/build-mouseclamp.sh x64 + bash scripts/build-haptics.sh arm64 + bash scripts/build-haptics.sh x64 fi # Node's default ~4 GB heap OOMs while codesign'ing the .app on dual-arch