Skip to content

Commit 96c9ec5

Browse files
feat: add stress test functionality for Discord mute/unmute actions
1 parent ac66d63 commit 96c9ec5

3 files changed

Lines changed: 234 additions & 63 deletions

File tree

discord-plugin/com.discord.streamdeck.sdPlugin/bin/plugin.cjs

Lines changed: 152 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -34,98 +34,162 @@ try {
3434
console.warn(`[${pluginUUID}] WARNING: xdotool not found. Install it with: sudo apt install xdotool`)
3535
}
3636

37+
// Stress test state — toggled via DISCORD_MUTE_STRESS=1 env var or 'stress-test' IPC event
38+
let _stressHotkey = null
39+
let _stressInterval = null
40+
let _stressCount = 0
41+
42+
function startStressTest(hotkey) {
43+
if (_stressInterval) return // already running
44+
_stressHotkey = hotkey
45+
_stressCount = 0
46+
let _busy = false
47+
_stressInterval = setInterval(() => {
48+
if (_busy || !_stressHotkey) return
49+
_busy = true
50+
try {
51+
_stressCount++
52+
console.log(`[${pluginUUID}] [stress] tick #${_stressCount} — firing "${_stressHotkey}"`)
53+
xdotoolKey(_stressHotkey)
54+
} finally {
55+
_busy = false
56+
}
57+
}, 5000)
58+
console.log(`[${pluginUUID}] [stress] started — will fire "${hotkey}" every 5s`)
59+
}
60+
61+
function stopStressTest() {
62+
if (!_stressInterval) return
63+
clearInterval(_stressInterval)
64+
_stressInterval = null
65+
console.log(`[${pluginUUID}] [stress] stopped after ${_stressCount} ticks`)
66+
_stressCount = 0
67+
}
68+
3769
/**
38-
* Find Discord's X11 window ID.
39-
* Returns the window ID string, or null if Discord is not running / not found.
70+
* Find a Discord X11 window ID.
71+
*
72+
* @param {boolean} onlyVisible - When true, restrict to viewable (on-screen) windows only.
73+
* IMPORTANT: XSetInputFocus (used by `xdotool windowfocus`) returns BadMatch for
74+
* non-viewable windows. Discord (Electron) creates many internal windows —
75+
* GPU process, renderer sub-windows, utility overlays — that share the class
76+
* 'discord' but are NOT viewable. The oldest result from an unrestricted
77+
* search is often one of these internal windows. Pass onlyVisible=true
78+
* whenever the window ID will be used for focus/key injection.
4079
*
41-
* Strategy: search by window NAME "Discord" first. Discord's main application
42-
* window has a title of "Discord" (or "Discord - #channel"), while the GPU
43-
* process, renderer sub-windows, and utility windows have empty or internal
44-
* titles that do NOT contain "Discord". Taking the first (oldest) result from
45-
* the name search reliably returns the main window across all button presses,
46-
* even after Discord briefly gains and loses focus (which can cause it to
47-
* create additional sub-windows, making a last-ID strategy unreliable).
80+
* Pass onlyVisible=false only when you need the window ID for
81+
* getwindowstate or windowactivate (de-iconify), where a hidden window ID
82+
* is acceptable and the visible window may not exist yet.
4883
*/
49-
function getDiscordWindowId() {
50-
// Primary: name search — matches only the main Discord application window
51-
const byName = spawnSync('xdotool', ['search', '--name', 'Discord'], { stdio: 'pipe' })
84+
function getDiscordWindowId(onlyVisible = false) {
85+
const flag = onlyVisible ? ['--onlyvisible'] : []
86+
// Primary: name search — Discord's main window title is "Discord" or "Discord - #channel"
87+
const byName = spawnSync('xdotool', ['search', ...flag, '--name', 'Discord'], { stdio: 'pipe', timeout: 2000 })
5288
if (byName.status === 0) {
5389
const ids = byName.stdout.toString().trim().split('\n').filter(Boolean)
5490
if (ids.length > 0) return ids[0]
5591
}
56-
// Fallback: class search — take the first (oldest/main) window
57-
const byClass = spawnSync('xdotool', ['search', '--class', 'discord'], { stdio: 'pipe' })
92+
// Fallback: class search
93+
const byClass = spawnSync('xdotool', ['search', ...flag, '--class', 'discord'], { stdio: 'pipe', timeout: 2000 })
5894
if (byClass.status === 0) {
5995
const ids = byClass.stdout.toString().trim().split('\n').filter(Boolean)
6096
if (ids.length > 0) return ids[0]
6197
}
62-
console.warn(`[${pluginUUID}] Could not find Discord window — sending key to focused window instead`)
6398
return null
6499
}
65100

66101
/**
67-
* Focus `winId`, run `action()`, then restore focus to the previously active
68-
* window. Uses XTestFakeKeyEvent (not XSendEvent) so Electron/Chromium treats
69-
* the event as a trusted hardware input (isTrusted=true in JavaScript).
70-
* xdotool key --window uses XSendEvent which Discord ignores.
102+
* Focus Discord's window and fire `cmdArgs` atomically in ONE xdotool process.
103+
*
104+
* Focus strategy (two-step, single atomic process):
105+
*
106+
* Step A — de-iconify (only if minimized):
107+
* windowactivate --sync uses _NET_ACTIVE_WINDOW (WM-level) to restore a
108+
* minimized window. This is the only mechanism that works for hidden
109+
* windows. After this, the window is on-screen and viewable.
110+
*
111+
* Step B — atomic focus + key in ONE process:
112+
* windowfocus --sync <visibleWinId> <cmd>
113+
* Both sub-commands share ONE X connection. windowfocus --sync calls
114+
* XSetInputFocus and waits for the FocusIn event; <cmd> fires the instant
115+
* that confirmation arrives. The WM cannot revert focus between these two
116+
* operations because they execute on the same X connection event cycle.
117+
*
118+
* WHY --onlyvisible IS REQUIRED for the focus step:
119+
* Discord (Electron) creates many X11 windows — GPU process, renderer
120+
* sub-processes, overlays — that are NOT viewable (not mapped on screen).
121+
* XSetInputFocus returns BadMatch for non-viewable windows, crashing the
122+
* entire xdotool invocation. --onlyvisible filters to only mapped,
123+
* on-screen windows, so XSetInputFocus always succeeds.
124+
*
125+
* WHY windowfocus --sync does NOT hang here:
126+
* --sync hangs only on iconified windows (hidden windows never receive
127+
* FocusIn). Step A already de-iconifies if needed. For a visible but
128+
* unfocused background window, FocusIn arrives in microseconds.
129+
*
130+
* @param {string[]} cmdArgs xdotool sub-command args, e.g.
131+
* ['key', '--clearmodifiers', 'ctrl+shift+m']
71132
*/
72-
function withDiscordFocus(winId, action) {
73-
const prevResult = spawnSync('xdotool', ['getactivewindow'], { stdio: 'pipe' })
133+
function withDiscordFocus(cmdArgs) {
134+
// Find any Discord window (including hidden internal ones) for state check + de-iconify
135+
const anyWinId = getDiscordWindowId(false)
136+
if (!anyWinId) {
137+
console.warn(`[${pluginUUID}] Discord window not found — sending key to focused window instead`)
138+
return spawnSync('xdotool', cmdArgs, { stdio: 'pipe', timeout: 2000 })
139+
}
140+
141+
// Detect iconified (minimized) state
142+
const stateResult = spawnSync('xdotool', ['getwindowstate', '--shell', anyWinId], { stdio: 'pipe', timeout: 2000 })
143+
const wasMinimized = stateResult.status === 0 && stateResult.stdout.toString().includes('HIDDEN=1')
144+
145+
const prevResult = spawnSync('xdotool', ['getactivewindow'], { stdio: 'pipe', timeout: 2000 })
74146
const prevWinId = prevResult.status === 0 ? prevResult.stdout.toString().trim() : null
75147

76-
spawnSync('xdotool', ['windowfocus', '--sync', winId], { stdio: 'pipe' })
77-
action()
78-
if (prevWinId && prevWinId !== winId) {
79-
spawnSync('xdotool', ['windowfocus', '--sync', prevWinId], { stdio: 'pipe' })
148+
// Step A: de-iconify minimized window so it becomes viewable for XSetInputFocus
149+
if (wasMinimized) {
150+
spawnSync('xdotool', ['windowactivate', '--sync', anyWinId], { stdio: 'pipe', timeout: 2000 })
151+
}
152+
153+
// Step B: find the VIEWABLE window for focus injection.
154+
// --onlyvisible skips non-viewable internal Electron/Discord windows that
155+
// cause XSetInputFocus to return BadMatch.
156+
const focusWinId = getDiscordWindowId(true) ?? anyWinId
157+
158+
// ATOMIC: focus + command on ONE X connection — WM cannot revert between them
159+
const r = spawnSync('xdotool', ['windowfocus', '--sync', focusWinId, ...cmdArgs], { stdio: 'pipe', timeout: 3000 })
160+
161+
// Re-minimize if Discord was iconified so it doesn't appear on screen
162+
if (wasMinimized) {
163+
spawnSync('xdotool', ['windowminimize', focusWinId], { stdio: 'pipe', timeout: 2000 })
164+
}
165+
166+
// Restore keyboard focus to whatever had it before
167+
if (prevWinId && prevWinId !== focusWinId) {
168+
spawnSync('xdotool', ['windowfocus', prevWinId], { stdio: 'pipe', timeout: 2000 })
80169
}
170+
171+
return r
81172
}
82173

83174
function xdotoolKey(hotkey) {
84175
if (!xdotoolAvailable) return
85-
const winId = getDiscordWindowId()
86-
const args = ['key', '--clearmodifiers', hotkey]
87-
console.log(`[${pluginUUID}] xdotool${winId ? ' (via Discord focus)' : ''} ${args.join(' ')}`)
88-
if (winId) {
89-
withDiscordFocus(winId, () => {
90-
const r = spawnSync('xdotool', args, { stdio: 'pipe' })
91-
if (r.status !== 0) console.warn(`[${pluginUUID}] xdotool key failed:`, r.stderr?.toString().trim())
92-
})
93-
} else {
94-
const r = spawnSync('xdotool', args, { stdio: 'pipe' })
95-
if (r.status !== 0) console.warn(`[${pluginUUID}] xdotool key failed:`, r.stderr?.toString().trim())
96-
}
176+
console.log(`[${pluginUUID}] xdotool key --clearmodifiers ${hotkey} (via Discord focus)`)
177+
const r = withDiscordFocus(['key', '--clearmodifiers', hotkey])
178+
if (r.status !== 0) console.warn(`[${pluginUUID}] xdotool key failed:`, r.stderr?.toString().trim())
97179
}
98180

99181
function xdotoolKeyDown(hotkey) {
100182
if (!xdotoolAvailable) return
101-
const winId = getDiscordWindowId()
102-
const args = ['keydown', '--clearmodifiers', hotkey]
103-
console.log(`[${pluginUUID}] xdotool${winId ? ' (via Discord focus)' : ''} ${args.join(' ')}`)
104-
if (winId) {
105-
withDiscordFocus(winId, () => {
106-
const r = spawnSync('xdotool', args, { stdio: 'pipe' })
107-
if (r.status !== 0) console.warn(`[${pluginUUID}] xdotool keydown failed:`, r.stderr?.toString().trim())
108-
})
109-
} else {
110-
const r = spawnSync('xdotool', args, { stdio: 'pipe' })
111-
if (r.status !== 0) console.warn(`[${pluginUUID}] xdotool keydown failed:`, r.stderr?.toString().trim())
112-
}
183+
console.log(`[${pluginUUID}] xdotool keydown --clearmodifiers ${hotkey} (via Discord focus)`)
184+
const r = withDiscordFocus(['keydown', '--clearmodifiers', hotkey])
185+
if (r.status !== 0) console.warn(`[${pluginUUID}] xdotool keydown failed:`, r.stderr?.toString().trim())
113186
}
114187

115188
function xdotoolKeyUp(hotkey) {
116189
if (!xdotoolAvailable) return
117-
const winId = getDiscordWindowId()
118-
const args = ['keyup', '--clearmodifiers', hotkey]
119-
console.log(`[${pluginUUID}] xdotool${winId ? ' (via Discord focus)' : ''} ${args.join(' ')}`)
120-
if (winId) {
121-
withDiscordFocus(winId, () => {
122-
const r = spawnSync('xdotool', args, { stdio: 'pipe' })
123-
if (r.status !== 0) console.warn(`[${pluginUUID}] xdotool keyup failed:`, r.stderr?.toString().trim())
124-
})
125-
} else {
126-
const r = spawnSync('xdotool', args, { stdio: 'pipe' })
127-
if (r.status !== 0) console.warn(`[${pluginUUID}] xdotool keyup failed:`, r.stderr?.toString().trim())
128-
}
190+
console.log(`[${pluginUUID}] xdotool keyup --clearmodifiers ${hotkey} (via Discord focus)`)
191+
const r = withDiscordFocus(['keyup', '--clearmodifiers', hotkey])
192+
if (r.status !== 0) console.warn(`[${pluginUUID}] xdotool keyup failed:`, r.stderr?.toString().trim())
129193
}
130194

131195
process.on('message', (msg) => {
@@ -143,6 +207,7 @@ process.on('message', (msg) => {
143207
if (HOLD_ACTIONS.has(actionUUID)) {
144208
xdotoolKeyDown(hotkey)
145209
} else {
210+
_stressHotkey = hotkey // track latest non-hold hotkey for stress testing
146211
xdotoolKey(hotkey)
147212
}
148213
}
@@ -154,7 +219,33 @@ process.on('message', (msg) => {
154219
xdotoolKeyUp(hotkey)
155220
}
156221
}
222+
223+
// Stress test control — sent from the UI toggle button
224+
if (msg.event === 'stress-test') {
225+
if (msg.settings?.active) {
226+
const h = msg.settings?.hotkey || _stressHotkey
227+
if (!h) {
228+
console.warn(`[${pluginUUID}] [stress] cannot start — no hotkey known yet. Press the mute button once first.`)
229+
return
230+
}
231+
startStressTest(h)
232+
} else {
233+
stopStressTest()
234+
}
235+
}
157236
})
158237

238+
// Auto-start stress test when DISCORD_MUTE_STRESS=1 is set — waits for the
239+
// first real keyDown to learn the hotkey, then fires every 5 s automatically.
240+
if (process.env.DISCORD_MUTE_STRESS === '1') {
241+
console.log(`[${pluginUUID}] [stress] DISCORD_MUTE_STRESS=1 — stress test will auto-start on first keyDown`)
242+
const _waitForHotkey = setInterval(() => {
243+
if (_stressHotkey) {
244+
clearInterval(_waitForHotkey)
245+
startStressTest(_stressHotkey)
246+
}
247+
}, 500)
248+
}
249+
159250
// Keep the process alive
160251
setInterval(() => {}, 60_000)

src/App.css

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,26 @@
291291
color: #bbb;
292292
}
293293

294+
.icon-btn.stress-btn-active {
295+
background: #3a1a1a;
296+
color: #e05555;
297+
width: auto;
298+
padding: 0 6px;
299+
gap: 4px;
300+
}
301+
302+
.icon-btn.stress-btn-active:hover {
303+
background: #4a2020;
304+
color: #f07070;
305+
}
306+
307+
.stress-count {
308+
font-size: 11px;
309+
font-weight: 600;
310+
min-width: 14px;
311+
text-align: center;
312+
}
313+
294314
/* ─── Workspace ──────────────────────────────────────────── */
295315
.workspace {
296316
display: flex;

src/App.jsx

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1793,6 +1793,8 @@ export default function App() {
17931793
const [appVersion, setAppVersion] = useState('')
17941794
const [pluginManifests, setPluginManifests] = useState([]) // installed .sdPlugin manifests
17951795
const [showPluginBrowser, setShowPluginBrowser] = useState(false) // plugin browser modal
1796+
const [stressActive, setStressActive] = useState(false) // Discord mute stress test
1797+
const [stressCount, setStressCount] = useState(0)
17961798

17971799
// Fetch app version once on mount
17981800
useEffect(() => {
@@ -1807,7 +1809,8 @@ export default function App() {
18071809
const pagesRef = useRef([{}])
18081810
const currentPageRef = useRef(0)
18091811
const folderPathRef = useRef([])
1810-
const deviceRef = useRef(null)
1812+
const deviceRef = useRef(null)
1813+
const stressIntervalRef = useRef(null)
18111814
useEffect(() => { buttonConfigsRef.current = buttonConfigs }, [buttonConfigs])
18121815
useEffect(() => { pagesRef.current = pages }, [pages])
18131816
useEffect(() => { currentPageRef.current = currentPage }, [currentPage])
@@ -2523,7 +2526,7 @@ export default function App() {
25232526
sleepingRef.current = false
25242527
setSleeping(false)
25252528
})
2526-
return () => { offInfo(); offDown(); offUp(); offSleep(); offWake(); offDisconnect?.() }
2529+
return () => { offInfo(); offDown(); offUp(); offSleep(); offWake(); offDisconnect?.(); clearInterval(stressIntervalRef.current) }
25272530
}, [])
25282531

25292532
// When waking, re-draw every hardware button with the stored config.
@@ -2552,6 +2555,44 @@ export default function App() {
25522555

25532556
const handleSelect = i => setSelectedKey(prev => prev === i ? null : i)
25542557

2558+
function toggleStress() {
2559+
if (stressActive) {
2560+
clearInterval(stressIntervalRef.current)
2561+
stressIntervalRef.current = null
2562+
setStressActive(false)
2563+
setStressCount(0)
2564+
} else {
2565+
// Find the first Discord non-PTT/PTM action across all pages
2566+
let discordAction = null
2567+
outer: for (const page of pagesRef.current) {
2568+
for (const cfg of Object.values(page)) {
2569+
if (
2570+
cfg?.action?.pluginUUID === 'com.discord.streamdeck' &&
2571+
cfg.action.type &&
2572+
!cfg.action.type.endsWith('.ptt') &&
2573+
!cfg.action.type.endsWith('.ptm')
2574+
) {
2575+
discordAction = cfg.action
2576+
break outer
2577+
}
2578+
}
2579+
}
2580+
if (!discordAction) {
2581+
alert('No Discord mute/unmute button configured. Add a Discord action to a button first.')
2582+
return
2583+
}
2584+
setStressActive(true)
2585+
let count = 0
2586+
setStressCount(0)
2587+
stressIntervalRef.current = setInterval(() => {
2588+
count++
2589+
setStressCount(count)
2590+
const ctx = JSON.stringify({ stressTest: true, tick: count })
2591+
window.streamDeck?.sendToPlugin?.(discordAction.pluginUUID, discordAction.type, 'keyDown', { ...discordAction }, ctx)
2592+
}, 5000)
2593+
}
2594+
}
2595+
25552596
return (
25562597
<div className="app">
25572598
{/* ── Topbar ── */}
@@ -2599,6 +2640,25 @@ export default function App() {
25992640
<span className={`device-status-dot${device ? ' connected' : ''}`} />
26002641
</span>
26012642

2643+
<button
2644+
className={`icon-btn${stressActive ? ' stress-btn-active' : ''}`}
2645+
title={stressActive ? `Stress test active — ${stressCount} fire${stressCount === 1 ? '' : 's'}. Click to stop.` : 'Stress test: fire Discord mute/unmute every 5s'}
2646+
onClick={toggleStress}
2647+
>
2648+
{stressActive ? (
2649+
<>
2650+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" width="16" height="16">
2651+
<rect x="3" y="3" width="10" height="10" rx="1" fill="currentColor" stroke="none" />
2652+
</svg>
2653+
<span className="stress-count">{stressCount}</span>
2654+
</>
2655+
) : (
2656+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" width="16" height="16">
2657+
<polyline points="1,8 4,4 7,12 10,6 13,9 16,7" strokeLinecap="round" strokeLinejoin="round" />
2658+
</svg>
2659+
)}
2660+
</button>
2661+
26022662
<button className="icon-btn" title="Plugins" onClick={() => setShowPluginBrowser(true)}>
26032663
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" width="16" height="16">
26042664
<rect x="2" y="2" width="5.5" height="5.5" rx="1" />

0 commit comments

Comments
 (0)