Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 40 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,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
Expand Down Expand Up @@ -271,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
Expand Down Expand Up @@ -409,16 +414,45 @@ On GNOME Wayland, Electron's `globalShortcut` API doesn't work due to Wayland's
- 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: `<Alt>r`, `<Control><Shift>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`:

Expand Down Expand Up @@ -500,7 +534,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)
Expand Down
10 changes: 5 additions & 5 deletions src/components/OnboardingFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
const [skipAuth, setSkipAuth] = useState(false);
const [pendingVerificationEmail, setPendingVerificationEmail] = useState<string | null>(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();
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -652,7 +652,7 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
</div>

{/* Mode section - inline with hotkey */}
{!isUsingGnomeHotkeys && (
{!isUsingNativeShortcut && (
<div className="p-4 flex items-center justify-between gap-4">
<div className="flex-1 min-w-0">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Expand Down Expand Up @@ -680,7 +680,7 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
{t("onboarding.activation.test")}
</span>
<span className="text-xs text-muted-foreground/60">
{activationMode === "tap" || isUsingGnomeHotkeys
{activationMode === "tap" || isUsingNativeShortcut
? t("onboarding.activation.hotkeyToStartStop", { hotkey: readableHotkey })
: t("onboarding.activation.holdHotkey", { hotkey: readableHotkey })}
</span>
Expand Down
8 changes: 4 additions & 4 deletions src/components/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -859,7 +859,7 @@ export default function SettingsPage({ activeSection = "general" }: SettingsPage
[]
);

const [isUsingGnomeHotkeys, setIsUsingGnomeHotkeys] = useState(false);
const [isUsingNativeShortcut, setIsUsingNativeShortcut] = useState(false);

const platform = getCachedPlatform();

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -2384,7 +2384,7 @@ EOF`,
)}
</SettingsPanelRow>

{!isUsingGnomeHotkeys && (
{!isUsingNativeShortcut && (
<SettingsPanelRow>
<p className="text-xs font-medium text-muted-foreground/80 mb-2">
{t("settingsPage.general.hotkey.activationMode")}
Expand Down
3 changes: 2 additions & 1 deletion src/helpers/clipboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,8 @@ class ClipboardManager {
_writeClipboardWayland(text, webContents) {
if (this.commandExists("wl-copy")) {
try {
const result = spawnSync("wl-copy", ["--", text], { timeout: 1 });
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;
Expand Down
124 changes: 124 additions & 0 deletions src/helpers/hotkeyManager.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -349,6 +352,50 @@ class HotkeyManager {
return false;
}

async initializeHyprlandShortcuts(callback) {
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 (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);
debugLogger.log("[HotkeyManager] Hyprland D-Bus init result:", dbusOk);
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");
Expand Down Expand Up @@ -392,6 +439,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") {
Expand Down Expand Up @@ -560,6 +646,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);
Expand Down Expand Up @@ -598,6 +706,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) {
Expand All @@ -612,6 +728,14 @@ class HotkeyManager {
return this.useGnome;
}

isUsingHyprland() {
return this.useHyprland;
}

isUsingNativeShortcut() {
return this.useGnome || this.useHyprland;
}

isHotkeyRegistered(hotkey) {
return globalShortcut.isRegistered(hotkey);
}
Expand Down
Loading
Loading