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
31 changes: 23 additions & 8 deletions main.js
Original file line number Diff line number Diff line change
Expand Up @@ -574,16 +574,31 @@ async function startApp() {

const savedAgentKey = environmentManager.getAgentKey?.() || "";
if (savedAgentKey) {
hotkeyManager.registerSlot("agent", savedAgentKey, agentHotkeyCallback);
const result = await hotkeyManager.registerSlot("agent", savedAgentKey, agentHotkeyCallback);
if (!result.success) {
debugLogger.warn("Failed to restore agent hotkey", { hotkey: savedAgentKey }, "hotkey");
}
}

ipcMain.on("agent-hotkey-changed", (_event, hotkey) => {
if (hotkey) {
hotkeyManager.registerSlot("agent", hotkey, agentHotkeyCallback);
environmentManager.saveAgentKey(hotkey);
} else {
hotkeyManager.unregisterSlot("agent");
environmentManager.saveAgentKey("");
ipcMain.on("agent-hotkey-changed", async (_event, hotkey) => {
try {
if (hotkey) {
const result = await hotkeyManager.registerSlot("agent", hotkey, agentHotkeyCallback);
if (result.success) {
environmentManager.saveAgentKey(hotkey);
} else {
debugLogger.warn("Failed to update agent hotkey", { hotkey }, "hotkey");
}
} else {
hotkeyManager.unregisterSlot("agent");
environmentManager.saveAgentKey("");
}
} catch (error) {
debugLogger.warn(
"Failed to update agent hotkey",
{ error: error instanceof Error ? error.message : String(error), hotkey },
"hotkey"
);
}
});

Expand Down
1 change: 1 addition & 0 deletions preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,7 @@ contextBridge.exposeInMainWorld("electronAPI", {
setAutoStartEnabled: (enabled) => ipcRenderer.invoke("set-auto-start-enabled", enabled),

// Agent mode
updateAgentHotkey: (hotkey) => ipcRenderer.invoke("update-agent-hotkey", hotkey),
notifyAgentHotkeyChanged: (hotkey) => ipcRenderer.send("agent-hotkey-changed", hotkey),
getAgentKey: () => ipcRenderer.invoke("get-agent-key"),
saveAgentKey: (key) => ipcRenderer.invoke("save-agent-key", key),
Expand Down
148 changes: 108 additions & 40 deletions src/helpers/gnomeShortcut.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,18 @@ const DBUS_SERVICE_NAME = "com.openwhispr.App";
const DBUS_OBJECT_PATH = "/com/openwhispr/App";
const DBUS_INTERFACE = "com.openwhispr.App";

const KEYBINDING_PATH =
"/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/openwhispr/";
// Per-slot gsettings paths and display names
const SLOT_CONFIG = {
dictation: {
path: "/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/openwhispr/",
name: "OpenWhispr Toggle",
},
agent: {
path: "/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/openwhispr-agent/",
name: "OpenWhispr Agent",
},
};

const KEYBINDING_SCHEMA = "org.gnome.settings-daemon.plugins.media-keys.custom-keybinding";

// Valid pattern for GNOME shortcut format (e.g., "<Alt>r", "<Control><Shift>space")
Expand Down Expand Up @@ -40,11 +50,21 @@ function getDBus() {
}
}

function getSlotConfig(slotName) {
const config = SLOT_CONFIG[slotName];
if (!config) {
throw new Error(`[GnomeShortcut] Unknown slot: "${slotName}"`);
}
return config;
}

class GnomeShortcutManager {
constructor() {
this.bus = null;
this.callback = null;
this.isRegistered = false;
this.dictationCallback = null;
this.agentCallback = null;
// Track which slots have been registered in gsettings
this.registeredSlots = new Set();
}

static isGnome() {
Expand All @@ -60,8 +80,21 @@ class GnomeShortcutManager {
return process.env.XDG_SESSION_TYPE === "wayland";
}

async initDBusService(callback) {
this.callback = callback;
/**
* Set or update the agent callback after initial D-Bus service initialisation.
* This supports the case where the dictation hotkey is set up first and the
* agent callback is only available later (after agent window creation).
*/
setAgentCallback(callback) {
this.agentCallback = callback;
if (this._ifaceRef) {
this._ifaceRef._agentCallback = callback;
}
debugLogger.log("[GnomeShortcut] Agent callback registered");
}

async initDBusService(dictationCallback) {
this.dictationCallback = dictationCallback;

const dbusModule = getDBus();
if (!dbusModule) {
Expand All @@ -72,8 +105,10 @@ class GnomeShortcutManager {
this.bus = dbusModule.sessionBus();
await this.bus.requestName(DBUS_SERVICE_NAME, 0);

const InterfaceClass = this._createInterfaceClass(dbusModule, callback);
const iface = new InterfaceClass();
const InterfaceClass = this._createInterfaceClass(dbusModule);
const iface = new InterfaceClass(dictationCallback, this.agentCallback);
// Keep a reference so setAgentCallback() can update it later
this._ifaceRef = iface;
this.bus.export(DBUS_OBJECT_PATH, iface);

debugLogger.log("[GnomeShortcut] D-Bus service initialized successfully");
Expand All @@ -88,23 +123,31 @@ class GnomeShortcutManager {
}
}

_createInterfaceClass(dbusModule, callback) {
_createInterfaceClass(dbusModule) {
class OpenWhisprInterface extends dbusModule.interface.Interface {
constructor() {
constructor(dictationCallback, agentCallback) {
super(DBUS_INTERFACE);
this._callback = callback;
this._dictationCallback = dictationCallback;
this._agentCallback = agentCallback || null;
}

Toggle() {
if (this._callback) {
this._callback();
if (this._dictationCallback) {
this._dictationCallback();
}
}

ToggleAgent() {
if (this._agentCallback) {
this._agentCallback();
}
}
}

OpenWhisprInterface.configureMembers({
methods: {
Toggle: { inSignature: "", outSignature: "" },
ToggleAgent: { inSignature: "", outSignature: "" },
},
});

Expand All @@ -118,41 +161,47 @@ class GnomeShortcutManager {
return VALID_SHORTCUT_PATTERN.test(shortcut);
}

async registerKeybinding(shortcut = "<Alt>r") {
async registerKeybinding(shortcut = "<Alt>r", slotName = "dictation") {
if (!GnomeShortcutManager.isGnome()) {
debugLogger.log("[GnomeShortcut] Not running on GNOME, skipping registration");
return false;
}

if (!GnomeShortcutManager.isValidShortcut(shortcut)) {
debugLogger.log(`[GnomeShortcut] Invalid shortcut format: "${shortcut}"`);
debugLogger.log(
`[GnomeShortcut] Invalid shortcut format: "${shortcut}" for slot "${slotName}"`
);
return false;
}

const { path: keybindingPath, name: keybindingName } = getSlotConfig(slotName);

// The dbus-send command for agent uses ToggleAgent, dictation uses Toggle
const dbusMethod = slotName === "agent" ? "ToggleAgent" : "Toggle";
const command = `dbus-send --session --type=method_call --dest=${DBUS_SERVICE_NAME} ${DBUS_OBJECT_PATH} ${DBUS_INTERFACE}.${dbusMethod}`;

try {
const existing = this.getExistingKeybindings();
const alreadyRegistered = existing.includes(KEYBINDING_PATH);

const command = `dbus-send --session --type=method_call --dest=${DBUS_SERVICE_NAME} ${DBUS_OBJECT_PATH} ${DBUS_INTERFACE}.Toggle`;
const alreadyRegistered = existing.includes(keybindingPath);

execFileSync(
"gsettings",
["set", `${KEYBINDING_SCHEMA}:${KEYBINDING_PATH}`, "name", "OpenWhispr Toggle"],
["set", `${KEYBINDING_SCHEMA}:${keybindingPath}`, "name", keybindingName],
{ stdio: "pipe" }
);
execFileSync(
"gsettings",
["set", `${KEYBINDING_SCHEMA}:${KEYBINDING_PATH}`, "binding", shortcut],
["set", `${KEYBINDING_SCHEMA}:${keybindingPath}`, "binding", shortcut],
{ stdio: "pipe" }
);
execFileSync(
"gsettings",
["set", `${KEYBINDING_SCHEMA}:${KEYBINDING_PATH}`, "command", command],
["set", `${KEYBINDING_SCHEMA}:${keybindingPath}`, "command", command],
{ stdio: "pipe" }
);

if (!alreadyRegistered) {
const newBindings = [...existing, KEYBINDING_PATH];
const newBindings = [...existing, keybindingPath];
const bindingsStr = "['" + newBindings.join("', '") + "']";
execFileSync(
"gsettings",
Expand All @@ -166,43 +215,57 @@ class GnomeShortcutManager {
);
}

this.isRegistered = true;
debugLogger.log(`[GnomeShortcut] Keybinding "${shortcut}" registered successfully`);
this.registeredSlots.add(slotName);
debugLogger.log(
`[GnomeShortcut] Keybinding "${shortcut}" registered for slot "${slotName}" successfully`
);
return true;
} catch (err) {
debugLogger.log("[GnomeShortcut] Failed to register keybinding:", err.message);
debugLogger.log(
`[GnomeShortcut] Failed to register keybinding for slot "${slotName}":`,
err.message
);
return false;
}
}

async updateKeybinding(shortcut) {
if (!this.isRegistered) {
return this.registerKeybinding(shortcut);
async updateKeybinding(shortcut, slotName = "dictation") {
if (!this.registeredSlots.has(slotName)) {
return this.registerKeybinding(shortcut, slotName);
}

if (!GnomeShortcutManager.isValidShortcut(shortcut)) {
debugLogger.log(`[GnomeShortcut] Invalid shortcut format for update: "${shortcut}"`);
debugLogger.log(
`[GnomeShortcut] Invalid shortcut format for update: "${shortcut}" (slot "${slotName}")`
);
return false;
}

const { path: keybindingPath } = getSlotConfig(slotName);

try {
execFileSync(
"gsettings",
["set", `${KEYBINDING_SCHEMA}:${KEYBINDING_PATH}`, "binding", shortcut],
["set", `${KEYBINDING_SCHEMA}:${keybindingPath}`, "binding", shortcut],
{ stdio: "pipe" }
);
debugLogger.log(`[GnomeShortcut] Keybinding updated to "${shortcut}"`);
debugLogger.log(`[GnomeShortcut] Keybinding updated to "${shortcut}" for slot "${slotName}"`);
return true;
} catch (err) {
debugLogger.log("[GnomeShortcut] Failed to update keybinding:", err.message);
debugLogger.log(
`[GnomeShortcut] Failed to update keybinding for slot "${slotName}":`,
err.message
);
return false;
}
}

async unregisterKeybinding() {
async unregisterKeybinding(slotName = "dictation") {
const { path: keybindingPath } = getSlotConfig(slotName);

try {
const existing = this.getExistingKeybindings();
const filtered = existing.filter((p) => p !== KEYBINDING_PATH);
const filtered = existing.filter((p) => p !== keybindingPath);

if (filtered.length === 0) {
execFileSync(
Expand All @@ -224,21 +287,26 @@ class GnomeShortcutManager {
);
}

execFileSync("gsettings", ["reset", `${KEYBINDING_SCHEMA}:${KEYBINDING_PATH}`, "name"], {
execFileSync("gsettings", ["reset", `${KEYBINDING_SCHEMA}:${keybindingPath}`, "name"], {
stdio: "pipe",
});
execFileSync("gsettings", ["reset", `${KEYBINDING_SCHEMA}:${KEYBINDING_PATH}`, "binding"], {
execFileSync("gsettings", ["reset", `${KEYBINDING_SCHEMA}:${keybindingPath}`, "binding"], {
stdio: "pipe",
});
execFileSync("gsettings", ["reset", `${KEYBINDING_SCHEMA}:${KEYBINDING_PATH}`, "command"], {
execFileSync("gsettings", ["reset", `${KEYBINDING_SCHEMA}:${keybindingPath}`, "command"], {
stdio: "pipe",
});

this.isRegistered = false;
debugLogger.log("[GnomeShortcut] Keybinding unregistered successfully");
this.registeredSlots.delete(slotName);
debugLogger.log(
`[GnomeShortcut] Keybinding unregistered for slot "${slotName}" successfully`
);
return true;
} catch (err) {
debugLogger.log("[GnomeShortcut] Failed to unregister keybinding:", err.message);
debugLogger.log(
`[GnomeShortcut] Failed to unregister keybinding for slot "${slotName}":`,
err.message
);
return false;
}
}
Expand Down
Loading