From 237038b3c52bd20ab4094b62ef66f8e30f9b9c5e Mon Sep 17 00:00:00 2001 From: Felipe Afonso Date: Wed, 11 Mar 2026 23:06:01 -0300 Subject: [PATCH 1/6] feat: global hyprland hotkeys integration --- CLAUDE.md | 86 +++++++- src/components/OnboardingFlow.tsx | 10 +- src/components/SettingsPage.tsx | 8 +- src/helpers/hotkeyManager.js | 110 +++++++++++ src/helpers/hyprlandShortcut.js | 315 ++++++++++++++++++++++++++++++ src/helpers/ipcHandlers.js | 32 ++- src/helpers/windowManager.js | 8 + src/types/electron.ts | 6 +- 8 files changed, 559 insertions(+), 16 deletions(-) create mode 100644 src/helpers/hyprlandShortcut.js diff --git a/CLAUDE.md b/CLAUDE.md index c5434ea5..71057129 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,6 +9,7 @@ OpenWhispr is an Electron-based desktop dictation application that uses whisper. ## Architecture Overview ### Core Technologies + - **Frontend**: React 19, TypeScript, Tailwind CSS v4, Vite - **Desktop Framework**: Electron 36 with context isolation - **Database**: better-sqlite3 for local transcription history @@ -65,11 +66,17 @@ OpenWhispr is an Electron-based desktop dictation application that uses whisper. - Auto-fallback to F8/F9 if default hotkey is unavailable - Notifies renderer via IPC when hotkey registration fails - Integrates with GnomeShortcutManager for GNOME Wayland support + - Integrates with HyprlandShortcutManager for Hyprland Wayland support - **gnomeShortcut.js**: GNOME Wayland global shortcut integration - Uses D-Bus service to receive hotkey toggle commands - Registers shortcuts via gsettings (visible in GNOME Settings → Keyboard → Shortcuts) - Converts Electron hotkey format to GNOME keysym format - Only active on Linux + Wayland + GNOME desktop +- **hyprlandShortcut.js**: Hyprland Wayland global shortcut integration + - Uses D-Bus service to receive hotkey toggle commands (same `com.openwhispr.App` service) + - Registers shortcuts via `hyprctl keyword bind` (runtime keybinding) + - Converts Electron hotkey format to Hyprland bind format (`MODS, key`) + - Only active on Linux + Wayland + Hyprland (detected via `HYPRLAND_INSTANCE_SIGNATURE`) - **ipcHandlers.js**: Centralized IPC handler registration - **windowsKeyManager.js**: Windows Push-to-Talk support with native key listener - Spawns native `windows-key-listener.exe` binary for low-level keyboard hooks @@ -179,6 +186,7 @@ OpenWhispr is an Electron-based desktop dictation application that uses whisper. ### 1. FFmpeg Integration FFmpeg is bundled with the app and doesn't require system installation: + ```javascript // FFmpeg is unpacked from ASAR to app.asar.unpacked/node_modules/ffmpeg-static/ ``` @@ -197,6 +205,7 @@ FFmpeg is bundled with the app and doesn't require system installation: ### 3. Local Whisper Models (GGML format) Models stored in `~/.cache/openwhispr/whisper-models/`: + - tiny: ~75MB (fastest, lowest quality) - base: ~142MB (recommended balance) - small: ~466MB (better quality) @@ -222,6 +231,7 @@ CREATE TABLE transcriptions ( ### 5. Settings Storage Settings stored in localStorage with these keys: + - `whisperModel`: Selected Whisper model - `useLocalWhisper`: Boolean for local vs cloud - `openaiApiKey`: Encrypted API key @@ -236,12 +246,14 @@ Settings stored in localStorage with these keys: - `customDictionary`: JSON array of words/phrases for improved transcription accuracy Environment variables persisted to `.env` (via `saveAllKeysToEnvFile()`): + - `LOCAL_TRANSCRIPTION_PROVIDER`: Transcription engine (`nvidia` for Parakeet) - `PARAKEET_MODEL`: Selected Parakeet model name (e.g., `parakeet-tdt-0.6b-v3`) ### 6. Language Support 58 languages supported (see src/utils/languages.ts): + - Each language has a two-letter code and label - "auto" for automatic detection - Passed to whisper.cpp via -l parameter @@ -280,6 +292,7 @@ All AI model definitions are centralized in `src/models/modelRegistryData.json` ``` **Key files:** + - `src/models/modelRegistryData.json` - Single source of truth for all models - `src/models/ModelRegistry.ts` - TypeScript wrapper with helper methods - `src/config/aiProvidersConfig.ts` - Derives AI_MODES from registry @@ -287,6 +300,7 @@ All AI model definitions are centralized in `src/models/modelRegistryData.json` - `src/helpers/modelManagerBridge.js` - Handles local model downloads **Local model features:** + - Each model has `hfRepo` for direct HuggingFace download URLs - `promptTemplate` defines the chat format (ChatML, Llama, Mistral) - Download URLs constructed as: `{baseUrl}/{hfRepo}/resolve/main/{fileName}` @@ -294,6 +308,7 @@ All AI model definitions are centralized in `src/models/modelRegistryData.json` ### 9. API Integrations and Updates **OpenAI Responses API (September 2025)**: + - Migrated from Chat Completions to new Responses API - Endpoint: `https://api.openai.com/v1/responses` - Simplified request format with `input` array instead of `messages` @@ -302,17 +317,20 @@ All AI model definitions are centralized in `src/models/modelRegistryData.json` - No temperature parameter for newer models (GPT-5, o-series) **Anthropic Integration**: + - Routes through IPC handler to avoid CORS issues in renderer process - Uses main process for API calls with proper error handling - Model IDs use alias format (e.g., `claude-sonnet-4-6` not date-suffixed versions) **Gemini Integration**: + - Direct API calls from renderer process - Increased token limits for Gemini 3.1 Pro (2000 minimum) - Proper handling of thinking process in responses - Error handling for MAX_TOKENS finish reason **API Key Persistence**: + - All API keys now properly persist to `.env` file - Keys stored in environment variables and reloaded on app start - Centralized `saveAllKeysToEnvFile()` method ensures consistency @@ -322,6 +340,7 @@ All AI model definitions are centralized in `src/models/modelRegistryData.json` The app can open OS-level settings for microphone permissions, sound input selection, and accessibility: **IPC Handlers** (in `ipcHandlers.js`): + - `open-microphone-settings`: Opens microphone privacy settings - `open-sound-input-settings`: Opens sound/audio input device settings - `open-accessibility-settings`: Opens accessibility privacy settings (macOS only) @@ -334,6 +353,7 @@ The app can open OS-level settings for microphone permissions, sound input selec | Linux | Manual (no URL scheme) | Manual (e.g., pavucontrol) | N/A | **UI Component** (`MicPermissionWarning.tsx`): + - Shows platform-appropriate buttons and messages - Linux only shows "Open Sound Settings" (no separate privacy settings) - macOS/Windows show both sound and privacy buttons @@ -341,6 +361,7 @@ The app can open OS-level settings for microphone permissions, sound input selec ### 11. Debug Mode Enable with `--log-level=debug` or `OPENWHISPR_LOG_LEVEL=debug` (can be set in `.env`): + - Logs saved to platform-specific app data directory - Comprehensive logging of audio pipeline - FFmpeg path resolution details @@ -352,22 +373,26 @@ Enable with `--log-level=debug` or `OPENWHISPR_LOG_LEVEL=debug` (can be set in ` Native Windows support for true push-to-talk functionality using low-level keyboard hooks: **Architecture**: + - `resources/windows-key-listener.c`: Native C program using Windows `SetWindowsHookEx` for keyboard hooks - `src/helpers/windowsKeyManager.js`: Node.js wrapper that spawns and manages the native binary - Binary outputs `KEY_DOWN` and `KEY_UP` to stdout when target key is pressed/released **Compound Hotkey Support**: + - Parses hotkey strings like `CommandOrControl+Shift+F11` - Maps modifiers: `CommandOrControl`/`Ctrl` → VK_CONTROL, `Alt`/`Option` → VK_MENU, `Shift` → VK_SHIFT - Verifies all required modifiers are held before emitting key events **Binary Distribution**: + - Prebuilt binary downloaded from GitHub releases (`windows-key-listener-v*` tags) - Download script: `scripts/download-windows-key-listener.js` - CI workflow: `.github/workflows/build-windows-key-listener.yml` - Fallback to tap mode if binary unavailable **IPC Events**: + - `windows-key-listener:key-down`: Fired when hotkey pressed (start recording) - `windows-key-listener:key-up`: Fired when hotkey released (stop recording) @@ -376,18 +401,21 @@ Native Windows support for true push-to-talk functionality using low-level keybo Improve transcription accuracy for specific words, names, or technical terms: **How it works**: + - User adds words/phrases through Settings → Custom Dictionary - Words stored as JSON array in localStorage (`customDictionary` key) - On transcription, words are joined and passed as `prompt` parameter to Whisper - Works with both local whisper.cpp and cloud OpenAI Whisper API **Implementation**: + - `src/hooks/useSettings.ts`: Manages `customDictionary` state - `src/components/SettingsPage.tsx`: UI for adding/removing dictionary words - `src/helpers/audioManager.js`: Reads dictionary and adds to transcription options - `src/helpers/whisperServer.js`: Includes dictionary as `prompt` in API request **Whisper Prompt Parameter**: + - Whisper uses the prompt as context/hints for transcription - Words in the prompt are more likely to be recognized correctly - Useful for: uncommon names, technical jargon, brand names, domain-specific terms @@ -397,6 +425,7 @@ Improve transcription accuracy for specific words, names, or technical terms: On GNOME Wayland, Electron's `globalShortcut` API doesn't work due to Wayland's security model. OpenWhispr uses native GNOME shortcuts: **Architecture**: + 1. `main.js` enables `GlobalShortcutsPortal` feature flag for Wayland 2. `hotkeyManager.js` detects GNOME + Wayland and initializes `GnomeShortcutManager` 3. `gnomeShortcut.js` creates D-Bus service at `com.openwhispr.App` @@ -404,40 +433,81 @@ On GNOME Wayland, Electron's `globalShortcut` API doesn't work due to Wayland's 5. GNOME triggers `dbus-send` command which calls the D-Bus `Toggle()` method **Key Constants**: + - D-Bus service: `com.openwhispr.App` - D-Bus path: `/com/openwhispr/App` - gsettings path: `/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/openwhispr/` **IPC Integration**: -- `get-hotkey-mode-info`: Returns `{ isUsingGnome: boolean }` to renderer -- UI hides activation mode selector when `isUsingGnome` is true + +- `get-hotkey-mode-info`: Returns `{ isUsingGnome, isUsingHyprland, isUsingNativeShortcut }` to renderer +- UI hides activation mode selector when `isUsingNativeShortcut` is true - Forces tap-to-talk mode (push-to-talk not supported) **Hotkey Format Conversion**: + - Electron format: `Alt+R`, `CommandOrControl+Shift+Space` - GNOME format: `r`, `space` - Backtick (`) → `grave` in GNOME keysym format -### 15. Meeting Detection (Event-Driven) +### 15. Hyprland Wayland Global Hotkeys + +On Hyprland (wlroots Wayland compositor), Electron's `globalShortcut` API and the `GlobalShortcutsPortal` feature don't work reliably. OpenWhispr uses native Hyprland keybindings: + +**Architecture**: + +1. `main.js` enables `GlobalShortcutsPortal` feature flag for Wayland (fallback) +2. `hotkeyManager.js` detects Hyprland + Wayland and initializes `HyprlandShortcutManager` +3. `hyprlandShortcut.js` creates D-Bus service at `com.openwhispr.App` (same as GNOME) +4. Shortcuts registered via `hyprctl keyword bind` (runtime keybinding) +5. Hyprland triggers `dbus-send` command which calls the D-Bus `Toggle()` method + +**Detection**: + +- Primary: `HYPRLAND_INSTANCE_SIGNATURE` environment variable (set by Hyprland) +- Fallback: `XDG_CURRENT_DESKTOP` contains "hyprland" + +**Hotkey Format Conversion**: + +- Electron format: `Alt+R`, `CommandOrControl+Shift+Space` +- Hyprland format: `ALT, R`, `CTRL SHIFT, space` +- Modifier-only combos (e.g., `Control+Super`) → `CTRL, Super_L` + +**Bind/Unbind Commands**: + +- Register: `hyprctl keyword bind "ALT, R, exec, dbus-send --session ..."` +- Unregister: `hyprctl keyword unbind "ALT, R"` +- Bindings are ephemeral (don't survive Hyprland restart) but re-registered on app startup + +**Limitations**: + +- Push-to-talk not supported (Hyprland `bind` fires a single exec, not key-down/key-up) +- Requires `hyprctl` on PATH (ships with Hyprland) + +### 16. Meeting Detection (Event-Driven) Detects meetings via three independent sources, orchestrated by `MeetingDetectionEngine`: **Architecture**: + - `MeetingDetectionEngine` listens to events from `MeetingProcessDetector` and `AudioActivityDetector` - `GoogleCalendarManager` provides calendar context (imminent events, active meetings) - All three sources feed into a unified notification pipeline **Process Detection** (known meeting apps — Zoom, Teams, Webex, FaceTime): + - macOS: `systemPreferences.subscribeWorkspaceNotification` — zero CPU, instant detection - Windows/Linux: `processListCache` shared polling (30s interval, `ps-list` npm) **Microphone Detection** (unscheduled/browser meetings like Google Meet): + - macOS: `macos-mic-listener` binary — CoreAudio `kAudioDevicePropertyDeviceIsRunningSomewhere` property listeners with hot-plug support - Windows: `windows-mic-listener.exe` — WASAPI `IAudioSessionManager2` session monitoring, `--exclude-pid` for self-mic exclusion - Linux: `pactl subscribe` — PulseAudio source-output events - All platforms: Graceful fallback to polling if native binary/command unavailable **UX Rules**: + - During recording (tap-to-talk or push-to-talk): ALL notifications suppressed - After recording: 2.5s cooldown before showing queued notifications - Multiple signals coalesced: process > audio priority, one notification shown @@ -445,11 +515,13 @@ Detects meetings via three independent sources, orchestrated by `MeetingDetectio - Active calendar meeting recording: all detections suppressed **Binary Distribution**: + - macOS: Compiled from Swift source via `scripts/build-macos-mic-listener.js` during `compile:native` - Windows: Prebuilt binary downloaded via `scripts/download-windows-mic-listener.js` during `prebuild:win` - CI workflow: `.github/workflows/build-windows-mic-listener.yml` auto-builds on push to main **Calendar Sync Resilience**: + - 10s socket timeout on all Google Calendar API requests - Exponential backoff on consecutive failures: 2min → 4min → 8min → cap 30min - Reset to normal 2min interval on any successful sync @@ -465,6 +537,7 @@ All user-facing strings **must** use the i18n system. Never hardcode UI text in **Supported languages**: en, es, fr, de, pt, it, ru, zh-CN, zh-TW **How to use**: + ```tsx import { useTranslation } from "react-i18next"; @@ -474,6 +547,7 @@ const { t } = useTranslation(); ``` **Rules**: + 1. Every new UI string must have a translation key in `en/translation.json` and all other language files 2. Use `useTranslation()` hook in components and hooks 3. Keep `{{variable}}` interpolation syntax for dynamic values @@ -500,7 +574,8 @@ const { t } = useTranslation(); - [ ] Test custom dictionary with uncommon words - [ ] Verify Windows Push-to-Talk with compound hotkeys - [ ] Test GNOME Wayland hotkeys (if on GNOME + Wayland) -- [ ] Verify activation mode selector is hidden on GNOME Wayland +- [ ] Test Hyprland Wayland hotkeys (if on Hyprland + Wayland) +- [ ] Verify activation mode selector is hidden on GNOME Wayland and Hyprland Wayland - [ ] Verify meeting detection works with event-driven mode (check debug logs for "event-driven") - [ ] Test meeting notification suppression during recording - [ ] Test post-recording cooldown (notifications shouldn't flash immediately) @@ -551,6 +626,7 @@ const { t } = useTranslation(); ### Platform-Specific Notes **macOS**: + - Requires accessibility permissions for clipboard (auto-paste) - Requires microphone permission (prompted by system) - Uses AppleScript for reliable pasting @@ -560,6 +636,7 @@ const { t } = useTranslation(); - System settings accessible via `x-apple.systempreferences:` URL scheme **Windows**: + - No special accessibility permissions needed - Microphone privacy settings at `ms-settings:privacy-microphone` - Sound settings at `ms-settings:sound` @@ -572,6 +649,7 @@ const { t } = useTranslation(); - Falls back to tap mode if unavailable **Linux**: + - Multiple package manager support - Standard XDG directories - AppImage for distribution diff --git a/src/components/OnboardingFlow.tsx b/src/components/OnboardingFlow.tsx index c250c003..cadac6a0 100644 --- a/src/components/OnboardingFlow.tsx +++ b/src/components/OnboardingFlow.tsx @@ -101,7 +101,7 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) { const [skipAuth, setSkipAuth] = useState(false); const [pendingVerificationEmail, setPendingVerificationEmail] = useState(null); const [isModelDownloaded, setIsModelDownloaded] = useState(false); - const [isUsingGnomeHotkeys, setIsUsingGnomeHotkeys] = useState(false); + const [isUsingNativeShortcut, setIsUsingNativeShortcut] = useState(false); const readableHotkey = formatHotkeyLabel(hotkey); const { alertDialog, confirmDialog, showAlertDialog, hideAlertDialog, hideConfirmDialog } = useDialogs(); @@ -150,8 +150,8 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) { const checkHotkeyMode = async () => { try { const info = await window.electronAPI?.getHotkeyModeInfo(); - if (info?.isUsingGnome) { - setIsUsingGnomeHotkeys(true); + if (info?.isUsingNativeShortcut) { + setIsUsingNativeShortcut(true); setActivationMode("tap"); } } catch (error) { @@ -652,7 +652,7 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) { {/* Mode section - inline with hotkey */} - {!isUsingGnomeHotkeys && ( + {!isUsingNativeShortcut && (
@@ -680,7 +680,7 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) { {t("onboarding.activation.test")} - {activationMode === "tap" || isUsingGnomeHotkeys + {activationMode === "tap" || isUsingNativeShortcut ? t("onboarding.activation.hotkeyToStartStop", { hotkey: readableHotkey }) : t("onboarding.activation.holdHotkey", { hotkey: readableHotkey })} diff --git a/src/components/SettingsPage.tsx b/src/components/SettingsPage.tsx index 83410a84..61d8fc18 100644 --- a/src/components/SettingsPage.tsx +++ b/src/components/SettingsPage.tsx @@ -859,7 +859,7 @@ export default function SettingsPage({ activeSection = "general" }: SettingsPage [] ); - const [isUsingGnomeHotkeys, setIsUsingGnomeHotkeys] = useState(false); + const [isUsingNativeShortcut, setIsUsingNativeShortcut] = useState(false); const platform = getCachedPlatform(); @@ -925,8 +925,8 @@ export default function SettingsPage({ activeSection = "general" }: SettingsPage const checkHotkeyMode = async () => { try { const info = await window.electronAPI?.getHotkeyModeInfo(); - if (info?.isUsingGnome) { - setIsUsingGnomeHotkeys(true); + if (info?.isUsingNativeShortcut) { + setIsUsingNativeShortcut(true); setActivationMode("tap"); } } catch (error) { @@ -2384,7 +2384,7 @@ EOF`, )} - {!isUsingGnomeHotkeys && ( + {!isUsingNativeShortcut && (

{t("settingsPage.general.hotkey.activationMode")} diff --git a/src/helpers/hotkeyManager.js b/src/helpers/hotkeyManager.js index 577b7155..99339637 100644 --- a/src/helpers/hotkeyManager.js +++ b/src/helpers/hotkeyManager.js @@ -1,6 +1,7 @@ const { globalShortcut } = require("electron"); const debugLogger = require("./debugLogger"); const GnomeShortcutManager = require("./gnomeShortcut"); +const HyprlandShortcutManager = require("./hyprlandShortcut"); const { i18nMain } = require("./i18nMain"); // Delay to ensure localStorage is accessible after window load @@ -65,6 +66,8 @@ class HotkeyManager { this.isListeningMode = false; this.gnomeManager = null; this.useGnome = false; + this.hyprlandManager = null; + this.useHyprland = false; } // Backward-compatible property accessors @@ -349,6 +352,36 @@ class HotkeyManager { return false; } + async initializeHyprlandShortcuts(callback) { + if (process.platform !== "linux" || !HyprlandShortcutManager.isWayland()) { + return false; + } + + if (HyprlandShortcutManager.isHyprland()) { + if (!HyprlandShortcutManager.isHyprctlAvailable()) { + debugLogger.log("[HotkeyManager] Hyprland detected but hyprctl not available"); + return false; + } + + try { + this.hyprlandManager = new HyprlandShortcutManager(); + + const dbusOk = await this.hyprlandManager.initDBusService(callback); + if (dbusOk) { + this.useHyprland = true; + this.hotkeyCallback = callback; + return true; + } + } catch (err) { + debugLogger.log("[HotkeyManager] Hyprland shortcut init failed:", err.message); + this.hyprlandManager = null; + this.useHyprland = false; + } + } + + return false; + } + async initializeHotkey(mainWindow, callback) { if (!mainWindow || !callback) { throw new Error("mainWindow and callback are required"); @@ -392,6 +425,45 @@ class HotkeyManager { this.isInitialized = true; return; } + + // Try Hyprland native shortcuts if GNOME path was not applicable + const hyprlandOk = await this.initializeHyprlandShortcuts(callback); + + if (hyprlandOk) { + const registerHyprlandHotkey = async () => { + try { + const savedHotkey = await mainWindow.webContents.executeJavaScript(` + localStorage.getItem("dictationKey") || "" + `); + const hotkey = savedHotkey && savedHotkey.trim() !== "" ? savedHotkey : "Control+Super"; + + const success = await this.hyprlandManager.registerKeybinding(hotkey); + if (success) { + this.currentHotkey = hotkey; + debugLogger.log( + `[HotkeyManager] Hyprland hotkey "${hotkey}" registered successfully` + ); + } else { + debugLogger.log( + "[HotkeyManager] Hyprland keybinding failed, falling back to globalShortcut" + ); + this.useHyprland = false; + this.loadSavedHotkeyOrDefault(mainWindow, callback); + } + } catch (err) { + debugLogger.log( + "[HotkeyManager] Hyprland keybinding failed, falling back to globalShortcut:", + err.message + ); + this.useHyprland = false; + this.loadSavedHotkeyOrDefault(mainWindow, callback); + } + }; + + setTimeout(registerHyprlandHotkey, HOTKEY_REGISTRATION_DELAY_MS); + this.isInitialized = true; + return; + } } if (process.platform === "linux") { @@ -560,6 +632,28 @@ class HotkeyManager { }; } + if (this.useHyprland && this.hyprlandManager) { + debugLogger.log(`[HotkeyManager] Updating Hyprland hotkey to "${hotkey}"`); + const success = await this.hyprlandManager.updateKeybinding(hotkey); + if (!success) { + return { + success: false, + message: `Failed to update Hyprland hotkey to "${hotkey}". Check the format is valid.`, + }; + } + this.currentHotkey = hotkey; + const saved = await this.saveHotkeyToRenderer(hotkey); + if (!saved) { + debugLogger.warn( + "[HotkeyManager] Hyprland hotkey registered but failed to persist to localStorage" + ); + } + return { + success: true, + message: `Hotkey updated to: ${hotkey} (via Hyprland native shortcut)`, + }; + } + const result = this.setupShortcuts(hotkey, callback); if (result.success) { const saved = await this.saveHotkeyToRenderer(hotkey); @@ -598,6 +692,14 @@ class HotkeyManager { this.gnomeManager = null; this.useGnome = false; } + if (this.hyprlandManager) { + this.hyprlandManager.unregisterKeybinding().catch((err) => { + debugLogger.warn("[HotkeyManager] Error unregistering Hyprland keybinding:", err.message); + }); + this.hyprlandManager.close(); + this.hyprlandManager = null; + this.useHyprland = false; + } for (const slotName of this.slots.keys()) { const slot = this.slots.get(slotName); if (slot) { @@ -612,6 +714,14 @@ class HotkeyManager { return this.useGnome; } + isUsingHyprland() { + return this.useHyprland; + } + + isUsingNativeShortcut() { + return this.useGnome || this.useHyprland; + } + isHotkeyRegistered(hotkey) { return globalShortcut.isRegistered(hotkey); } diff --git a/src/helpers/hyprlandShortcut.js b/src/helpers/hyprlandShortcut.js new file mode 100644 index 00000000..e35f5d99 --- /dev/null +++ b/src/helpers/hyprlandShortcut.js @@ -0,0 +1,315 @@ +const { execFileSync } = require("child_process"); +const debugLogger = require("./debugLogger"); + +const DBUS_SERVICE_NAME = "com.openwhispr.App"; +const DBUS_OBJECT_PATH = "/com/openwhispr/App"; +const DBUS_INTERFACE = "com.openwhispr.App"; + +// Map Electron modifier names to Hyprland modifier names +const ELECTRON_TO_HYPRLAND_MOD = { + commandorcontrol: "CTRL", + control: "CTRL", + ctrl: "CTRL", + alt: "ALT", + option: "ALT", + shift: "SHIFT", + super: "SUPER", + meta: "SUPER", + win: "SUPER", + command: "SUPER", + cmd: "SUPER", + cmdorctrl: "CTRL", +}; + +// Map Electron key names to Hyprland key names +const ELECTRON_TO_HYPRLAND_KEY = { + pageup: "Page_Up", + pagedown: "Page_Down", + scrolllock: "Scroll_Lock", + printscreen: "Print", + enter: "Return", + arrowup: "Up", + arrowdown: "Down", + arrowleft: "Left", + arrowright: "Right", + backquote: "grave", + "`": "grave", + " ": "space", +}; + +let dbus = null; + +function getDBus() { + if (dbus) return dbus; + try { + dbus = require("dbus-next"); + return dbus; + } catch (err) { + debugLogger.log("[HyprlandShortcut] Failed to load dbus-next:", err.message); + return null; + } +} + +class HyprlandShortcutManager { + constructor() { + this.bus = null; + this.callback = null; + this.isRegistered = false; + this.currentBinding = null; // Store the current Hyprland bind string for unbinding + } + + /** + * Detect if the current session is running on Hyprland. + * Checks the HYPRLAND_INSTANCE_SIGNATURE env var (most reliable) + * and falls back to XDG_CURRENT_DESKTOP. + */ + static isHyprland() { + if (process.env.HYPRLAND_INSTANCE_SIGNATURE) { + return true; + } + const desktop = (process.env.XDG_CURRENT_DESKTOP || "").toLowerCase(); + return desktop.includes("hyprland"); + } + + static isWayland() { + return process.env.XDG_SESSION_TYPE === "wayland"; + } + + /** + * Check if hyprctl is available on the system. + */ + static isHyprctlAvailable() { + try { + execFileSync("hyprctl", ["version"], { stdio: "pipe", timeout: 3000 }); + return true; + } catch { + return false; + } + } + + /** + * Initialize a D-Bus service to receive Toggle() calls from Hyprland keybindings. + * Reuses the same D-Bus service name/path as the GNOME integration. + */ + async initDBusService(callback) { + this.callback = callback; + + const dbusModule = getDBus(); + if (!dbusModule) { + return false; + } + + try { + this.bus = dbusModule.sessionBus(); + await this.bus.requestName(DBUS_SERVICE_NAME, 0); + + const InterfaceClass = this._createInterfaceClass(dbusModule, callback); + const iface = new InterfaceClass(); + this.bus.export(DBUS_OBJECT_PATH, iface); + + debugLogger.log("[HyprlandShortcut] D-Bus service initialized successfully"); + return true; + } catch (err) { + debugLogger.log("[HyprlandShortcut] Failed to initialize D-Bus service:", err.message); + if (this.bus) { + this.bus.disconnect(); + this.bus = null; + } + return false; + } + } + + _createInterfaceClass(dbusModule, callback) { + class OpenWhisprInterface extends dbusModule.interface.Interface { + constructor() { + super(DBUS_INTERFACE); + this._callback = callback; + } + + Toggle() { + if (this._callback) { + this._callback(); + } + } + } + + OpenWhisprInterface.configureMembers({ + methods: { + Toggle: { inSignature: "", outSignature: "" }, + }, + }); + + return OpenWhisprInterface; + } + + /** + * Convert an Electron-format hotkey string to Hyprland bind format. + * + * Electron format: "Control+Super", "Alt+R", "CommandOrControl+Shift+Space" + * Hyprland format: "CTRL SUPER", "ALT, R" (mods space-separated, comma before key) + * + * For modifier-only combos (e.g. "Control+Super"), Hyprland expects: + * bind = CTRL, Super_L, exec, ... + * where the last modifier is treated as the trigger key. + * + * Returns { mods, key } where mods is the modifier string and key is the trigger key, + * or null if the hotkey can't be converted. + */ + static convertToHyprlandFormat(hotkey) { + if (!hotkey || typeof hotkey !== "string") { + return null; + } + + const parts = hotkey + .split("+") + .map((p) => p.trim()) + .filter(Boolean); + + if (parts.length === 0) { + return null; + } + + // Separate modifiers from the key + const modifiers = []; + let key = null; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + const modName = ELECTRON_TO_HYPRLAND_MOD[part.toLowerCase()]; + if (modName) { + modifiers.push(modName); + } else { + // This is the actual key (should be the last part) + key = part; + } + } + + // If no key was found (modifier-only combo like "Control+Super"), + // use the last modifier as the trigger key in XKB format + if (!key && modifiers.length >= 2) { + const triggerMod = modifiers.pop(); + const modToXkbKey = { + CTRL: "Control_L", + ALT: "Alt_L", + SHIFT: "Shift_L", + SUPER: "Super_L", + }; + key = modToXkbKey[triggerMod] || triggerMod; + } else if (!key && modifiers.length === 1) { + // Single modifier -- can't create a useful bind + return null; + } + + // Convert special key names + if (key) { + const mappedKey = ELECTRON_TO_HYPRLAND_KEY[key.toLowerCase()]; + if (mappedKey) { + key = mappedKey; + } + } + + // Deduplicate modifiers (e.g. if "Control+Ctrl" was somehow passed) + const uniqueMods = [...new Set(modifiers)]; + + return { + mods: uniqueMods.join(" "), + key: key, + // Full bind key string for hyprctl keyword bind/unbind + bindKey: uniqueMods.length > 0 ? `${uniqueMods.join(" ")}, ${key}` : `, ${key}`, + }; + } + + /** + * Register a keybinding in Hyprland using hyprctl keyword bind. + * The binding executes a dbus-send command that calls our Toggle() method. + */ + async registerKeybinding(hotkey) { + if (!HyprlandShortcutManager.isHyprland()) { + debugLogger.log("[HyprlandShortcut] Not running on Hyprland, skipping registration"); + return false; + } + + const converted = HyprlandShortcutManager.convertToHyprlandFormat(hotkey); + if (!converted) { + debugLogger.log(`[HyprlandShortcut] Could not convert hotkey "${hotkey}" to Hyprland format`); + return false; + } + + try { + // First unregister any existing binding + if (this.currentBinding) { + await this.unregisterKeybinding(); + } + + const dbusCommand = `dbus-send --session --type=method_call --dest=${DBUS_SERVICE_NAME} ${DBUS_OBJECT_PATH} ${DBUS_INTERFACE}.Toggle`; + + // hyprctl keyword bind "MODS, key, exec, command" + const bindValue = `${converted.bindKey}, exec, ${dbusCommand}`; + + execFileSync("hyprctl", ["keyword", "bind", bindValue], { + stdio: "pipe", + timeout: 5000, + }); + + this.currentBinding = converted.bindKey; + this.isRegistered = true; + debugLogger.log( + `[HyprlandShortcut] Keybinding "${hotkey}" (${converted.bindKey}) registered successfully` + ); + return true; + } catch (err) { + debugLogger.log("[HyprlandShortcut] Failed to register keybinding:", err.message); + return false; + } + } + + /** + * Update the keybinding to a new hotkey. + */ + async updateKeybinding(hotkey) { + // Just unregister old and register new + return this.registerKeybinding(hotkey); + } + + /** + * Unregister the current keybinding from Hyprland. + */ + async unregisterKeybinding() { + if (!this.currentBinding) { + this.isRegistered = false; + return true; + } + + try { + execFileSync("hyprctl", ["keyword", "unbind", this.currentBinding], { + stdio: "pipe", + timeout: 5000, + }); + + debugLogger.log( + `[HyprlandShortcut] Keybinding "${this.currentBinding}" unregistered successfully` + ); + this.currentBinding = null; + this.isRegistered = false; + return true; + } catch (err) { + debugLogger.log("[HyprlandShortcut] Failed to unregister keybinding:", err.message); + // Even if unbind fails, clear state so we don't keep retrying + this.currentBinding = null; + this.isRegistered = false; + return false; + } + } + + /** + * Clean up D-Bus connection. + */ + close() { + if (this.bus) { + this.bus.disconnect(); + this.bus = null; + } + } +} + +module.exports = HyprlandShortcutManager; diff --git a/src/helpers/ipcHandlers.js b/src/helpers/ipcHandlers.js index 5f4a2b8f..36dba404 100644 --- a/src/helpers/ipcHandlers.js +++ b/src/helpers/ipcHandlers.js @@ -6,6 +6,7 @@ const crypto = require("crypto"); const AppUtils = require("../utils"); const debugLogger = require("./debugLogger"); const GnomeShortcutManager = require("./gnomeShortcut"); +const HyprlandShortcutManager = require("./hyprlandShortcut"); const AssemblyAiStreaming = require("./assemblyAiStreaming"); const { i18nMain, changeLanguage } = require("./i18nMain"); const DeepgramStreaming = require("./deepgramStreaming"); @@ -993,7 +994,10 @@ class IPCHandlers { await this.whisperManager.stopServer().catch(() => {}); return { success: true }; } catch (error) { - debugLogger.error("CUDA binary download failed", { error: error.message, stack: error.stack }); + debugLogger.error("CUDA binary download failed", { + error: error.message, + stack: error.stack, + }); return { success: false, error: error.message }; } }); @@ -1191,6 +1195,14 @@ class IPCHandlers { debugLogger.warn("[IPC] Failed to unregister GNOME keybinding:", err.message); }); } + + // On Hyprland Wayland, unregister the keybinding during capture + if (hotkeyManager.isUsingHyprland() && hotkeyManager.hyprlandManager) { + debugLogger.log("[IPC] Unregistering Hyprland keybinding for hotkey capture mode"); + await hotkeyManager.hyprlandManager.unregisterKeybinding().catch((err) => { + debugLogger.warn("[IPC] Failed to unregister Hyprland keybinding:", err.message); + }); + } } else { // Exiting capture mode - re-register globalShortcut if not already registered if (effectiveHotkey && !usesNativeListener(effectiveHotkey)) { @@ -1238,6 +1250,17 @@ class IPCHandlers { hotkeyManager.currentHotkey = effectiveHotkey; } } + + // On Hyprland Wayland, re-register the keybinding with the effective hotkey + if (hotkeyManager.isUsingHyprland() && hotkeyManager.hyprlandManager && effectiveHotkey) { + debugLogger.log( + `[IPC] Re-registering Hyprland keybinding "${effectiveHotkey}" after capture mode` + ); + const success = await hotkeyManager.hyprlandManager.registerKeybinding(effectiveHotkey); + if (success) { + hotkeyManager.currentHotkey = effectiveHotkey; + } + } } return { success: true }; @@ -1246,6 +1269,8 @@ class IPCHandlers { ipcMain.handle("get-hotkey-mode-info", async () => { return { isUsingGnome: this.windowManager.isUsingGnomeHotkeys(), + isUsingHyprland: this.windowManager.isUsingHyprlandHotkeys(), + isUsingNativeShortcut: this.windowManager.isUsingNativeShortcutHotkeys(), }; }); @@ -1791,7 +1816,10 @@ class IPCHandlers { return result; } catch (error) { - debugLogger.error("Vulkan binary download failed", { error: error.message, stack: error.stack }); + debugLogger.error("Vulkan binary download failed", { + error: error.message, + stack: error.stack, + }); return { success: false, error: error.message }; } }); diff --git a/src/helpers/windowManager.js b/src/helpers/windowManager.js index 0e4feab7..7db980d3 100644 --- a/src/helpers/windowManager.js +++ b/src/helpers/windowManager.js @@ -488,6 +488,14 @@ class WindowManager { return this.hotkeyManager.isUsingGnome(); } + isUsingHyprlandHotkeys() { + return this.hotkeyManager.isUsingHyprland(); + } + + isUsingNativeShortcutHotkeys() { + return this.hotkeyManager.isUsingNativeShortcut(); + } + async startWindowDrag() { return await this.dragManager.startWindowDrag(); } diff --git a/src/types/electron.ts b/src/types/electron.ts index 4a6bc71f..92630ded 100644 --- a/src/types/electron.ts +++ b/src/types/electron.ts @@ -634,7 +634,11 @@ declare global { enabled: boolean, newHotkey?: string | null ) => Promise<{ success: boolean }>; - getHotkeyModeInfo?: () => Promise<{ isUsingGnome: boolean }>; + getHotkeyModeInfo?: () => Promise<{ + isUsingGnome: boolean; + isUsingHyprland: boolean; + isUsingNativeShortcut: boolean; + }>; // Wayland paste diagnostics getYdotoolStatus?: () => Promise<{ From 091b30d9abaee099330395697057a82d9ceb1e4e Mon Sep 17 00:00:00 2001 From: Felipe Afonso Date: Wed, 11 Mar 2026 23:11:46 -0300 Subject: [PATCH 2/6] fix: wl-copy timing issue --- src/helpers/clipboard.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/helpers/clipboard.js b/src/helpers/clipboard.js index 624a924f..64342723 100644 --- a/src/helpers/clipboard.js +++ b/src/helpers/clipboard.js @@ -91,7 +91,7 @@ class ClipboardManager { _writeClipboardWayland(text, webContents) { if (this.commandExists("wl-copy")) { try { - const result = spawnSync("wl-copy", ["--", text], { timeout: 1 }); + const result = spawnSync("wl-copy", ["--", text], { timeout: 50 }); if (result.status === 0) { clipboard.writeText(text); return; From 9bfebc376e045538cb0d52b01e2b2123b6855a93 Mon Sep 17 00:00:00 2001 From: Felipe Afonso Date: Wed, 11 Mar 2026 23:21:28 -0300 Subject: [PATCH 3/6] chore: cleanup md file blank spaces these were added by claude opus after writing to the md file --- CLAUDE.md | 43 ------------------------------------------- 1 file changed, 43 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 71057129..de34f4b6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,6 @@ OpenWhispr is an Electron-based desktop dictation application that uses whisper. ## Architecture Overview ### Core Technologies - - **Frontend**: React 19, TypeScript, Tailwind CSS v4, Vite - **Desktop Framework**: Electron 36 with context isolation - **Database**: better-sqlite3 for local transcription history @@ -186,7 +185,6 @@ OpenWhispr is an Electron-based desktop dictation application that uses whisper. ### 1. FFmpeg Integration FFmpeg is bundled with the app and doesn't require system installation: - ```javascript // FFmpeg is unpacked from ASAR to app.asar.unpacked/node_modules/ffmpeg-static/ ``` @@ -205,7 +203,6 @@ FFmpeg is bundled with the app and doesn't require system installation: ### 3. Local Whisper Models (GGML format) Models stored in `~/.cache/openwhispr/whisper-models/`: - - tiny: ~75MB (fastest, lowest quality) - base: ~142MB (recommended balance) - small: ~466MB (better quality) @@ -231,7 +228,6 @@ CREATE TABLE transcriptions ( ### 5. Settings Storage Settings stored in localStorage with these keys: - - `whisperModel`: Selected Whisper model - `useLocalWhisper`: Boolean for local vs cloud - `openaiApiKey`: Encrypted API key @@ -246,14 +242,12 @@ Settings stored in localStorage with these keys: - `customDictionary`: JSON array of words/phrases for improved transcription accuracy Environment variables persisted to `.env` (via `saveAllKeysToEnvFile()`): - - `LOCAL_TRANSCRIPTION_PROVIDER`: Transcription engine (`nvidia` for Parakeet) - `PARAKEET_MODEL`: Selected Parakeet model name (e.g., `parakeet-tdt-0.6b-v3`) ### 6. Language Support 58 languages supported (see src/utils/languages.ts): - - Each language has a two-letter code and label - "auto" for automatic detection - Passed to whisper.cpp via -l parameter @@ -283,7 +277,6 @@ Environment variables persisted to `.env` (via `saveAllKeysToEnvFile()`): ### 8. Model Registry Architecture All AI model definitions are centralized in `src/models/modelRegistryData.json` as the single source of truth: - ```json { "cloudProviders": [...], // OpenAI, Anthropic, Gemini API models @@ -292,7 +285,6 @@ All AI model definitions are centralized in `src/models/modelRegistryData.json` ``` **Key files:** - - `src/models/modelRegistryData.json` - Single source of truth for all models - `src/models/ModelRegistry.ts` - TypeScript wrapper with helper methods - `src/config/aiProvidersConfig.ts` - Derives AI_MODES from registry @@ -300,7 +292,6 @@ All AI model definitions are centralized in `src/models/modelRegistryData.json` - `src/helpers/modelManagerBridge.js` - Handles local model downloads **Local model features:** - - Each model has `hfRepo` for direct HuggingFace download URLs - `promptTemplate` defines the chat format (ChatML, Llama, Mistral) - Download URLs constructed as: `{baseUrl}/{hfRepo}/resolve/main/{fileName}` @@ -308,7 +299,6 @@ All AI model definitions are centralized in `src/models/modelRegistryData.json` ### 9. API Integrations and Updates **OpenAI Responses API (September 2025)**: - - Migrated from Chat Completions to new Responses API - Endpoint: `https://api.openai.com/v1/responses` - Simplified request format with `input` array instead of `messages` @@ -317,20 +307,17 @@ All AI model definitions are centralized in `src/models/modelRegistryData.json` - No temperature parameter for newer models (GPT-5, o-series) **Anthropic Integration**: - - Routes through IPC handler to avoid CORS issues in renderer process - Uses main process for API calls with proper error handling - Model IDs use alias format (e.g., `claude-sonnet-4-6` not date-suffixed versions) **Gemini Integration**: - - Direct API calls from renderer process - Increased token limits for Gemini 3.1 Pro (2000 minimum) - Proper handling of thinking process in responses - Error handling for MAX_TOKENS finish reason **API Key Persistence**: - - All API keys now properly persist to `.env` file - Keys stored in environment variables and reloaded on app start - Centralized `saveAllKeysToEnvFile()` method ensures consistency @@ -340,7 +327,6 @@ All AI model definitions are centralized in `src/models/modelRegistryData.json` The app can open OS-level settings for microphone permissions, sound input selection, and accessibility: **IPC Handlers** (in `ipcHandlers.js`): - - `open-microphone-settings`: Opens microphone privacy settings - `open-sound-input-settings`: Opens sound/audio input device settings - `open-accessibility-settings`: Opens accessibility privacy settings (macOS only) @@ -353,7 +339,6 @@ The app can open OS-level settings for microphone permissions, sound input selec | Linux | Manual (no URL scheme) | Manual (e.g., pavucontrol) | N/A | **UI Component** (`MicPermissionWarning.tsx`): - - Shows platform-appropriate buttons and messages - Linux only shows "Open Sound Settings" (no separate privacy settings) - macOS/Windows show both sound and privacy buttons @@ -361,7 +346,6 @@ The app can open OS-level settings for microphone permissions, sound input selec ### 11. Debug Mode Enable with `--log-level=debug` or `OPENWHISPR_LOG_LEVEL=debug` (can be set in `.env`): - - Logs saved to platform-specific app data directory - Comprehensive logging of audio pipeline - FFmpeg path resolution details @@ -373,26 +357,22 @@ Enable with `--log-level=debug` or `OPENWHISPR_LOG_LEVEL=debug` (can be set in ` Native Windows support for true push-to-talk functionality using low-level keyboard hooks: **Architecture**: - - `resources/windows-key-listener.c`: Native C program using Windows `SetWindowsHookEx` for keyboard hooks - `src/helpers/windowsKeyManager.js`: Node.js wrapper that spawns and manages the native binary - Binary outputs `KEY_DOWN` and `KEY_UP` to stdout when target key is pressed/released **Compound Hotkey Support**: - - Parses hotkey strings like `CommandOrControl+Shift+F11` - Maps modifiers: `CommandOrControl`/`Ctrl` → VK_CONTROL, `Alt`/`Option` → VK_MENU, `Shift` → VK_SHIFT - Verifies all required modifiers are held before emitting key events **Binary Distribution**: - - Prebuilt binary downloaded from GitHub releases (`windows-key-listener-v*` tags) - Download script: `scripts/download-windows-key-listener.js` - CI workflow: `.github/workflows/build-windows-key-listener.yml` - Fallback to tap mode if binary unavailable **IPC Events**: - - `windows-key-listener:key-down`: Fired when hotkey pressed (start recording) - `windows-key-listener:key-up`: Fired when hotkey released (stop recording) @@ -401,21 +381,18 @@ Native Windows support for true push-to-talk functionality using low-level keybo Improve transcription accuracy for specific words, names, or technical terms: **How it works**: - - User adds words/phrases through Settings → Custom Dictionary - Words stored as JSON array in localStorage (`customDictionary` key) - On transcription, words are joined and passed as `prompt` parameter to Whisper - Works with both local whisper.cpp and cloud OpenAI Whisper API **Implementation**: - - `src/hooks/useSettings.ts`: Manages `customDictionary` state - `src/components/SettingsPage.tsx`: UI for adding/removing dictionary words - `src/helpers/audioManager.js`: Reads dictionary and adds to transcription options - `src/helpers/whisperServer.js`: Includes dictionary as `prompt` in API request **Whisper Prompt Parameter**: - - Whisper uses the prompt as context/hints for transcription - Words in the prompt are more likely to be recognized correctly - Useful for: uncommon names, technical jargon, brand names, domain-specific terms @@ -425,7 +402,6 @@ Improve transcription accuracy for specific words, names, or technical terms: On GNOME Wayland, Electron's `globalShortcut` API doesn't work due to Wayland's security model. OpenWhispr uses native GNOME shortcuts: **Architecture**: - 1. `main.js` enables `GlobalShortcutsPortal` feature flag for Wayland 2. `hotkeyManager.js` detects GNOME + Wayland and initializes `GnomeShortcutManager` 3. `gnomeShortcut.js` creates D-Bus service at `com.openwhispr.App` @@ -433,19 +409,16 @@ On GNOME Wayland, Electron's `globalShortcut` API doesn't work due to Wayland's 5. GNOME triggers `dbus-send` command which calls the D-Bus `Toggle()` method **Key Constants**: - - D-Bus service: `com.openwhispr.App` - D-Bus path: `/com/openwhispr/App` - gsettings path: `/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/openwhispr/` **IPC Integration**: - - `get-hotkey-mode-info`: Returns `{ isUsingGnome, isUsingHyprland, isUsingNativeShortcut }` to renderer - UI hides activation mode selector when `isUsingNativeShortcut` is true - Forces tap-to-talk mode (push-to-talk not supported) **Hotkey Format Conversion**: - - Electron format: `Alt+R`, `CommandOrControl+Shift+Space` - GNOME format: `r`, `space` - Backtick (`) → `grave` in GNOME keysym format @@ -455,7 +428,6 @@ On GNOME Wayland, Electron's `globalShortcut` API doesn't work due to Wayland's On Hyprland (wlroots Wayland compositor), Electron's `globalShortcut` API and the `GlobalShortcutsPortal` feature don't work reliably. OpenWhispr uses native Hyprland keybindings: **Architecture**: - 1. `main.js` enables `GlobalShortcutsPortal` feature flag for Wayland (fallback) 2. `hotkeyManager.js` detects Hyprland + Wayland and initializes `HyprlandShortcutManager` 3. `hyprlandShortcut.js` creates D-Bus service at `com.openwhispr.App` (same as GNOME) @@ -463,24 +435,20 @@ On Hyprland (wlroots Wayland compositor), Electron's `globalShortcut` API and th 5. Hyprland triggers `dbus-send` command which calls the D-Bus `Toggle()` method **Detection**: - - Primary: `HYPRLAND_INSTANCE_SIGNATURE` environment variable (set by Hyprland) - Fallback: `XDG_CURRENT_DESKTOP` contains "hyprland" **Hotkey Format Conversion**: - - Electron format: `Alt+R`, `CommandOrControl+Shift+Space` - Hyprland format: `ALT, R`, `CTRL SHIFT, space` - Modifier-only combos (e.g., `Control+Super`) → `CTRL, Super_L` **Bind/Unbind Commands**: - - Register: `hyprctl keyword bind "ALT, R, exec, dbus-send --session ..."` - Unregister: `hyprctl keyword unbind "ALT, R"` - Bindings are ephemeral (don't survive Hyprland restart) but re-registered on app startup **Limitations**: - - Push-to-talk not supported (Hyprland `bind` fires a single exec, not key-down/key-up) - Requires `hyprctl` on PATH (ships with Hyprland) @@ -489,25 +457,21 @@ On Hyprland (wlroots Wayland compositor), Electron's `globalShortcut` API and th Detects meetings via three independent sources, orchestrated by `MeetingDetectionEngine`: **Architecture**: - - `MeetingDetectionEngine` listens to events from `MeetingProcessDetector` and `AudioActivityDetector` - `GoogleCalendarManager` provides calendar context (imminent events, active meetings) - All three sources feed into a unified notification pipeline **Process Detection** (known meeting apps — Zoom, Teams, Webex, FaceTime): - - macOS: `systemPreferences.subscribeWorkspaceNotification` — zero CPU, instant detection - Windows/Linux: `processListCache` shared polling (30s interval, `ps-list` npm) **Microphone Detection** (unscheduled/browser meetings like Google Meet): - - macOS: `macos-mic-listener` binary — CoreAudio `kAudioDevicePropertyDeviceIsRunningSomewhere` property listeners with hot-plug support - Windows: `windows-mic-listener.exe` — WASAPI `IAudioSessionManager2` session monitoring, `--exclude-pid` for self-mic exclusion - Linux: `pactl subscribe` — PulseAudio source-output events - All platforms: Graceful fallback to polling if native binary/command unavailable **UX Rules**: - - During recording (tap-to-talk or push-to-talk): ALL notifications suppressed - After recording: 2.5s cooldown before showing queued notifications - Multiple signals coalesced: process > audio priority, one notification shown @@ -515,13 +479,11 @@ Detects meetings via three independent sources, orchestrated by `MeetingDetectio - Active calendar meeting recording: all detections suppressed **Binary Distribution**: - - macOS: Compiled from Swift source via `scripts/build-macos-mic-listener.js` during `compile:native` - Windows: Prebuilt binary downloaded via `scripts/download-windows-mic-listener.js` during `prebuild:win` - CI workflow: `.github/workflows/build-windows-mic-listener.yml` auto-builds on push to main **Calendar Sync Resilience**: - - 10s socket timeout on all Google Calendar API requests - Exponential backoff on consecutive failures: 2min → 4min → 8min → cap 30min - Reset to normal 2min interval on any successful sync @@ -537,7 +499,6 @@ All user-facing strings **must** use the i18n system. Never hardcode UI text in **Supported languages**: en, es, fr, de, pt, it, ru, zh-CN, zh-TW **How to use**: - ```tsx import { useTranslation } from "react-i18next"; @@ -547,7 +508,6 @@ const { t } = useTranslation(); ``` **Rules**: - 1. Every new UI string must have a translation key in `en/translation.json` and all other language files 2. Use `useTranslation()` hook in components and hooks 3. Keep `{{variable}}` interpolation syntax for dynamic values @@ -626,7 +586,6 @@ const { t } = useTranslation(); ### Platform-Specific Notes **macOS**: - - Requires accessibility permissions for clipboard (auto-paste) - Requires microphone permission (prompted by system) - Uses AppleScript for reliable pasting @@ -636,7 +595,6 @@ const { t } = useTranslation(); - System settings accessible via `x-apple.systempreferences:` URL scheme **Windows**: - - No special accessibility permissions needed - Microphone privacy settings at `ms-settings:privacy-microphone` - Sound settings at `ms-settings:sound` @@ -649,7 +607,6 @@ const { t } = useTranslation(); - Falls back to tap mode if unavailable **Linux**: - - Multiple package manager support - Standard XDG directories - AppImage for distribution From 93bcb30a1acee26175650d7d234ef82c8b8c37bf Mon Sep 17 00:00:00 2001 From: Alcahest Date: Thu, 12 Mar 2026 06:41:52 +0100 Subject: [PATCH 4/6] add hotkey validation and scope wl-copy timeout to Hyprland Changes are Hyprland-only except for the shared native shortcut logic. --- src/helpers/clipboard.js | 3 ++- src/helpers/hyprlandShortcut.js | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/helpers/clipboard.js b/src/helpers/clipboard.js index 64342723..43fc0340 100644 --- a/src/helpers/clipboard.js +++ b/src/helpers/clipboard.js @@ -91,7 +91,8 @@ class ClipboardManager { _writeClipboardWayland(text, webContents) { if (this.commandExists("wl-copy")) { try { - const result = spawnSync("wl-copy", ["--", text], { timeout: 50 }); + const isHyprland = !!process.env.HYPRLAND_INSTANCE_SIGNATURE; + const result = spawnSync("wl-copy", ["--", text], { timeout: isHyprland ? 50 : 1 }); if (result.status === 0) { clipboard.writeText(text); return; diff --git a/src/helpers/hyprlandShortcut.js b/src/helpers/hyprlandShortcut.js index e35f5d99..65374387 100644 --- a/src/helpers/hyprlandShortcut.js +++ b/src/helpers/hyprlandShortcut.js @@ -37,6 +37,11 @@ const ELECTRON_TO_HYPRLAND_KEY = { " ": "space", }; +// Valid Electron-format hotkey: modifiers joined by +, ending with an optional key +// Supports: letters, digits, function keys (F1-F24), navigation, special keys, and modifier-only combos +const VALID_HOTKEY_PATTERN = + /^(CommandOrControl|CmdOrCtrl|Control|Ctrl|Alt|Option|Shift|Super|Meta|Win|Command|Cmd)(\+(CommandOrControl|CmdOrCtrl|Control|Ctrl|Alt|Option|Shift|Super|Meta|Win|Command|Cmd))*(\+(F([1-9]|1[0-9]|2[0-4])|[A-Za-z0-9]|Space|Escape|Tab|Backspace|Delete|Insert|Home|End|PageUp|PageDown|ArrowUp|ArrowDown|ArrowLeft|ArrowRight|Enter|PrintScreen|ScrollLock|Pause|Backquote|`))?$/i; + let dbus = null; function getDBus() { @@ -142,6 +147,13 @@ class HyprlandShortcutManager { return OpenWhisprInterface; } + static isValidHotkey(hotkey) { + if (!hotkey || typeof hotkey !== "string") { + return false; + } + return VALID_HOTKEY_PATTERN.test(hotkey); + } + /** * Convert an Electron-format hotkey string to Hyprland bind format. * @@ -229,6 +241,11 @@ class HyprlandShortcutManager { return false; } + if (!HyprlandShortcutManager.isValidHotkey(hotkey)) { + debugLogger.log(`[HyprlandShortcut] Invalid hotkey format: "${hotkey}"`); + return false; + } + const converted = HyprlandShortcutManager.convertToHyprlandFormat(hotkey); if (!converted) { debugLogger.log(`[HyprlandShortcut] Could not convert hotkey "${hotkey}" to Hyprland format`); From 6c5d0c6b8f10d8aea6a7ceebb7899fd9aabdfa82 Mon Sep 17 00:00:00 2001 From: Alcahest Date: Thu, 12 Mar 2026 06:55:00 +0100 Subject: [PATCH 5/6] fix hotkey validation to accept standalone keys --- src/helpers/hyprlandShortcut.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/helpers/hyprlandShortcut.js b/src/helpers/hyprlandShortcut.js index 65374387..90f95d9b 100644 --- a/src/helpers/hyprlandShortcut.js +++ b/src/helpers/hyprlandShortcut.js @@ -37,10 +37,10 @@ const ELECTRON_TO_HYPRLAND_KEY = { " ": "space", }; -// Valid Electron-format hotkey: modifiers joined by +, ending with an optional key -// Supports: letters, digits, function keys (F1-F24), navigation, special keys, and modifier-only combos +// Valid Electron-format hotkey: optional modifiers joined by +, ending with a key +// Supports: standalone keys (F4, Space), modifier+key combos, and modifier-only combos (Control+Super) const VALID_HOTKEY_PATTERN = - /^(CommandOrControl|CmdOrCtrl|Control|Ctrl|Alt|Option|Shift|Super|Meta|Win|Command|Cmd)(\+(CommandOrControl|CmdOrCtrl|Control|Ctrl|Alt|Option|Shift|Super|Meta|Win|Command|Cmd))*(\+(F([1-9]|1[0-9]|2[0-4])|[A-Za-z0-9]|Space|Escape|Tab|Backspace|Delete|Insert|Home|End|PageUp|PageDown|ArrowUp|ArrowDown|ArrowLeft|ArrowRight|Enter|PrintScreen|ScrollLock|Pause|Backquote|`))?$/i; + /^((CommandOrControl|CmdOrCtrl|Control|Ctrl|Alt|Option|Shift|Super|Meta|Win|Command|Cmd)(\+(CommandOrControl|CmdOrCtrl|Control|Ctrl|Alt|Option|Shift|Super|Meta|Win|Command|Cmd))*(\+)?)?(F([1-9]|1[0-9]|2[0-4])|[A-Za-z0-9]|Space|Escape|Tab|Backspace|Delete|Insert|Home|End|PageUp|PageDown|ArrowUp|ArrowDown|ArrowLeft|ArrowRight|Enter|PrintScreen|ScrollLock|Pause|Backquote|`)?$/i; let dbus = null; From 5cf8a2cd507226676a2bdf686280fd89811dba3d Mon Sep 17 00:00:00 2001 From: Alcahest Date: Thu, 12 Mar 2026 07:01:15 +0100 Subject: [PATCH 6/6] add diagnostic logging for Hyprland detection --- src/helpers/hotkeyManager.js | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/helpers/hotkeyManager.js b/src/helpers/hotkeyManager.js index 99339637..4c61957c 100644 --- a/src/helpers/hotkeyManager.js +++ b/src/helpers/hotkeyManager.js @@ -353,11 +353,24 @@ class HotkeyManager { } async initializeHyprlandShortcuts(callback) { - if (process.platform !== "linux" || !HyprlandShortcutManager.isWayland()) { + const isLinux = process.platform === "linux"; + const isWayland = HyprlandShortcutManager.isWayland(); + const isHyprland = HyprlandShortcutManager.isHyprland(); + + debugLogger.log("[HotkeyManager] Hyprland detection", { + isLinux, + isWayland, + isHyprland, + XDG_SESSION_TYPE: process.env.XDG_SESSION_TYPE || "(unset)", + HYPRLAND_INSTANCE_SIGNATURE: process.env.HYPRLAND_INSTANCE_SIGNATURE ? "present" : "(unset)", + XDG_CURRENT_DESKTOP: process.env.XDG_CURRENT_DESKTOP || "(unset)", + }); + + if (!isLinux || !isWayland) { return false; } - if (HyprlandShortcutManager.isHyprland()) { + if (isHyprland) { if (!HyprlandShortcutManager.isHyprctlAvailable()) { debugLogger.log("[HotkeyManager] Hyprland detected but hyprctl not available"); return false; @@ -367,6 +380,7 @@ class HotkeyManager { this.hyprlandManager = new HyprlandShortcutManager(); const dbusOk = await this.hyprlandManager.initDBusService(callback); + debugLogger.log("[HotkeyManager] Hyprland D-Bus init result:", dbusOk); if (dbusOk) { this.useHyprland = true; this.hotkeyCallback = callback;