@@ -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
83174function 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
99181function 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
115188function 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
131195process . 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
160251setInterval ( ( ) => { } , 60_000 )
0 commit comments