diff --git a/discord-plugin/com.discord.streamdeck.sdPlugin/bin/plugin.cjs b/discord-plugin/com.discord.streamdeck.sdPlugin/bin/plugin.cjs index 893708c..2bc8684 100644 --- a/discord-plugin/com.discord.streamdeck.sdPlugin/bin/plugin.cjs +++ b/discord-plugin/com.discord.streamdeck.sdPlugin/bin/plugin.cjs @@ -34,98 +34,162 @@ try { console.warn(`[${pluginUUID}] WARNING: xdotool not found. Install it with: sudo apt install xdotool`) } +// Stress test state — toggled via DISCORD_MUTE_STRESS=1 env var or 'stress-test' IPC event +let _stressHotkey = null +let _stressInterval = null +let _stressCount = 0 + +function startStressTest(hotkey) { + if (_stressInterval) return // already running + _stressHotkey = hotkey + _stressCount = 0 + let _busy = false + _stressInterval = setInterval(() => { + if (_busy || !_stressHotkey) return + _busy = true + try { + _stressCount++ + console.log(`[${pluginUUID}] [stress] tick #${_stressCount} — firing "${_stressHotkey}"`) + xdotoolKey(_stressHotkey) + } finally { + _busy = false + } + }, 5000) + console.log(`[${pluginUUID}] [stress] started — will fire "${hotkey}" every 5s`) +} + +function stopStressTest() { + if (!_stressInterval) return + clearInterval(_stressInterval) + _stressInterval = null + console.log(`[${pluginUUID}] [stress] stopped after ${_stressCount} ticks`) + _stressCount = 0 +} + /** - * Find Discord's X11 window ID. - * Returns the window ID string, or null if Discord is not running / not found. + * Find a Discord X11 window ID. + * + * @param {boolean} onlyVisible - When true, restrict to viewable (on-screen) windows only. + * IMPORTANT: XSetInputFocus (used by `xdotool windowfocus`) returns BadMatch for + * non-viewable windows. Discord (Electron) creates many internal windows — + * GPU process, renderer sub-windows, utility overlays — that share the class + * 'discord' but are NOT viewable. The oldest result from an unrestricted + * search is often one of these internal windows. Pass onlyVisible=true + * whenever the window ID will be used for focus/key injection. * - * Strategy: search by window NAME "Discord" first. Discord's main application - * window has a title of "Discord" (or "Discord - #channel"), while the GPU - * process, renderer sub-windows, and utility windows have empty or internal - * titles that do NOT contain "Discord". Taking the first (oldest) result from - * the name search reliably returns the main window across all button presses, - * even after Discord briefly gains and loses focus (which can cause it to - * create additional sub-windows, making a last-ID strategy unreliable). + * Pass onlyVisible=false only when you need the window ID for + * getwindowstate or windowactivate (de-iconify), where a hidden window ID + * is acceptable and the visible window may not exist yet. */ -function getDiscordWindowId() { - // Primary: name search — matches only the main Discord application window - const byName = spawnSync('xdotool', ['search', '--name', 'Discord'], { stdio: 'pipe' }) +function getDiscordWindowId(onlyVisible = false) { + const flag = onlyVisible ? ['--onlyvisible'] : [] + // Primary: name search — Discord's main window title is "Discord" or "Discord - #channel" + const byName = spawnSync('xdotool', ['search', ...flag, '--name', 'Discord'], { stdio: 'pipe', timeout: 2000 }) if (byName.status === 0) { const ids = byName.stdout.toString().trim().split('\n').filter(Boolean) if (ids.length > 0) return ids[0] } - // Fallback: class search — take the first (oldest/main) window - const byClass = spawnSync('xdotool', ['search', '--class', 'discord'], { stdio: 'pipe' }) + // Fallback: class search + const byClass = spawnSync('xdotool', ['search', ...flag, '--class', 'discord'], { stdio: 'pipe', timeout: 2000 }) if (byClass.status === 0) { const ids = byClass.stdout.toString().trim().split('\n').filter(Boolean) if (ids.length > 0) return ids[0] } - console.warn(`[${pluginUUID}] Could not find Discord window — sending key to focused window instead`) 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. + * Focus Discord's window and fire `cmdArgs` atomically in ONE xdotool process. + * + * Focus strategy (two-step, single atomic process): + * + * Step A — de-iconify (only if minimized): + * windowactivate --sync uses _NET_ACTIVE_WINDOW (WM-level) to restore a + * minimized window. This is the only mechanism that works for hidden + * windows. After this, the window is on-screen and viewable. + * + * Step B — atomic focus + key in ONE process: + * windowfocus --sync + * Both sub-commands share ONE X connection. windowfocus --sync calls + * XSetInputFocus and waits for the FocusIn event; fires the instant + * that confirmation arrives. The WM cannot revert focus between these two + * operations because they execute on the same X connection event cycle. + * + * WHY --onlyvisible IS REQUIRED for the focus step: + * Discord (Electron) creates many X11 windows — GPU process, renderer + * sub-processes, overlays — that are NOT viewable (not mapped on screen). + * XSetInputFocus returns BadMatch for non-viewable windows, crashing the + * entire xdotool invocation. --onlyvisible filters to only mapped, + * on-screen windows, so XSetInputFocus always succeeds. + * + * WHY windowfocus --sync does NOT hang here: + * --sync hangs only on iconified windows (hidden windows never receive + * FocusIn). Step A already de-iconifies if needed. For a visible but + * unfocused background window, FocusIn arrives in microseconds. + * + * @param {string[]} cmdArgs xdotool sub-command args, e.g. + * ['key', '--clearmodifiers', 'ctrl+shift+m'] */ -function withDiscordFocus(winId, action) { - const prevResult = spawnSync('xdotool', ['getactivewindow'], { stdio: 'pipe' }) +function withDiscordFocus(cmdArgs) { + // Find any Discord window (including hidden internal ones) for state check + de-iconify + const anyWinId = getDiscordWindowId(false) + if (!anyWinId) { + console.warn(`[${pluginUUID}] Discord window not found — sending key to focused window instead`) + return spawnSync('xdotool', cmdArgs, { stdio: 'pipe', timeout: 2000 }) + } + + // Detect iconified (minimized) state + const stateResult = spawnSync('xdotool', ['getwindowstate', '--shell', anyWinId], { stdio: 'pipe', timeout: 2000 }) + const wasMinimized = stateResult.status === 0 && stateResult.stdout.toString().includes('HIDDEN=1') + + const prevResult = spawnSync('xdotool', ['getactivewindow'], { stdio: 'pipe', timeout: 2000 }) 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' }) + // Step A: de-iconify minimized window so it becomes viewable for XSetInputFocus + if (wasMinimized) { + spawnSync('xdotool', ['windowactivate', '--sync', anyWinId], { stdio: 'pipe', timeout: 2000 }) + } + + // Step B: find the VIEWABLE window for focus injection. + // --onlyvisible skips non-viewable internal Electron/Discord windows that + // cause XSetInputFocus to return BadMatch. + const focusWinId = getDiscordWindowId(true) ?? anyWinId + + // ATOMIC: focus + command on ONE X connection — WM cannot revert between them + const r = spawnSync('xdotool', ['windowfocus', '--sync', focusWinId, ...cmdArgs], { stdio: 'pipe', timeout: 3000 }) + + // Re-minimize if Discord was iconified so it doesn't appear on screen + if (wasMinimized) { + spawnSync('xdotool', ['windowminimize', focusWinId], { stdio: 'pipe', timeout: 2000 }) + } + + // Restore keyboard focus to whatever had it before + if (prevWinId && prevWinId !== focusWinId) { + spawnSync('xdotool', ['windowfocus', prevWinId], { stdio: 'pipe', timeout: 2000 }) } + + return r } function xdotoolKey(hotkey) { if (!xdotoolAvailable) return - const winId = getDiscordWindowId() - 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()) - } + console.log(`[${pluginUUID}] xdotool key --clearmodifiers ${hotkey} (via Discord focus)`) + const r = withDiscordFocus(['key', '--clearmodifiers', hotkey]) + 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 = ['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()) - } + console.log(`[${pluginUUID}] xdotool keydown --clearmodifiers ${hotkey} (via Discord focus)`) + const r = withDiscordFocus(['keydown', '--clearmodifiers', hotkey]) + 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 = ['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()) - } + console.log(`[${pluginUUID}] xdotool keyup --clearmodifiers ${hotkey} (via Discord focus)`) + const r = withDiscordFocus(['keyup', '--clearmodifiers', hotkey]) + if (r.status !== 0) console.warn(`[${pluginUUID}] xdotool keyup failed:`, r.stderr?.toString().trim()) } process.on('message', (msg) => { @@ -143,6 +207,7 @@ process.on('message', (msg) => { if (HOLD_ACTIONS.has(actionUUID)) { xdotoolKeyDown(hotkey) } else { + _stressHotkey = hotkey // track latest non-hold hotkey for stress testing xdotoolKey(hotkey) } } @@ -154,7 +219,33 @@ process.on('message', (msg) => { xdotoolKeyUp(hotkey) } } + + // Stress test control — sent from the UI toggle button + if (msg.event === 'stress-test') { + if (msg.settings?.active) { + const h = msg.settings?.hotkey || _stressHotkey + if (!h) { + console.warn(`[${pluginUUID}] [stress] cannot start — no hotkey known yet. Press the mute button once first.`) + return + } + startStressTest(h) + } else { + stopStressTest() + } + } }) +// Auto-start stress test when DISCORD_MUTE_STRESS=1 is set — waits for the +// first real keyDown to learn the hotkey, then fires every 5 s automatically. +if (process.env.DISCORD_MUTE_STRESS === '1') { + console.log(`[${pluginUUID}] [stress] DISCORD_MUTE_STRESS=1 — stress test will auto-start on first keyDown`) + const _waitForHotkey = setInterval(() => { + if (_stressHotkey) { + clearInterval(_waitForHotkey) + startStressTest(_stressHotkey) + } + }, 500) +} + // Keep the process alive setInterval(() => {}, 60_000) diff --git a/src/App.css b/src/App.css index c16e32c..fa5aee6 100644 --- a/src/App.css +++ b/src/App.css @@ -291,6 +291,26 @@ color: #bbb; } +.icon-btn.stress-btn-active { + background: #3a1a1a; + color: #e05555; + width: auto; + padding: 0 6px; + gap: 4px; +} + +.icon-btn.stress-btn-active:hover { + background: #4a2020; + color: #f07070; +} + +.stress-count { + font-size: 11px; + font-weight: 600; + min-width: 14px; + text-align: center; +} + /* ─── Workspace ──────────────────────────────────────────── */ .workspace { display: flex; diff --git a/src/App.jsx b/src/App.jsx index 0571ed2..2ea1729 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1793,6 +1793,8 @@ export default function App() { const [appVersion, setAppVersion] = useState('') const [pluginManifests, setPluginManifests] = useState([]) // installed .sdPlugin manifests const [showPluginBrowser, setShowPluginBrowser] = useState(false) // plugin browser modal + const [stressActive, setStressActive] = useState(false) // Discord mute stress test + const [stressCount, setStressCount] = useState(0) // Fetch app version once on mount useEffect(() => { @@ -1807,7 +1809,8 @@ export default function App() { const pagesRef = useRef([{}]) const currentPageRef = useRef(0) const folderPathRef = useRef([]) - const deviceRef = useRef(null) + const deviceRef = useRef(null) + const stressIntervalRef = useRef(null) useEffect(() => { buttonConfigsRef.current = buttonConfigs }, [buttonConfigs]) useEffect(() => { pagesRef.current = pages }, [pages]) useEffect(() => { currentPageRef.current = currentPage }, [currentPage]) @@ -2523,7 +2526,7 @@ export default function App() { sleepingRef.current = false setSleeping(false) }) - return () => { offInfo(); offDown(); offUp(); offSleep(); offWake(); offDisconnect?.() } + return () => { offInfo(); offDown(); offUp(); offSleep(); offWake(); offDisconnect?.(); clearInterval(stressIntervalRef.current) } }, []) // When waking, re-draw every hardware button with the stored config. @@ -2552,6 +2555,44 @@ export default function App() { const handleSelect = i => setSelectedKey(prev => prev === i ? null : i) + function toggleStress() { + if (stressActive) { + clearInterval(stressIntervalRef.current) + stressIntervalRef.current = null + setStressActive(false) + setStressCount(0) + } else { + // Find the first Discord non-PTT/PTM action across all pages + let discordAction = null + outer: for (const page of pagesRef.current) { + for (const cfg of Object.values(page)) { + if ( + cfg?.action?.pluginUUID === 'com.discord.streamdeck' && + cfg.action.type && + !cfg.action.type.endsWith('.ptt') && + !cfg.action.type.endsWith('.ptm') + ) { + discordAction = cfg.action + break outer + } + } + } + if (!discordAction) { + alert('No Discord mute/unmute button configured. Add a Discord action to a button first.') + return + } + setStressActive(true) + let count = 0 + setStressCount(0) + stressIntervalRef.current = setInterval(() => { + count++ + setStressCount(count) + const ctx = JSON.stringify({ stressTest: true, tick: count }) + window.streamDeck?.sendToPlugin?.(discordAction.pluginUUID, discordAction.type, 'keyDown', { ...discordAction }, ctx) + }, 5000) + } + } + return (
{/* ── Topbar ── */} @@ -2599,6 +2640,25 @@ export default function App() { + +