Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
12 changes: 10 additions & 2 deletions electron-builder.json
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,11 @@
"--deb-suggests",
"wtype",
"--deb-recommends",
"wl-clipboard"
"wl-clipboard",
"--deb-recommends",
"xclip",
"--deb-suggests",
"xsel"
]
},
"rpm": {
Expand All @@ -190,7 +194,11 @@
"--rpm-tag",
"Suggests: wtype",
"--rpm-tag",
"Recommends: wl-clipboard"
"Recommends: wl-clipboard",
"--rpm-tag",
"Recommends: xclip",
"--rpm-tag",
"Suggests: xsel"
]
},
"nsis": {
Expand Down
7 changes: 3 additions & 4 deletions main.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,9 @@ if (process.platform === "win32") {
}

// Enable native Wayland support: Ozone platform for native rendering.
// KDE is forced to XWayland because globalShortcut doesn't work on native
// Wayland without the GlobalShortcuts portal, and the portal causes unwanted
// permission dialogs with broken hotkey registration.
// GNOME and Hyprland use their own D-Bus shortcut managers so they're fine.
// KDE uses XWayland for reliable clipboard access (Wayland restricts clipboard
// to focused windows), with KGlobalAccel D-Bus for global shortcuts.
// GNOME and Hyprland run native Wayland with their own D-Bus shortcut managers.
if (process.platform === "linux" && process.env.XDG_SESSION_TYPE === "wayland") {
const desktop = (process.env.XDG_CURRENT_DESKTOP || "").toLowerCase();
if (desktop.includes("kde")) {
Expand Down
25 changes: 25 additions & 0 deletions src/components/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -794,6 +794,9 @@ export default function SettingsPage({ activeSection = "general" }: SettingsPage
hasUdevRule: boolean;
hasGroup: boolean;
allGood: boolean;
isKde?: boolean;
hasXclip?: boolean;
hasXsel?: boolean;
} | null>(null);
const [ydotoolGuideKey, setYdotoolGuideKey] = useState<string | null>(null);

Expand Down Expand Up @@ -2605,6 +2608,28 @@ EOF`,
},
];

if (ydotoolStatus.isKde) {
checks.push({
key: "hasXclip",
label: "xclip",
ok: ydotoolStatus.hasXclip || ydotoolStatus.hasXsel || false,
desc: t("settingsPage.general.waylandPaste.xclipDesc", {
defaultValue: "Clipboard tool for KDE Wayland paste (xclip or xsel)",
}),
guide: [
{
title: t("settingsPage.general.waylandPaste.guide.xclip.step1Title", {
defaultValue: "Install xclip",
}),
cmds: [
{ cmd: "sudo dnf install xclip # Fedora" },
{ cmd: "sudo apt install xclip # Debian/Ubuntu" },
],
},
],
});
}

const allOk = checks.every((c) => c.ok);
const activeGuide = checks.find((c) => c.key === ydotoolGuideKey);

Expand Down
43 changes: 39 additions & 4 deletions src/helpers/clipboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,40 @@ class ClipboardManager {
}

_writeClipboardWayland(text, webContents) {
const { isKde } = getLinuxSessionInfo();

// On KDE with XWayland, write to X11 clipboard directly because
// wl-copy targets the Wayland clipboard which is desynced from X11
if (isKde) {
if (this.commandExists("xclip")) {
try {
const result = spawnSync("xclip", ["-selection", "clipboard"], {
input: text,
timeout: 200,
});
if (result.status === 0) {
clipboard.writeText(text);
return;
}
} catch {}
}
if (this.commandExists("xsel")) {
try {
const result = spawnSync("xsel", ["--clipboard", "--input"], {
input: text,
timeout: 200,
});
if (result.status === 0) {
clipboard.writeText(text);
return;
}
} catch {}
}
// Last resort: Electron's clipboard.writeText should work on XWayland
clipboard.writeText(text);
return;
}

if (this.commandExists("wl-copy")) {
try {
const isHyprland = !!process.env.HYPRLAND_INSTANCE_SIGNATURE;
Expand Down Expand Up @@ -1112,11 +1146,12 @@ class ClipboardManager {
});

if (isWayland) {
// On KDE Wayland, uinput works reliably for all apps (including
// Chromium/Electron which ignore RemoteDesktop portal keystrokes).
// On KDE with XWayland (ozone-platform-hint: x11), portal paste works
// because clipboard and input are both on X11. uinput causes a clipboard
// desync (X11 clipboard vs Wayland input) so portal is preferred.
// On GNOME, Mutter doesn't reliably route uinput to native Wayland
// windows (issue #292), so portal is tried first there.
const preferUinput = isKde;
// windows (issue #292), so portal is tried first there too.
const preferUinput = false;

const tryUinputPaste = async () => {
const args = ["--uinput"];
Expand Down
128 changes: 126 additions & 2 deletions src/helpers/hotkeyManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const { globalShortcut } = require("electron");
const debugLogger = require("./debugLogger");
const GnomeShortcutManager = require("./gnomeShortcut");
const HyprlandShortcutManager = require("./hyprlandShortcut");
const KDEShortcutManager = require("./kdeShortcut");
const { i18nMain } = require("./i18nMain");

// Delay to ensure localStorage is accessible after window load
Expand Down Expand Up @@ -68,6 +69,8 @@ class HotkeyManager {
this.useGnome = false;
this.hyprlandManager = null;
this.useHyprland = false;
this.kdeManager = null;
this.useKDE = false;
}

// Backward-compatible property accessors
Expand Down Expand Up @@ -185,6 +188,35 @@ class HotkeyManager {
return { success: true, hotkey };
}

// On KDE Wayland, route all slots through KGlobalAccel D-Bus
if (this.useKDE && this.kdeManager) {
if (slotName === "agent") {
this.kdeManager.setAgentCallback(callback);
}

const success = await this.kdeManager.registerKeybinding(hotkey, slotName, callback);
if (!success) {
debugLogger.log(
`[HotkeyManager] KDE keybinding registration failed for slot "${slotName}" ("${hotkey}")`
);
return {
success: false,
error: `Failed to register KDE hotkey "${hotkey}" for ${slotName}`,
};
}

const slot = this.slots.get(slotName) || { hotkey: null, callback: null, accelerator: null };
slot.hotkey = hotkey;
slot.callback = callback;
slot.accelerator = null;
this.slots.set(slotName, slot);

debugLogger.log(
`[HotkeyManager] KDE slot "${slotName}" set to "${hotkey}"`
);
return { success: true, hotkey };
}

const result = this.setupShortcuts(hotkey, callback, slotName);
if (result.success) {
const slot = this.slots.get(slotName) || {};
Expand All @@ -198,6 +230,19 @@ class HotkeyManager {
const slot = this.slots.get(slotName);
if (!slot || !slot.hotkey) return;

// On KDE Wayland, all slots are managed via KGlobalAccel
if (this.useKDE && this.kdeManager) {
this.kdeManager.unregisterKeybinding(slotName).catch((err) => {
debugLogger.warn(
`[HotkeyManager] Error unregistering KDE keybinding for slot "${slotName}":`,
err.message
);
});
slot.hotkey = null;
slot.accelerator = null;
return;
}

// On GNOME Wayland, non-dictation slots are managed via gsettings, not globalShortcut
if (this.useGnome && this.gnomeManager && slotName !== "dictation") {
this.gnomeManager.unregisterKeybinding(slotName).catch((err) => {
Expand Down Expand Up @@ -437,6 +482,29 @@ class HotkeyManager {
return false;
}

async initializeKDEShortcuts(callback) {
if (process.platform !== "linux" || !KDEShortcutManager.isWayland() || !KDEShortcutManager.isKDE()) {
return false;
}

try {
this.kdeManager = new KDEShortcutManager();
const ok = await this.kdeManager.init();
if (ok) {
this.useKDE = true;
this.hotkeyCallback = callback;
debugLogger.log("[HotkeyManager] KDE Wayland shortcuts initialized via KGlobalAccel D-Bus");
return true;
}
} catch (err) {
debugLogger.log("[HotkeyManager] KDE shortcut init failed:", err.message);
this.kdeManager = null;
this.useKDE = false;
}

return false;
}

async initializeHyprlandShortcuts(callback) {
const isLinux = process.platform === "linux";
const isWayland = HyprlandShortcutManager.isWayland();
Expand Down Expand Up @@ -525,7 +593,45 @@ class HotkeyManager {
return;
}

// Try Hyprland native shortcuts if GNOME path was not applicable
// Try KDE native shortcuts via KGlobalAccel
const kdeOk = await this.initializeKDEShortcuts(callback);

if (kdeOk) {
const registerKDEHotkey = async () => {
try {
const savedHotkey = await mainWindow.webContents.executeJavaScript(`
localStorage.getItem("dictationKey") || ""
`);
const hotkey = savedHotkey && savedHotkey.trim() !== "" ? savedHotkey : "Control+Super";
const success = await this.kdeManager.registerKeybinding(hotkey, "dictation", callback);
if (success) {
this.currentHotkey = hotkey;
debugLogger.log(
`[HotkeyManager] KDE hotkey "${hotkey}" registered successfully`
);
} else {
debugLogger.log(
"[HotkeyManager] KDE keybinding failed, falling back to globalShortcut"
);
this.useKDE = false;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both here and in the catch block below, when KDE registration fails you set useKDE = false and fall back to globalShortcut, but kdeManager is still alive. Should probably also do this.kdeManager.close() and this.kdeManager = null before falling back, otherwise you have a half-initialized manager hanging around.

this.loadSavedHotkeyOrDefault(mainWindow, callback);
}
} catch (err) {
debugLogger.log(
"[HotkeyManager] KDE keybinding failed, falling back to globalShortcut:",
err.message
);
this.useKDE = false;
this.loadSavedHotkeyOrDefault(mainWindow, callback);
}
};

setTimeout(registerKDEHotkey, HOTKEY_REGISTRATION_DELAY_MS);
this.isInitialized = true;
return;
}

// Try Hyprland native shortcuts if GNOME/KDE paths were not applicable
const hyprlandOk = await this.initializeHyprlandShortcuts(callback);

if (hyprlandOk) {
Expand Down Expand Up @@ -796,6 +902,20 @@ class HotkeyManager {
this.gnomeManager = null;
this.useGnome = false;
}
if (this.kdeManager) {
const kdeSlots = [...this.kdeManager.registeredSlots];
for (const slotName of kdeSlots) {
this.kdeManager.unregisterKeybinding(slotName).catch((err) => {
debugLogger.warn(
`[HotkeyManager] Error unregistering KDE keybinding for slot "${slotName}":`,
err.message
);
});
}
this.kdeManager.close();
this.kdeManager = null;
this.useKDE = false;
}
if (this.hyprlandManager) {
this.hyprlandManager.unregisterKeybinding().catch((err) => {
debugLogger.warn("[HotkeyManager] Error unregistering Hyprland keybinding:", err.message);
Expand All @@ -822,8 +942,12 @@ class HotkeyManager {
return this.useHyprland;
}

isUsingKDE() {
return this.useKDE;
}

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

isHotkeyRegistered(hotkey) {
Expand Down
10 changes: 9 additions & 1 deletion src/helpers/ipcHandlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -1384,6 +1384,7 @@ class IPCHandlers {
return {
isUsingGnome: this.windowManager.isUsingGnomeHotkeys(),
isUsingHyprland: this.windowManager.isUsingHyprlandHotkeys(),
isUsingKDE: this.windowManager.isUsingKDEHotkeys?.() || false,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ?. is inconsistent with the lines right above it. isUsingGnomeHotkeys() and isUsingHyprlandHotkeys() don't use optional chaining. Since isUsingKDEHotkeys was added to WindowManager in this same PR, it'll always exist.

isUsingNativeShortcut: this.windowManager.isUsingNativeShortcutHotkeys(),
};
});
Expand Down Expand Up @@ -3354,7 +3355,14 @@ class IPCHandlers {

ipcMain.handle("get-ydotool-status", () => {
const { getYdotoolStatus } = require("./ensureYdotool");
return getYdotoolStatus();
const status = getYdotoolStatus();
const { execFileSync } = require("child_process");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this require is inside the IPC handler so it runs on every get-ydotool-status call. Would be cleaner to hoist it to the top of the file with the other requires.

const isKde = (process.env.XDG_CURRENT_DESKTOP || "").toLowerCase().includes("kde");
let hasXclip = false;
let hasXsel = false;
try { execFileSync("which", ["xclip"], { timeout: 1000 }); hasXclip = true; } catch {}
try { execFileSync("which", ["xsel"], { timeout: 1000 }); hasXsel = true; } catch {}
return { ...status, isKde, hasXclip, hasXsel };
});

ipcMain.handle("get-debug-state", async () => {
Expand Down
Loading
Loading