From a5753c6e1ab72b33e20a944f1672794ec20c69ed Mon Sep 17 00:00:00 2001 From: Fritz Date: Wed, 27 May 2026 16:26:12 +1000 Subject: [PATCH 1/4] fix: correct keyUp handler for plugin actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit config is the full button object { title, iconDataUrl, bgColor, action: {...} }. The action type/pluginUUID/hotkey live at config.action, not at config root. - config?.type?.includes('.') → config?.action?.type?.includes('.') - config.type in context → config.action.type - config.pluginUUID in context → config.action.pluginUUID - { ...config } as settings → { ...config.action } Impact: Push-to-Talk and Push-to-Mute keys were never released because the condition was always false (config.type === undefined). The key got stuck held down. Toggle-based actions (Mute, Deafen, Video, etc.) were unaffected since they only need keyDown. --- src/App.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index 90f32ad..c941d1e 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -2479,9 +2479,9 @@ export default function App() { setPressedKey(p => p === index ? null : p) const config = buttonConfigsRef.current[index] // Forward keyUp to plugin processes (needed for Push-to-Talk / Push-to-Mute) - if (config?.type?.includes('.')) { - const context = JSON.stringify({ index, actionUUID: config.type, pluginUUID: config.pluginUUID }) - window.streamDeck?.sendToPlugin?.(config.pluginUUID, config.type, 'keyUp', { ...config }, context) + if (config?.action?.type?.includes('.')) { + const context = JSON.stringify({ index, actionUUID: config.action.type, pluginUUID: config.action.pluginUUID }) + window.streamDeck?.sendToPlugin?.(config.action.pluginUUID, config.action.type, 'keyUp', { ...config.action }, context) } if (config?.pressedIconDataUrl) { From 4b2bd37ceb162d4ac899f995d8bf90c51ddb4ea4 Mon Sep 17 00:00:00 2001 From: Fritz Date: Wed, 27 May 2026 16:33:22 +1000 Subject: [PATCH 2/4] fix: include sdpi-bridge.js in packaged app via extraResources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The electron-builder 'files' array only packaged dist/, electron/, and package.json. public/bridge/sdpi-bridge.js was never copied into the app bundle. In the packaged app (app.isPackaged === true), main.cjs looks for the bridge at process.resourcesPath/bridge/sdpi-bridge.js. Since the file was absent, net.fetch() returned ERR_FILE_NOT_FOUND, sdpi-bridge.js failed to load, and window.sdpi was undefined in the inspector iframe — causing 'ReferenceError: sdpi is not defined' when clicking Save. Fix: add extraResources entry so electron-builder copies the bridge into resources/bridge/sdpi-bridge.js during packaging. --- package.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/package.json b/package.json index 67aa124..396866f 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,12 @@ "electron/**", "package.json" ], + "extraResources": [ + { + "from": "public/bridge/sdpi-bridge.js", + "to": "bridge/sdpi-bridge.js" + } + ], "linux": { "target": [ { From dbbe5fe7b7c16b13745e2563828cd04de7d0bb29 Mon Sep 17 00:00:00 2001 From: Fritz Date: Wed, 27 May 2026 17:19:41 +1000 Subject: [PATCH 3/4] fix(discord-plugin): target Discord window for xdotool key events xdotool key without --window sends events to the currently focused window. When a physical Stream Deck button is pressed, the Electron app or another window may be focused, so Discord never receives the synthetic key event. Add getDiscordWindowId() which searches by window class 'discord' (with title fallback) and passes --window to all xdotool calls. Also add --clearmodifiers to prevent stale modifier state from interfering with the key combination. Falls back to sending to the focused window if Discord is not found. --- .../bin/plugin.cjs | 47 +++++++++++++++++-- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/discord-plugin/com.discord.streamdeck.sdPlugin/bin/plugin.cjs b/discord-plugin/com.discord.streamdeck.sdPlugin/bin/plugin.cjs index 9727fa1..3402536 100644 --- a/discord-plugin/com.discord.streamdeck.sdPlugin/bin/plugin.cjs +++ b/discord-plugin/com.discord.streamdeck.sdPlugin/bin/plugin.cjs @@ -34,9 +34,40 @@ try { console.warn(`[${pluginUUID}] WARNING: xdotool not found. Install it with: sudo apt install xdotool`) } +/** + * Find Discord's X11 window ID so we can send key events directly to it. + * Without targeting a specific window, xdotool sends to the currently focused + * window — which is the tech-stack-streamdeck Electron app when a Stream Deck + * button is pressed. Targeting Discord's window ensures the event reaches + * Discord even when it is not focused. + * + * Returns the window ID string, or null if Discord is not running / not found. + */ +function getDiscordWindowId() { + // Try by window class first (most reliable — matches the Electron app class) + const byClass = spawnSync('xdotool', ['search', '--class', 'discord'], { stdio: 'pipe' }) + if (byClass.status === 0) { + const ids = byClass.stdout.toString().trim().split('\n').filter(Boolean) + if (ids.length > 0) return ids[ids.length - 1] // last = most recently active + } + // Fallback: search by window title + const byName = spawnSync('xdotool', ['search', '--name', 'Discord'], { stdio: 'pipe' }) + if (byName.status === 0) { + const ids = byName.stdout.toString().trim().split('\n').filter(Boolean) + if (ids.length > 0) return ids[ids.length - 1] + } + console.warn(`[${pluginUUID}] Could not find Discord window — sending key to focused window instead`) + return null +} + function xdotoolKey(hotkey) { if (!xdotoolAvailable) return - const result = spawnSync('xdotool', ['key', hotkey], { stdio: 'pipe' }) + const winId = getDiscordWindowId() + const args = winId + ? ['key', '--clearmodifiers', '--window', winId, hotkey] + : ['key', '--clearmodifiers', hotkey] + console.log(`[${pluginUUID}] xdotool ${args.join(' ')}`) + const result = spawnSync('xdotool', args, { stdio: 'pipe' }) if (result.status !== 0) { console.warn(`[${pluginUUID}] xdotool key failed for "${hotkey}":`, result.stderr?.toString().trim()) } @@ -44,7 +75,12 @@ function xdotoolKey(hotkey) { function xdotoolKeyDown(hotkey) { if (!xdotoolAvailable) return - const result = spawnSync('xdotool', ['keydown', hotkey], { stdio: 'pipe' }) + const winId = getDiscordWindowId() + const args = winId + ? ['keydown', '--clearmodifiers', '--window', winId, hotkey] + : ['keydown', '--clearmodifiers', hotkey] + console.log(`[${pluginUUID}] xdotool ${args.join(' ')}`) + const result = spawnSync('xdotool', args, { stdio: 'pipe' }) if (result.status !== 0) { console.warn(`[${pluginUUID}] xdotool keydown failed for "${hotkey}":`, result.stderr?.toString().trim()) } @@ -52,7 +88,12 @@ function xdotoolKeyDown(hotkey) { function xdotoolKeyUp(hotkey) { if (!xdotoolAvailable) return - const result = spawnSync('xdotool', ['keyup', hotkey], { stdio: 'pipe' }) + const winId = getDiscordWindowId() + const args = winId + ? ['keyup', '--clearmodifiers', '--window', winId, hotkey] + : ['keyup', '--clearmodifiers', hotkey] + console.log(`[${pluginUUID}] xdotool ${args.join(' ')}`) + const result = spawnSync('xdotool', args, { stdio: 'pipe' }) if (result.status !== 0) { console.warn(`[${pluginUUID}] xdotool keyup failed for "${hotkey}":`, result.stderr?.toString().trim()) } From ed5d6fc95c966277c836344d0720f81f6f5233a7 Mon Sep 17 00:00:00 2001 From: Fritz Date: Wed, 27 May 2026 17:31:04 +1000 Subject: [PATCH 4/4] fix(discord-plugin): use windowfocus+XTest instead of XSendEvent xdotool key --window uses XSendEvent which sets send_event=True in the X11 event. Discord (and Chromium/Electron in general) rejects these as synthetic events - they are ignored by both the global hotkey listener and the Electron renderer's isTrusted check. Switch to: focus Discord's window first, then send the key via XTestFakeKeyEvent (no --window). XTest events have send_event=False so Chromium treats them as real hardware input (isTrusted=true), and they are dispatched to Discord's focused-window keyboard handler which triggers the mute/deafen/PTT action. Focus is saved before and restored after each call so the user's original window regains focus immediately. --- .../bin/plugin.cjs | 79 ++++++++++++------- 1 file changed, 50 insertions(+), 29 deletions(-) diff --git a/discord-plugin/com.discord.streamdeck.sdPlugin/bin/plugin.cjs b/discord-plugin/com.discord.streamdeck.sdPlugin/bin/plugin.cjs index 3402536..a7093a5 100644 --- a/discord-plugin/com.discord.streamdeck.sdPlugin/bin/plugin.cjs +++ b/discord-plugin/com.discord.streamdeck.sdPlugin/bin/plugin.cjs @@ -35,20 +35,15 @@ try { } /** - * Find Discord's X11 window ID so we can send key events directly to it. - * Without targeting a specific window, xdotool sends to the currently focused - * window — which is the tech-stack-streamdeck Electron app when a Stream Deck - * button is pressed. Targeting Discord's window ensures the event reaches - * Discord even when it is not focused. - * + * Find Discord's X11 window ID. * Returns the window ID string, or null if Discord is not running / not found. */ function getDiscordWindowId() { - // Try by window class first (most reliable — matches the Electron app class) + // Try by window class first (most reliable) const byClass = spawnSync('xdotool', ['search', '--class', 'discord'], { stdio: 'pipe' }) if (byClass.status === 0) { const ids = byClass.stdout.toString().trim().split('\n').filter(Boolean) - if (ids.length > 0) return ids[ids.length - 1] // last = most recently active + if (ids.length > 0) return ids[ids.length - 1] } // Fallback: search by window title const byName = spawnSync('xdotool', ['search', '--name', 'Discord'], { stdio: 'pipe' }) @@ -60,42 +55,68 @@ function getDiscordWindowId() { return null } +/** + * Focus `winId`, run `action()`, then restore focus to the previously active + * window. Uses XTestFakeKeyEvent (not XSendEvent) so Electron/Chromium treats + * the event as a trusted hardware input (isTrusted=true in JavaScript). + * xdotool key --window uses XSendEvent which Discord ignores. + */ +function withDiscordFocus(winId, action) { + const prevResult = spawnSync('xdotool', ['getactivewindow'], { stdio: 'pipe' }) + const prevWinId = prevResult.status === 0 ? prevResult.stdout.toString().trim() : null + + spawnSync('xdotool', ['windowfocus', '--sync', winId], { stdio: 'pipe' }) + action() + if (prevWinId && prevWinId !== winId) { + spawnSync('xdotool', ['windowfocus', '--sync', prevWinId], { stdio: 'pipe' }) + } +} + function xdotoolKey(hotkey) { if (!xdotoolAvailable) return const winId = getDiscordWindowId() - const args = winId - ? ['key', '--clearmodifiers', '--window', winId, hotkey] - : ['key', '--clearmodifiers', hotkey] - console.log(`[${pluginUUID}] xdotool ${args.join(' ')}`) - const result = spawnSync('xdotool', args, { stdio: 'pipe' }) - if (result.status !== 0) { - console.warn(`[${pluginUUID}] xdotool key failed for "${hotkey}":`, result.stderr?.toString().trim()) + const args = ['key', '--clearmodifiers', hotkey] + console.log(`[${pluginUUID}] xdotool${winId ? ' (via Discord focus)' : ''} ${args.join(' ')}`) + if (winId) { + withDiscordFocus(winId, () => { + const r = spawnSync('xdotool', args, { stdio: 'pipe' }) + if (r.status !== 0) console.warn(`[${pluginUUID}] xdotool key failed:`, r.stderr?.toString().trim()) + }) + } else { + const r = spawnSync('xdotool', args, { stdio: 'pipe' }) + if (r.status !== 0) console.warn(`[${pluginUUID}] xdotool key failed:`, r.stderr?.toString().trim()) } } function xdotoolKeyDown(hotkey) { if (!xdotoolAvailable) return const winId = getDiscordWindowId() - const args = winId - ? ['keydown', '--clearmodifiers', '--window', winId, hotkey] - : ['keydown', '--clearmodifiers', hotkey] - console.log(`[${pluginUUID}] xdotool ${args.join(' ')}`) - const result = spawnSync('xdotool', args, { stdio: 'pipe' }) - if (result.status !== 0) { - console.warn(`[${pluginUUID}] xdotool keydown failed for "${hotkey}":`, result.stderr?.toString().trim()) + const args = ['keydown', '--clearmodifiers', hotkey] + console.log(`[${pluginUUID}] xdotool${winId ? ' (via Discord focus)' : ''} ${args.join(' ')}`) + if (winId) { + withDiscordFocus(winId, () => { + const r = spawnSync('xdotool', args, { stdio: 'pipe' }) + if (r.status !== 0) console.warn(`[${pluginUUID}] xdotool keydown failed:`, r.stderr?.toString().trim()) + }) + } else { + const r = spawnSync('xdotool', args, { stdio: 'pipe' }) + if (r.status !== 0) console.warn(`[${pluginUUID}] xdotool keydown failed:`, r.stderr?.toString().trim()) } } function xdotoolKeyUp(hotkey) { if (!xdotoolAvailable) return const winId = getDiscordWindowId() - const args = winId - ? ['keyup', '--clearmodifiers', '--window', winId, hotkey] - : ['keyup', '--clearmodifiers', hotkey] - console.log(`[${pluginUUID}] xdotool ${args.join(' ')}`) - const result = spawnSync('xdotool', args, { stdio: 'pipe' }) - if (result.status !== 0) { - console.warn(`[${pluginUUID}] xdotool keyup failed for "${hotkey}":`, result.stderr?.toString().trim()) + const args = ['keyup', '--clearmodifiers', hotkey] + console.log(`[${pluginUUID}] xdotool${winId ? ' (via Discord focus)' : ''} ${args.join(' ')}`) + if (winId) { + withDiscordFocus(winId, () => { + const r = spawnSync('xdotool', args, { stdio: 'pipe' }) + if (r.status !== 0) console.warn(`[${pluginUUID}] xdotool keyup failed:`, r.stderr?.toString().trim()) + }) + } else { + const r = spawnSync('xdotool', args, { stdio: 'pipe' }) + if (r.status !== 0) console.warn(`[${pluginUUID}] xdotool keyup failed:`, r.stderr?.toString().trim()) } }