Skip to content
Merged
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
213 changes: 152 additions & 61 deletions discord-plugin/com.discord.streamdeck.sdPlugin/bin/plugin.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <visibleWinId> <cmd>
* Both sub-commands share ONE X connection. windowfocus --sync calls
* XSetInputFocus and waits for the FocusIn event; <cmd> 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) => {
Expand All @@ -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)
}
}
Expand All @@ -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)
20 changes: 20 additions & 0 deletions src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
64 changes: 62 additions & 2 deletions src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -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])
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 (
<div className="app">
{/* ── Topbar ── */}
Expand Down Expand Up @@ -2599,6 +2640,25 @@ export default function App() {
<span className={`device-status-dot${device ? ' connected' : ''}`} />
</span>

<button
className={`icon-btn${stressActive ? ' stress-btn-active' : ''}`}
title={stressActive ? `Stress test active — ${stressCount} fire${stressCount === 1 ? '' : 's'}. Click to stop.` : 'Stress test: fire Discord mute/unmute every 5s'}
onClick={toggleStress}
>
{stressActive ? (
<>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" width="16" height="16">
<rect x="3" y="3" width="10" height="10" rx="1" fill="currentColor" stroke="none" />
</svg>
<span className="stress-count">{stressCount}</span>
</>
) : (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" width="16" height="16">
<polyline points="1,8 4,4 7,12 10,6 13,9 16,7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</button>

<button className="icon-btn" title="Plugins" onClick={() => setShowPluginBrowser(true)}>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" width="16" height="16">
<rect x="2" y="2" width="5.5" height="5.5" rx="1" />
Expand Down