diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da13982..976bd10 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,7 +55,7 @@ jobs: # main → increment RELEASE (1st digit), reset BETA/ALPHA/BUILD to 0, tag release/X.0.0.0 - name: Bump version id: bump - if: github.event_name == 'push' && github.ref_name != 'uat' + if: github.event_name == 'push' && github.ref_name != 'uat' && github.actor != 'dependabot[bot]' run: | # Read the 4-part display version from extraMetadata EXT_VERSION=$(node -p "require('./package.json').build.extraMetadata.version") @@ -105,7 +105,7 @@ jobs: # Push the version bump commit AFTER tests pass so a failed test doesn't # advance the version counter. - name: Commit and push version bump - if: github.event_name == 'push' && github.ref_name != 'uat' && steps.bump.outputs.new-version != '' + if: github.event_name == 'push' && github.ref_name != 'uat' && github.actor != 'dependabot[bot]' && steps.bump.outputs.new-version != '' run: | NEW_VERSION="${{ steps.bump.outputs.new-version }}" git config user.name "github-actions[bot]" @@ -117,7 +117,7 @@ jobs: # On main: create a release tag after the version bump commit is pushed. - name: Tag release - if: github.event_name == 'push' && github.ref_name == 'main' && steps.bump.outputs.new-version != '' + if: github.event_name == 'push' && github.ref_name == 'main' && github.actor != 'dependabot[bot]' && steps.bump.outputs.new-version != '' run: | TAG="release/${{ steps.bump.outputs.new-version }}" git tag "$TAG" diff --git a/README.md b/README.md index 0d19d90..109c083 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ OBS Studio and Discord support are distributed as separate `.sdPlugin` packages. | Plugin | Actions | Requires | |--------|---------|----------| | **OBS Studio** (`obs-plugin/`) | Toggle record, toggle stream, pause recording, replay buffer, switch scene, switch collection, source visibility, mute, media control, studio mode, filter toggle, screenshot, transition, chapter marker | OBS WebSocket | -| **Discord** (`discord-plugin/`) | Push-to-Talk, Mute, Deafen | `xdotool` | +| **Discord** (`discord-plugin/`) | Mute, Deafen, Push-to-Talk, Push-to-Mute, channel switching via Discord RPC | Discord desktop app + Discord developer app credentials | --- @@ -129,7 +129,7 @@ OBS Studio and Discord support are distributed as separate `.sdPlugin` packages. > | Plugin | Extra setup required | > |--------|---------------------| > | **OBS Studio** | Open OBS → **Tools → WebSocket Server Settings** → enable the server (default port **4455**). The plugin auto-connects and retries every 5 s. | -> | **Discord** | Ensure `xdotool` is installed (`sudo apt install xdotool`). Open Discord and configure your Push-to-Talk / Mute / Deafen keys there. | +> | **Discord** | Open Discord desktop app. In the Property Inspector, provide your Discord Developer Application `client_id` and `client_secret`, authorize RPC access, then select channels where needed. | > > To uninstall a plugin, delete its folder from `~/.config/tech-stack-streamdeck/plugins/` and restart the app. @@ -224,8 +224,10 @@ The Scene, Scene Collection, Source, Input, and Transition pickers in the Proper ### Discord plugin 1. Install the plugin (see [Installing a plugin](#installing-a-plugin) above) -2. Ensure `xdotool` is installed (`sudo apt install xdotool`) -3. Open Discord and assign Push-to-Talk / Mute / Deafen to buttons +2. Open Discord desktop app +3. In the button's Property Inspector, enter Discord application `client_id` and `client_secret` +4. Click **Authorize** and approve the Discord prompt +5. For voice/text channel actions, pick a guild and channel from the inspector dropdowns --- diff --git a/discord-plugin/com.discord.streamdeck.sdPlugin/bin/discord-rpc.cjs b/discord-plugin/com.discord.streamdeck.sdPlugin/bin/discord-rpc.cjs new file mode 100644 index 0000000..c7a2797 --- /dev/null +++ b/discord-plugin/com.discord.streamdeck.sdPlugin/bin/discord-rpc.cjs @@ -0,0 +1,392 @@ +'use strict' + +const fs = require('fs') +const os = require('os') +const path = require('path') +const net = require('net') +const crypto = require('crypto') + +const OPCODE_HANDSHAKE = 0 +const OPCODE_FRAME = 1 +const OPCODE_CLOSE = 2 +const OPCODE_PING = 3 +const OPCODE_PONG = 4 + +const REQUEST_TIMEOUT_MS = 12_000 + +function makeNonce() { + return crypto.randomBytes(8).toString('hex') +} + +function toLe32(num) { + const b = Buffer.allocUnsafe(4) + b.writeUInt32LE(num, 0) + return b +} + +function splitScopes(scopes) { + if (!scopes) return ['rpc', 'identify'] + if (Array.isArray(scopes)) return scopes + return String(scopes) + .split(/[ ,]+/) + .map(s => s.trim()) + .filter(Boolean) +} + +class DiscordRpcClient { + constructor({ logger } = {}) { + this.log = logger || console + this.socket = null + this.buffer = Buffer.alloc(0) + this.connected = false + this.ready = false + this.pending = new Map() + this.clientId = null + } + + getState() { + return { + connected: this.connected, + ready: this.ready, + clientId: this.clientId, + } + } + + async ensureConnected(clientId) { + if (!clientId) throw new Error('Missing Discord client ID') + + if (this.ready && this.connected && this.clientId === clientId) return + + await this.disconnect() + + this.clientId = clientId + const socketPath = this.findSocketPath() + if (!socketPath) { + throw new Error('Discord IPC socket not found. Start Discord desktop app and try again.') + } + + this.socket = await this.openSocket(socketPath) + this.connected = true + this.ready = false + + this.socket.on('data', chunk => this.onData(chunk)) + this.socket.on('error', err => this.onSocketError(err)) + this.socket.on('close', () => this.onSocketClose()) + + await this.sendRaw(OPCODE_HANDSHAKE, { v: 1, client_id: clientId }) + await this.waitForReady() + } + + async disconnect() { + this.ready = false + this.connected = false + + for (const [, pending] of this.pending) { + clearTimeout(pending.timeout) + pending.reject(new Error('Discord RPC disconnected')) + } + this.pending.clear() + + if (this.socket) { + try { this.socket.end() } catch {} + try { this.socket.destroy() } catch {} + this.socket = null + } + + this.buffer = Buffer.alloc(0) + } + + async authorize({ clientId, scopes }) { + return this.call('AUTHORIZE', { + client_id: clientId, + scopes: splitScopes(scopes), + }) + } + + async authenticate(accessToken) { + if (!accessToken) throw new Error('Missing access token') + return this.call('AUTHENTICATE', { access_token: accessToken }) + } + + async getVoiceSettings() { + return this.call('GET_VOICE_SETTINGS', {}) + } + + async setVoiceSettings(patch) { + return this.call('SET_VOICE_SETTINGS', patch || {}) + } + + async getGuilds() { + const data = await this.call('GET_GUILDS', {}) + return data.guilds || [] + } + + async getChannels(guildId) { + if (!guildId) throw new Error('Missing guild ID') + const data = await this.call('GET_CHANNELS', { guild_id: guildId }) + return data.channels || [] + } + + async selectVoiceChannel(channelId) { + return this.call('SELECT_VOICE_CHANNEL', { channel_id: channelId || null }) + } + + async selectTextChannel(channelId) { + return this.call('SELECT_TEXT_CHANNEL', { channel_id: channelId || null }) + } + + async call(cmd, args) { + if (!this.connected || !this.ready) { + throw new Error('Discord RPC is not connected') + } + + const nonce = makeNonce() + const payload = { cmd, args: args || {}, nonce } + + const result = await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.pending.delete(nonce) + reject(new Error(`Discord RPC timed out for ${cmd}`)) + }, REQUEST_TIMEOUT_MS) + + this.pending.set(nonce, { resolve, reject, timeout }) + this.sendRaw(OPCODE_FRAME, payload).catch(err => { + clearTimeout(timeout) + this.pending.delete(nonce) + reject(err) + }) + }) + + if (result?.evt === 'ERROR') { + const code = result?.data?.code ? ` (${result.data.code})` : '' + throw new Error(`${result?.data?.message || 'Discord RPC error'}${code}`) + } + + return result?.data || {} + } + + findSocketPath() { + const envCandidates = [ + process.env.XDG_RUNTIME_DIR, + process.env.TMPDIR, + process.env.TMP, + process.env.TEMP, + '/tmp', + path.join('/run/user', String(process.getuid?.() || process.geteuid?.() || '')), + os.tmpdir(), + ] + + const dirs = [...new Set(envCandidates.filter(Boolean))] + for (const dir of dirs) { + for (let i = 0; i <= 9; i++) { + const candidate = path.join(dir, `discord-ipc-${i}`) + if (fs.existsSync(candidate)) return candidate + } + } + return null + } + + openSocket(socketPath) { + return new Promise((resolve, reject) => { + const socket = net.createConnection(socketPath) + const onError = err => { + socket.removeListener('connect', onConnect) + reject(err) + } + const onConnect = () => { + socket.removeListener('error', onError) + resolve(socket) + } + socket.once('error', onError) + socket.once('connect', onConnect) + }) + } + + async waitForReady() { + const nonce = makeNonce() + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.pending.delete(nonce) + reject(new Error('Discord RPC handshake timed out')) + }, REQUEST_TIMEOUT_MS) + + this.pending.set(nonce, { + resolve: payload => { + clearTimeout(timeout) + if (payload?.evt === 'READY') { + this.ready = true + resolve() + } else { + reject(new Error('Discord RPC did not return READY')) + } + }, + reject: err => { + clearTimeout(timeout) + reject(err) + }, + timeout, + }) + + this.handshakeNonce = nonce + }) + } + + async sendRaw(opcode, payloadObj) { + if (!this.socket) throw new Error('Discord socket is not open') + const payload = Buffer.from(JSON.stringify(payloadObj), 'utf8') + const header = Buffer.allocUnsafe(8) + header.writeInt32LE(opcode, 0) + header.writeInt32LE(payload.length, 4) + const frame = Buffer.concat([header, payload]) + + await new Promise((resolve, reject) => { + this.socket.write(frame, err => (err ? reject(err) : resolve())) + }) + } + + onData(chunk) { + this.buffer = Buffer.concat([this.buffer, chunk]) + while (this.buffer.length >= 8) { + const opcode = this.buffer.readInt32LE(0) + const length = this.buffer.readInt32LE(4) + if (this.buffer.length < 8 + length) return + + const payloadBuf = this.buffer.subarray(8, 8 + length) + this.buffer = this.buffer.subarray(8 + length) + + let payload = null + try { + payload = JSON.parse(payloadBuf.toString('utf8')) + } catch { + continue + } + + if (opcode === OPCODE_PING) { + this.sendRaw(OPCODE_PONG, payload).catch(() => {}) + continue + } + + if (opcode === OPCODE_CLOSE) { + this.onSocketClose() + continue + } + + if (opcode === OPCODE_FRAME || opcode === OPCODE_HANDSHAKE) { + this.dispatchPayload(payload) + } + } + } + + dispatchPayload(payload) { + if (payload?.evt === 'READY' && this.handshakeNonce) { + const pending = this.pending.get(this.handshakeNonce) + if (pending) { + this.pending.delete(this.handshakeNonce) + this.handshakeNonce = null + pending.resolve(payload) + return + } + } + + const nonce = payload?.nonce + if (!nonce) return + + const pending = this.pending.get(nonce) + if (!pending) return + + this.pending.delete(nonce) + clearTimeout(pending.timeout) + pending.resolve(payload) + } + + onSocketError(err) { + this.log.warn('[Discord RPC] socket error:', err?.message || err) + } + + onSocketClose() { + this.ready = false + this.connected = false + for (const [, pending] of this.pending) { + clearTimeout(pending.timeout) + pending.reject(new Error('Discord RPC connection closed')) + } + this.pending.clear() + } +} + +async function exchangeAuthCode({ clientId, clientSecret, code }) { + if (!clientId) throw new Error('Missing client ID for token exchange') + if (!clientSecret) throw new Error('Missing client secret for token exchange') + if (!code) throw new Error('Missing authorization code from Discord') + + const body = new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + grant_type: 'authorization_code', + code, + }) + + const res = await fetch('https://discord.com/api/oauth2/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }) + + let payload = null + try { payload = await res.json() } catch {} + + if (!res.ok) { + const reason = payload?.error_description || payload?.error || `HTTP ${res.status}` + throw new Error(`Token exchange failed: ${reason}`) + } + + return { + accessToken: payload.access_token, + refreshToken: payload.refresh_token || null, + expiresAt: Date.now() + ((payload.expires_in || 0) * 1000), + scope: payload.scope || null, + tokenType: payload.token_type || null, + } +} + +async function refreshAccessToken({ clientId, clientSecret, refreshToken }) { + if (!clientId) throw new Error('Missing client ID for refresh') + if (!clientSecret) throw new Error('Missing client secret for refresh') + if (!refreshToken) throw new Error('Missing refresh token') + + const body = new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + grant_type: 'refresh_token', + refresh_token: refreshToken, + }) + + const res = await fetch('https://discord.com/api/oauth2/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }) + + let payload = null + try { payload = await res.json() } catch {} + + if (!res.ok) { + const reason = payload?.error_description || payload?.error || `HTTP ${res.status}` + throw new Error(`Token refresh failed: ${reason}`) + } + + return { + accessToken: payload.access_token, + refreshToken: payload.refresh_token || refreshToken, + expiresAt: Date.now() + ((payload.expires_in || 0) * 1000), + scope: payload.scope || null, + tokenType: payload.token_type || null, + } +} + +module.exports = { + DiscordRpcClient, + exchangeAuthCode, + refreshAccessToken, + splitScopes, +} diff --git a/discord-plugin/com.discord.streamdeck.sdPlugin/bin/plugin.cjs b/discord-plugin/com.discord.streamdeck.sdPlugin/bin/plugin.cjs index 2bc8684..a7c96d9 100644 --- a/discord-plugin/com.discord.streamdeck.sdPlugin/bin/plugin.cjs +++ b/discord-plugin/com.discord.streamdeck.sdPlugin/bin/plugin.cjs @@ -1,251 +1,302 @@ -/** - * com.discord.streamdeck — plugin.cjs - * - * Runs as a child process forked by the host app. - * Controls Discord via xdotool keyboard shortcuts. - * - * Each button stores a `hotkey` in its settings (e.g. "ctrl+shift+m"). - * On keyDown the plugin fires `xdotool key `. - * Push-to-Talk and Push-to-Mute use `xdotool keydown` / `xdotool keyup` - * so the key is held for the duration of the button press. - * - * Requirements: xdotool must be installed (sudo apt install xdotool). - */ - 'use strict' -const { spawnSync, execFileSync } = require('child_process') +const { + DiscordRpcClient, + exchangeAuthCode, + refreshAccessToken, + splitScopes, +} = require('./discord-rpc.cjs') const pluginUUID = process.env.PLUGIN_UUID || 'com.discord.streamdeck' -// Actions that use hold-and-release rather than a single tap -const HOLD_ACTIONS = new Set([ - 'com.discord.streamdeck.ptt', - 'com.discord.streamdeck.ptm', -]) - -// Verify xdotool is available at startup -let xdotoolAvailable = false -try { - execFileSync('which', ['xdotool'], { stdio: 'ignore' }) - xdotoolAvailable = true - console.log(`[${pluginUUID}] xdotool found — ready`) -} catch { - console.warn(`[${pluginUUID}] WARNING: xdotool not found. Install it with: sudo apt install xdotool`) +const ACTION_PTT = 'com.discord.streamdeck.ptt' +const ACTION_PTM = 'com.discord.streamdeck.ptm' + +const HOLD_ACTIONS = new Set([ACTION_PTT, ACTION_PTM]) + +const rpc = new DiscordRpcClient({ logger: console }) + +const holdState = new Map() + +function log(msg, ...rest) { + console.log(`[${pluginUUID}] ${msg}`, ...rest) } -// 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 sendToInspector(actionUUID, payload) { + if (!process.send || !actionUUID) return + process.send({ event: 'sendToPropertyInspector', actionUUID, payload }) +} + +function sanitizeSettings(settings = {}) { + return { + clientId: String(settings.clientId || '').trim(), + clientSecret: String(settings.clientSecret || '').trim(), + accessToken: String(settings.accessToken || '').trim(), + refreshToken: String(settings.refreshToken || '').trim(), + expiresAt: Number(settings.expiresAt || 0), + scopes: splitScopes(settings.scopes), + guildId: String(settings.guildId || '').trim(), + channelId: String(settings.channelId || '').trim(), + forceMove: Boolean(settings.forceMove), + } } -function stopStressTest() { - if (!_stressInterval) return - clearInterval(_stressInterval) - _stressInterval = null - console.log(`[${pluginUUID}] [stress] stopped after ${_stressCount} ticks`) - _stressCount = 0 +async function ensureConnection(settings) { + const s = sanitizeSettings(settings) + await rpc.ensureConnected(s.clientId) + return s } -/** - * 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. - * - * 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(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] +async function ensureAuthenticated(actionUUID, settings) { + const s = await ensureConnection(settings) + + if (s.accessToken && s.expiresAt > Date.now() + 30_000) { + await rpc.authenticate(s.accessToken) + return s } - // 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] + + if (s.refreshToken) { + const refreshed = await refreshAccessToken({ + clientId: s.clientId, + clientSecret: s.clientSecret, + refreshToken: s.refreshToken, + }) + + await rpc.authenticate(refreshed.accessToken) + + sendToInspector(actionUUID, { + type: 'patchSettings', + patch: { + accessToken: refreshed.accessToken, + refreshToken: refreshed.refreshToken, + expiresAt: refreshed.expiresAt, + scope: refreshed.scope, + tokenType: refreshed.tokenType, + }, + }) + + return { ...s, ...refreshed } } - return null + + throw new Error('Not authorized. Use the Discord inspector to authorize this action first.') } -/** - * 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(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 }) - } +async function toggleMute() { + const voice = await rpc.getVoiceSettings() + await rpc.setVoiceSettings({ mute: !Boolean(voice.mute) }) +} - // 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') +async function toggleDeafen() { + const voice = await rpc.getVoiceSettings() + await rpc.setVoiceSettings({ deaf: !Boolean(voice.deaf) }) +} + +async function handleHold(actionUUID, event, context) { + if (!context) return - const prevResult = spawnSync('xdotool', ['getactivewindow'], { stdio: 'pipe', timeout: 2000 }) - const prevWinId = prevResult.status === 0 ? prevResult.stdout.toString().trim() : null + if (event === 'keyDown') { + const voice = await rpc.getVoiceSettings() + holdState.set(context, { mute: Boolean(voice.mute), deaf: Boolean(voice.deaf) }) - // Step A: de-iconify minimized window so it becomes viewable for XSetInputFocus - if (wasMinimized) { - spawnSync('xdotool', ['windowactivate', '--sync', anyWinId], { stdio: 'pipe', timeout: 2000 }) + if (actionUUID === ACTION_PTT) { + await rpc.setVoiceSettings({ mute: false }) + } else if (actionUUID === ACTION_PTM) { + await rpc.setVoiceSettings({ mute: true }) + } + return } - // 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 + if (event === 'keyUp') { + const prev = holdState.get(context) + holdState.delete(context) - // ATOMIC: focus + command on ONE X connection — WM cannot revert between them - const r = spawnSync('xdotool', ['windowfocus', '--sync', focusWinId, ...cmdArgs], { stdio: 'pipe', timeout: 3000 }) + if (actionUUID === ACTION_PTT) { + await rpc.setVoiceSettings({ mute: prev ? prev.mute : true }) + } else if (actionUUID === ACTION_PTM) { + await rpc.setVoiceSettings({ mute: prev ? prev.mute : false }) + } + } +} - // Re-minimize if Discord was iconified so it doesn't appear on screen - if (wasMinimized) { - spawnSync('xdotool', ['windowminimize', focusWinId], { stdio: 'pipe', timeout: 2000 }) +async function handleChannelAction(actionUUID, settings) { + const s = sanitizeSettings(settings) + if (!s.channelId) { + throw new Error('No channel selected. Open the Discord inspector and choose a channel first.') } - // Restore keyboard focus to whatever had it before - if (prevWinId && prevWinId !== focusWinId) { - spawnSync('xdotool', ['windowfocus', prevWinId], { stdio: 'pipe', timeout: 2000 }) + if (actionUUID === 'com.discord.streamdeck.voice-channel') { + await rpc.selectVoiceChannel(s.channelId) + return } - return r + if (actionUUID === 'com.discord.streamdeck.text-channel') { + await rpc.selectTextChannel(s.channelId) + return + } } -function xdotoolKey(hotkey) { - if (!xdotoolAvailable) return - 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 unsupportedActionError(actionUUID) { + if (actionUUID === 'com.discord.streamdeck.video') { + return 'Discord RPC does not expose a stable video-toggle command for this plugin. Action is currently unsupported in RPC mode.' + } + if (actionUUID === 'com.discord.streamdeck.stream') { + return 'Discord RPC does not expose a stable stream-toggle command for this plugin. Action is currently unsupported in RPC mode.' + } + return 'Unsupported Discord action.' } -function xdotoolKeyDown(hotkey) { - if (!xdotoolAvailable) return - 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()) -} +async function handleKeyEvent(msg) { + const actionUUID = msg.actionUUID || '' + const event = msg.event || 'keyDown' + const settings = msg.settings || {} + const context = msg.context || '' + + if (!actionUUID) return + + try { + await ensureAuthenticated(actionUUID, settings) + + if (HOLD_ACTIONS.has(actionUUID)) { + await handleHold(actionUUID, event, context) + return + } -function xdotoolKeyUp(hotkey) { - if (!xdotoolAvailable) return - 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()) + if (event !== 'keyDown') return + + switch (actionUUID) { + case 'com.discord.streamdeck.mute': + await toggleMute() + return + case 'com.discord.streamdeck.deafen': + await toggleDeafen() + return + case 'com.discord.streamdeck.voice-channel': + case 'com.discord.streamdeck.text-channel': + await handleChannelAction(actionUUID, settings) + return + case 'com.discord.streamdeck.video': + case 'com.discord.streamdeck.stream': + throw new Error(unsupportedActionError(actionUUID)) + default: + return + } + } catch (err) { + const message = err?.message || String(err) + console.warn(`[${pluginUUID}] ${actionUUID} failed: ${message}`) + sendToInspector(actionUUID, { type: 'error', message }) + } } -process.on('message', (msg) => { - if (!msg?.event) return +async function handleInspectorMessage(actionUUID, payload) { + const cmd = payload?.$cmd || '' + const settings = payload?.settings || payload || {} - const hotkey = msg.settings?.hotkey - const actionUUID = msg.actionUUID || '' + try { + if (cmd === 'rpc-status') { + const s = sanitizeSettings(settings) + await rpc.ensureConnected(s.clientId) - if (msg.event === 'keyDown') { - if (!hotkey) { - console.warn(`[${pluginUUID}] keyDown on ${actionUUID} — no hotkey configured`) + let authed = false + if (s.accessToken) { + try { + await rpc.authenticate(s.accessToken) + authed = true + } catch { + authed = false + } + } + + sendToInspector(actionUUID, { + type: 'status', + connected: true, + ready: rpc.getState().ready, + authenticated: authed, + }) return } - console.log(`[${pluginUUID}] keyDown ${actionUUID} → ${HOLD_ACTIONS.has(actionUUID) ? 'keydown' : 'key'} "${hotkey}"`) - if (HOLD_ACTIONS.has(actionUUID)) { - xdotoolKeyDown(hotkey) - } else { - _stressHotkey = hotkey // track latest non-hold hotkey for stress testing - xdotoolKey(hotkey) + + if (cmd === 'rpc-authorize') { + const s = await ensureConnection(settings) + const auth = await rpc.authorize({ clientId: s.clientId, scopes: s.scopes }) + const token = await exchangeAuthCode({ + clientId: s.clientId, + clientSecret: s.clientSecret, + code: auth.code, + }) + await rpc.authenticate(token.accessToken) + + sendToInspector(actionUUID, { + type: 'patchSettings', + patch: { + accessToken: token.accessToken, + refreshToken: token.refreshToken, + expiresAt: token.expiresAt, + scope: token.scope, + tokenType: token.tokenType, + }, + }) + + sendToInspector(actionUUID, { + type: 'status', + connected: true, + ready: rpc.getState().ready, + authenticated: true, + }) + return } - } - if (msg.event === 'keyUp') { - if (!hotkey) return - if (HOLD_ACTIONS.has(actionUUID)) { - console.log(`[${pluginUUID}] keyUp ${actionUUID} → keyup "${hotkey}"`) - xdotoolKeyUp(hotkey) + if (cmd === 'rpc-refresh') { + const authedSettings = await ensureAuthenticated(actionUUID, settings) + const guilds = await rpc.getGuilds() + + sendToInspector(actionUUID, { + type: 'guilds', + guilds, + }) + + if (authedSettings.guildId) { + const channels = await rpc.getChannels(authedSettings.guildId) + sendToInspector(actionUUID, { + type: 'channels', + guildId: authedSettings.guildId, + channels, + }) + } + return } - } - // 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.`) + if (cmd === 'rpc-load-channels') { + const authedSettings = await ensureAuthenticated(actionUUID, settings) + if (!authedSettings.guildId) { + sendToInspector(actionUUID, { type: 'channels', guildId: '', channels: [] }) return } - startStressTest(h) - } else { - stopStressTest() + const channels = await rpc.getChannels(authedSettings.guildId) + sendToInspector(actionUUID, { + type: 'channels', + guildId: authedSettings.guildId, + channels, + }) + return } + } catch (err) { + const message = err?.message || String(err) + sendToInspector(actionUUID, { type: 'error', message }) } -}) - -// 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 +process.on('message', msg => { + if (!msg?.event) return + + if (msg.event === 'sendToPlugin') { + handleInspectorMessage(msg.actionUUID || '', msg.settings || {}) + return + } + + if (msg.event === 'keyDown' || msg.event === 'keyUp') { + handleKeyEvent(msg) + } +}) + +log('Discord RPC plugin ready') setInterval(() => {}, 60_000) diff --git a/discord-plugin/com.discord.streamdeck.sdPlugin/ui/inspector.html b/discord-plugin/com.discord.streamdeck.sdPlugin/ui/inspector.html index c686e46..c5a507a 100644 --- a/discord-plugin/com.discord.streamdeck.sdPlugin/ui/inspector.html +++ b/discord-plugin/com.discord.streamdeck.sdPlugin/ui/inspector.html @@ -3,7 +3,7 @@ - Discord + Discord RPC -
Discord
-
- -
- - - -
- Use lowercase key names with + as separator.
- Examples: ctrl+shift+m   F13   super+alt+d +
+

Action

+
-
- - +
+

Discord RPC Auth

+ + + + + + + + + + +
+ + + +
+ +
+ Authorize opens Discord's permission prompt. This action stores tokens in your local profile so the plugin can call RPC commands. +
+ +
-
- xdotool not installed? Run sudo apt install xdotool first. + @@ -173,85 +182,239 @@ const ACTION_INFO = { 'com.discord.streamdeck.mute': { label: 'Mute / Unmute', - html: 'In Discord: Settings → Keybinds → Toggle Mute.
Set a keybind there, then enter the same key combination below.', + text: 'Toggles your Discord self-mute state via RPC.', }, 'com.discord.streamdeck.deafen': { label: 'Deafen / Undeafen', - html: 'In Discord: Settings → Keybinds → Toggle Deafen.
Set a keybind there, then enter the same key combination below.', + text: 'Toggles your Discord self-deafen state via RPC.', }, 'com.discord.streamdeck.ptt': { label: 'Push to Talk', - html: 'In Discord: Settings → Voice & Video → Input Mode → Push to Talk.
Set your PTT key there. The button holds the key down while pressed and releases it on release.', + text: 'Temporarily unmutes while the key is held.', }, 'com.discord.streamdeck.ptm': { label: 'Push to Mute', - html: 'In Discord: Settings → Keybinds → Push to Mute.
Set a keybind there. The button holds the key down while pressed and releases it on release.', + text: 'Temporarily mutes while the key is held.', }, 'com.discord.streamdeck.voice-channel': { - label: 'Voice Channel', - html: 'Discord has no built-in keybind for specific voice channels. Create a system shortcut with AutoKey or similar that clicks your channel, then enter that hotkey below.', + label: 'Join Voice Channel', + text: 'Select a guild and voice channel below.', }, 'com.discord.streamdeck.text-channel': { - label: 'Text Channel', - html: 'Discord has no built-in keybind for specific text channels. Create a system shortcut with AutoKey or similar that navigates to your channel, then enter that hotkey below.', + label: 'Go to Text Channel', + text: 'Select a guild and text channel below.', }, 'com.discord.streamdeck.video': { label: 'Toggle Video', - html: 'In Discord: Settings → Keybinds → Toggle Video.
Set a keybind there, then enter the same key combination below.', + text: 'This action is currently limited by Discord RPC command support in this plugin.', }, 'com.discord.streamdeck.stream': { label: 'Toggle Stream', - html: 'In Discord: Settings → Keybinds → Start/Stop Screenshare.
Set a keybind there, then enter the same key combination below.', + text: 'This action is currently limited by Discord RPC command support in this plugin.', }, } - const hotkeyInput = document.getElementById('hotkeyInput') - const saveBtn = document.getElementById('saveBtn') - const statusEl = document.getElementById('status') - const actionNameEl = document.getElementById('actionName') - const instructionEl = document.getElementById('instruction') + const el = { + actionName: document.getElementById('actionName'), + instruction: document.getElementById('instruction'), + status: document.getElementById('status'), + clientId: document.getElementById('clientId'), + clientSecret: document.getElementById('clientSecret'), + scopes: document.getElementById('scopes'), + saveBtn: document.getElementById('saveBtn'), + authorizeBtn: document.getElementById('authorizeBtn'), + refreshBtn: document.getElementById('refreshBtn'), + channelPanel: document.getElementById('channelPanel'), + guildSelect: document.getElementById('guildSelect'), + channelSelect: document.getElementById('channelSelect'), + channelHint: document.getElementById('channelHint'), + } - let statusTimer = null + let settings = {} + let currentActionType = '' - function showStatus(msg, isError = false) { - clearTimeout(statusTimer) - statusEl.textContent = msg - statusEl.className = 'status visible' + (isError ? ' error' : '') - statusTimer = setTimeout(() => { statusEl.className = 'status' }, 2500) + function isVoiceAction() { + return currentActionType === 'com.discord.streamdeck.voice-channel' } - function save() { - const hotkey = hotkeyInput.value.trim() - if (!hotkey) { - showStatus('Enter a hotkey first.', true) - return - } - sdpi.setSettings({ hotkey }) - showStatus('Saved') + function isTextAction() { + return currentActionType === 'com.discord.streamdeck.text-channel' } - saveBtn.addEventListener('click', save) + function channelTypeAllowed(type) { + if (isVoiceAction()) return Number(type) === 2 + if (isTextAction()) return Number(type) === 0 || Number(type) === 1 || Number(type) === 3 + return false + } - hotkeyInput.addEventListener('keydown', e => { - if (e.key === 'Enter') save() - }) + function setStatus(message, kind) { + el.status.textContent = message || '' + el.status.className = 'status' + if (kind === 'error') el.status.classList.add('error') + if (kind === 'success') el.status.classList.add('success') + } - // Load current settings when the inspector opens - sdpi.getSettings(settings => { - const type = settings?.type || '' - const info = ACTION_INFO[type] + function persist(patch) { + settings = { ...settings, ...patch } + sdpi.setSettings(settings) + } + function syncFormFromSettings() { + el.clientId.value = settings.clientId || '' + el.clientSecret.value = settings.clientSecret || '' + el.scopes.value = settings.scopes || 'rpc identify' + } + + function renderActionInfo() { + const info = ACTION_INFO[currentActionType] if (info) { - actionNameEl.textContent = info.label - instructionEl.innerHTML = info.html + el.actionName.textContent = info.label + el.instruction.textContent = info.text + } else { + el.actionName.textContent = 'Discord' + el.instruction.textContent = 'Configure Discord RPC settings.' + } + + const showChannels = isVoiceAction() || isTextAction() + el.channelPanel.classList.toggle('hidden', !showChannels) + if (isVoiceAction()) { + el.channelHint.textContent = 'Only voice channels are listed.' + } else if (isTextAction()) { + el.channelHint.textContent = 'Only text/DM channels are listed.' } else { - instructionEl.innerHTML = 'Configure a hotkey to match the corresponding Discord keybind.' + el.channelHint.textContent = '' + } + } + + function sendPlugin(cmd, payload = {}) { + sdpi.sendToPlugin({ + $cmd: cmd, + settings: { ...settings, ...payload }, + }) + } + + function populateGuilds(guilds) { + const previous = settings.guildId || '' + el.guildSelect.innerHTML = '' + + guilds.forEach(g => { + const opt = document.createElement('option') + opt.value = String(g.id) + opt.textContent = g.name || g.id + el.guildSelect.appendChild(opt) + }) + + if (previous) el.guildSelect.value = previous + } + + function populateChannels(channels) { + const previous = settings.channelId || '' + el.channelSelect.innerHTML = '' + + channels + .filter(ch => channelTypeAllowed(ch.type)) + .sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''))) + .forEach(ch => { + const opt = document.createElement('option') + opt.value = String(ch.id) + opt.textContent = ch.name || ch.id + el.channelSelect.appendChild(opt) + }) + + if (previous) el.channelSelect.value = previous + } + + function requestStatusAndLists() { + sendPlugin('rpc-status') + if (isVoiceAction() || isTextAction()) { + sendPlugin('rpc-refresh') + } + } + + el.saveBtn.addEventListener('click', () => { + persist({ + clientId: el.clientId.value.trim(), + clientSecret: el.clientSecret.value.trim(), + scopes: el.scopes.value.trim() || 'rpc identify', + }) + setStatus('Saved settings', 'success') + }) + + el.authorizeBtn.addEventListener('click', () => { + persist({ + clientId: el.clientId.value.trim(), + clientSecret: el.clientSecret.value.trim(), + scopes: el.scopes.value.trim() || 'rpc identify', + }) + setStatus('Authorizing via Discord...', null) + sendPlugin('rpc-authorize') + }) + + el.refreshBtn.addEventListener('click', () => { + persist({ + clientId: el.clientId.value.trim(), + clientSecret: el.clientSecret.value.trim(), + scopes: el.scopes.value.trim() || 'rpc identify', + }) + setStatus('Refreshing Discord data...', null) + requestStatusAndLists() + }) + + el.guildSelect.addEventListener('change', () => { + persist({ guildId: el.guildSelect.value, channelId: '' }) + sendPlugin('rpc-load-channels') + }) + + el.channelSelect.addEventListener('change', () => { + persist({ channelId: el.channelSelect.value }) + setStatus('Channel saved', 'success') + }) + + sdpi.onSendToPropertyInspector(payload => { + if (!payload || typeof payload !== 'object') return + + if (payload.type === 'error') { + setStatus(payload.message || 'Discord RPC error', 'error') + return } - if (settings?.hotkey) { - hotkeyInput.value = settings.hotkey + if (payload.type === 'status') { + if (payload.authenticated) { + setStatus('Connected and authenticated', 'success') + } else if (payload.connected) { + setStatus('Connected. Authorize to use commands.', null) + } else { + setStatus('Disconnected from Discord', 'error') + } + return + } + + if (payload.type === 'patchSettings') { + persist(payload.patch || {}) + syncFormFromSettings() + return + } + + if (payload.type === 'guilds') { + populateGuilds(Array.isArray(payload.guilds) ? payload.guilds : []) + return + } + + if (payload.type === 'channels') { + if (!payload.guildId || payload.guildId === settings.guildId) { + populateChannels(Array.isArray(payload.channels) ? payload.channels : []) + } } }) + + sdpi.getSettings(current => { + settings = current || {} + currentActionType = settings.type || '' + + renderActionInfo() + syncFormFromSettings() + requestStatusAndLists() + }) diff --git a/src/App.css b/src/App.css index fa5aee6..c16e32c 100644 --- a/src/App.css +++ b/src/App.css @@ -291,26 +291,6 @@ 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 2ea1729..044a72a 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1793,8 +1793,6 @@ 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(() => { @@ -1810,7 +1808,6 @@ export default function App() { const currentPageRef = useRef(0) const folderPathRef = useRef([]) const deviceRef = useRef(null) - const stressIntervalRef = useRef(null) useEffect(() => { buttonConfigsRef.current = buttonConfigs }, [buttonConfigs]) useEffect(() => { pagesRef.current = pages }, [pages]) useEffect(() => { currentPageRef.current = currentPage }, [currentPage]) @@ -2526,7 +2523,7 @@ export default function App() { sleepingRef.current = false setSleeping(false) }) - return () => { offInfo(); offDown(); offUp(); offSleep(); offWake(); offDisconnect?.(); clearInterval(stressIntervalRef.current) } + return () => { offInfo(); offDown(); offUp(); offSleep(); offWake(); offDisconnect?.() } }, []) // When waking, re-draw every hardware button with the stored config. @@ -2555,44 +2552,6 @@ 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 ── */} @@ -2640,25 +2599,6 @@ export default function App() { - -