From 0d7e093eddc9546bd60e845cfea5d5c4e24d4d8f Mon Sep 17 00:00:00 2001 From: Fritz Date: Thu, 4 Jun 2026 10:22:22 +1000 Subject: [PATCH 1/4] chore: update workflow and Discord RPC migration integration --- .github/workflows/ci.yml | 6 +- README.md | 10 +- .../bin/discord-rpc.cjs | 392 +++++++++++++++ .../bin/plugin.cjs | 457 ++++++++++-------- .../ui/inspector.html | 419 +++++++++++----- src/App.css | 20 - src/App.jsx | 62 +-- 7 files changed, 947 insertions(+), 419 deletions(-) create mode 100644 discord-plugin/com.discord.streamdeck.sdPlugin/bin/discord-rpc.cjs 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() { - -
- Authorize opens Discord's permission prompt. This action stores tokens in your local profile so the plugin can call RPC commands. + Authorize opens Discord's permission prompt. With a relay URL configured, your Discord client secret and refresh token stay on the relay and are not persisted locally.
@@ -219,6 +225,8 @@

Channel Selection

instruction: document.getElementById('instruction'), status: document.getElementById('status'), clientId: document.getElementById('clientId'), + relayUrl: document.getElementById('relayUrl'), + relayApiKey: document.getElementById('relayApiKey'), clientSecret: document.getElementById('clientSecret'), scopes: document.getElementById('scopes'), saveBtn: document.getElementById('saveBtn'), @@ -261,6 +269,8 @@

Channel Selection

function syncFormFromSettings() { el.clientId.value = settings.clientId || '' + el.relayUrl.value = settings.relayUrl || '' + el.relayApiKey.value = settings.relayApiKey || '' el.clientSecret.value = settings.clientSecret || '' el.scopes.value = settings.scopes || 'rpc identify' } @@ -332,18 +342,24 @@

Channel Selection

} el.saveBtn.addEventListener('click', () => { + const relayUrl = el.relayUrl.value.trim() persist({ clientId: el.clientId.value.trim(), - clientSecret: el.clientSecret.value.trim(), + relayUrl, + relayApiKey: el.relayApiKey.value.trim(), + clientSecret: relayUrl ? '' : el.clientSecret.value.trim(), scopes: el.scopes.value.trim() || 'rpc identify', }) setStatus('Saved settings', 'success') }) el.authorizeBtn.addEventListener('click', () => { + const relayUrl = el.relayUrl.value.trim() persist({ clientId: el.clientId.value.trim(), - clientSecret: el.clientSecret.value.trim(), + relayUrl, + relayApiKey: el.relayApiKey.value.trim(), + clientSecret: relayUrl ? '' : el.clientSecret.value.trim(), scopes: el.scopes.value.trim() || 'rpc identify', }) setStatus('Authorizing via Discord...', null) @@ -351,9 +367,12 @@

Channel Selection

}) el.refreshBtn.addEventListener('click', () => { + const relayUrl = el.relayUrl.value.trim() persist({ clientId: el.clientId.value.trim(), - clientSecret: el.clientSecret.value.trim(), + relayUrl, + relayApiKey: el.relayApiKey.value.trim(), + clientSecret: relayUrl ? '' : el.clientSecret.value.trim(), scopes: el.scopes.value.trim() || 'rpc identify', }) setStatus('Refreshing Discord data...', null) diff --git a/discord-relay/README.md b/discord-relay/README.md new file mode 100644 index 0000000..9c37ed7 --- /dev/null +++ b/discord-relay/README.md @@ -0,0 +1,46 @@ +# Discord OAuth Relay (Cloudflare Worker) + +This worker keeps the Discord client secret and refresh tokens on the server side. +The Stream Deck Discord plugin calls this relay for OAuth token exchange and refresh. + +## Endpoints + +- `GET /health` +- `POST /oauth/discord/exchange` +- `POST /oauth/discord/access` +- `POST /oauth/discord/revoke` + +## Required bindings and secrets + +1. Cloudflare KV namespace bound as `DISCORD_SESSIONS` +1. Secret: `DISCORD_CLIENT_SECRET` +1. Optional secret: `RELAY_API_KEY` + +## Local dev + +1. Install Wrangler + - `npm i -g wrangler` +1. Authenticate + - `wrangler login` +1. Create KV + - `wrangler kv namespace create DISCORD_SESSIONS` + - `wrangler kv namespace create DISCORD_SESSIONS --preview` +1. Put generated IDs into `wrangler.toml` +1. Set secrets + - `wrangler secret put DISCORD_CLIENT_SECRET` + - `wrangler secret put RELAY_API_KEY` +1. Start worker + - `wrangler dev` + +## Deploy + +1. `wrangler deploy` +1. Copy deployed worker URL +1. Put relay URL into the Discord action inspector +1. Put `RELAY_API_KEY` into the plugin setting if you enabled it + +## Security notes + +- In hosted mode, the plugin should not store `clientSecret` or `refreshToken`. +- The relay stores refresh tokens in KV with TTL (`SESSION_TTL_SECONDS`). +- Rotate `RELAY_API_KEY` and `DISCORD_CLIENT_SECRET` regularly. diff --git a/discord-relay/worker.mjs b/discord-relay/worker.mjs new file mode 100644 index 0000000..75de8fb --- /dev/null +++ b/discord-relay/worker.mjs @@ -0,0 +1,215 @@ +export default { + async fetch(request, env) { + try { + if (request.method !== 'POST' && !isHealthRequest(request)) { + return json({ error: 'Method not allowed' }, 405) + } + + if (isHealthRequest(request)) { + return json({ ok: true, service: 'discord-relay' }) + } + + const authError = checkRelayAuth(request, env) + if (authError) return authError + + const url = new URL(request.url) + if (url.pathname === '/oauth/discord/exchange') { + return handleExchange(request, env) + } + + if (url.pathname === '/oauth/discord/access') { + return handleAccess(request, env) + } + + if (url.pathname === '/oauth/discord/revoke') { + return handleRevoke(request, env) + } + + return json({ error: 'Not found' }, 404) + } catch (err) { + return json({ error: err?.message || 'Unhandled relay error' }, 500) + } + }, +} + +function isHealthRequest(request) { + const url = new URL(request.url) + return request.method === 'GET' && url.pathname === '/health' +} + +function checkRelayAuth(request, env) { + const requiredKey = String(env.RELAY_API_KEY || '').trim() + if (!requiredKey) return null + + const incomingKey = String(request.headers.get('x-relay-key') || '').trim() + if (incomingKey && incomingKey === requiredKey) return null + + return json({ error: 'Unauthorized relay request' }, 401) +} + +async function handleExchange(request, env) { + const body = await request.json() + const clientId = String(body?.clientId || '').trim() + const code = String(body?.code || '').trim() + const existingSessionId = String(body?.sessionId || '').trim() + + if (!clientId) return json({ error: 'Missing clientId' }, 400) + if (!code) return json({ error: 'Missing authorization code' }, 400) + + const discordPayload = await exchangeWithDiscord({ + clientId, + clientSecret: requiredSecret(env), + grantType: 'authorization_code', + code, + }) + + const sessionId = existingSessionId || crypto.randomUUID() + await writeSession(env, sessionId, { + clientId, + refreshToken: String(discordPayload.refresh_token || ''), + scope: discordPayload.scope || null, + tokenType: discordPayload.token_type || null, + updatedAt: Date.now(), + }) + + return json(toClientTokenResponse(discordPayload, sessionId)) +} + +async function handleAccess(request, env) { + const body = await request.json() + const clientId = String(body?.clientId || '').trim() + const sessionId = String(body?.sessionId || '').trim() + const legacyRefreshToken = String(body?.refreshToken || '').trim() + + if (!clientId) return json({ error: 'Missing clientId' }, 400) + + let refreshToken = legacyRefreshToken + if (!refreshToken && sessionId) { + const session = await readSession(env, sessionId) + if (!session) return json({ error: 'Relay session not found' }, 404) + if (session.clientId !== clientId) return json({ error: 'Session client mismatch' }, 400) + refreshToken = String(session.refreshToken || '') + } + + if (!refreshToken) { + return json({ error: 'Missing refresh token or relay session' }, 400) + } + + const discordPayload = await exchangeWithDiscord({ + clientId, + clientSecret: requiredSecret(env), + grantType: 'refresh_token', + refreshToken, + }) + + if (sessionId) { + await writeSession(env, sessionId, { + clientId, + refreshToken: String(discordPayload.refresh_token || refreshToken), + scope: discordPayload.scope || null, + tokenType: discordPayload.token_type || null, + updatedAt: Date.now(), + }) + } + + return json(toClientTokenResponse(discordPayload, sessionId || null)) +} + +async function handleRevoke(request, env) { + const body = await request.json() + const sessionId = String(body?.sessionId || '').trim() + + if (!sessionId) return json({ error: 'Missing sessionId' }, 400) + + await env.DISCORD_SESSIONS.delete(sessionKey(sessionId)) + return json({ revoked: true, sessionId }) +} + +async function exchangeWithDiscord({ clientId, clientSecret, grantType, code, refreshToken }) { + const params = new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + grant_type: grantType, + }) + + if (code) params.set('code', code) + if (refreshToken) params.set('refresh_token', refreshToken) + + const res = await fetch('https://discord.com/api/oauth2/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params, + }) + + const payload = await safeJson(res) + if (!res.ok) { + const reason = payload?.error_description || payload?.error || `Discord HTTP ${res.status}` + throw new Error(`Discord token exchange failed: ${reason}`) + } + + return payload +} + +function toClientTokenResponse(discordPayload, sessionId) { + return { + accessToken: String(discordPayload.access_token || ''), + expiresAt: Date.now() + (Number(discordPayload.expires_in || 0) * 1000), + scope: discordPayload.scope || null, + tokenType: discordPayload.token_type || null, + sessionId: sessionId || null, + } +} + +async function writeSession(env, sessionId, data) { + const ttl = clampTtlSeconds(env.SESSION_TTL_SECONDS) + await env.DISCORD_SESSIONS.put(sessionKey(sessionId), JSON.stringify(data), { + expirationTtl: ttl, + }) +} + +async function readSession(env, sessionId) { + const raw = await env.DISCORD_SESSIONS.get(sessionKey(sessionId)) + if (!raw) return null + try { + return JSON.parse(raw) + } catch { + return null + } +} + +function sessionKey(sessionId) { + return `discord-session:${sessionId}` +} + +function requiredSecret(env) { + const value = String(env.DISCORD_CLIENT_SECRET || '').trim() + if (!value) throw new Error('Server misconfigured: DISCORD_CLIENT_SECRET is missing') + return value +} + +function clampTtlSeconds(raw) { + const fallback = 60 * 60 * 24 * 30 + const value = Number(raw) + if (!Number.isFinite(value)) return fallback + if (value < 60) return 60 + if (value > 60 * 60 * 24 * 90) return 60 * 60 * 24 * 90 + return Math.floor(value) +} + +function json(payload, status = 200) { + return new Response(JSON.stringify(payload), { + status, + headers: { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'no-store', + }, + }) +} + +async function safeJson(response) { + try { + return await response.json() + } catch { + return null + } +} diff --git a/discord-relay/wrangler.toml b/discord-relay/wrangler.toml new file mode 100644 index 0000000..e0a6f87 --- /dev/null +++ b/discord-relay/wrangler.toml @@ -0,0 +1,15 @@ +name = "discord-rpc-relay" +main = "worker.mjs" +compatibility_date = "2026-06-04" + +[[kv_namespaces]] +binding = "DISCORD_SESSIONS" +id = "REPLACE_WITH_PROD_KV_ID" +preview_id = "REPLACE_WITH_PREVIEW_KV_ID" + +[vars] +SESSION_TTL_SECONDS = "2592000" + +# Set these with wrangler secret put: +# DISCORD_CLIENT_SECRET +# RELAY_API_KEY (optional but recommended) diff --git a/docs/installation-guide.md b/docs/installation-guide.md index 6c63f3e..f40a165 100644 --- a/docs/installation-guide.md +++ b/docs/installation-guide.md @@ -259,9 +259,37 @@ If you want the app to start automatically when you log in: --- +## Discord Plugin Hosted Relay Setup (Recommended) + +If you use the Discord plugin, configure a hosted OAuth relay to keep the Discord `client_secret` and refresh tokens off the local machine. + +1. Open a terminal in the project folder: + +```bash +cd discord-relay +wrangler login +wrangler kv namespace create DISCORD_SESSIONS +wrangler kv namespace create DISCORD_SESSIONS --preview +# copy generated KV IDs into discord-relay/wrangler.toml +wrangler secret put DISCORD_CLIENT_SECRET +wrangler secret put RELAY_API_KEY +wrangler deploy +``` + +2. Install the Discord plugin as usual into `~/.config/tech-stack-streamdeck/plugins/`. +3. In the Discord action inspector, set: + - `Client ID` — your Discord application client ID + - `Relay URL` — your deployed Worker URL + - `Relay API Key` — your relay key if enabled +4. Click **Authorize** and accept the Discord prompt. + +In hosted mode, local plugin settings will not keep `client_secret` or `refresh_token`. + +--- + ## Next Steps -- **Install plugins** (OBS Studio, Discord Push-to-Talk) — see the separate Plugin Installation Guide *(coming soon)* +- **Install plugins** (OBS Studio, Discord) — see the Plugin Installation Guide in the README - **Create profiles** — use the profile switcher at the top of the app to create named layouts for different workflows - **Icon Library** — assign images to your buttons by pointing the app at a local folder of `.png` / `.gif` files via the button editor panel From 70f443b87d7911772219246e9c0319ec2f340fcd Mon Sep 17 00:00:00 2001 From: Fritz Date: Tue, 9 Jun 2026 08:30:19 +1000 Subject: [PATCH 4/4] feat(discord): enhance local relay setup and configuration options --- README.md | 45 ++- .../bin/discord-rpc.cjs | 7 +- .../bin/plugin.cjs | 3 + discord-relay/README.md | 79 ++++ discord-relay/local-server.mjs | 336 ++++++++++++++++++ discord-relay/start-local.sh | 66 ++++ discord-relay/worker.mjs | 5 +- package.json | 4 +- 8 files changed, 535 insertions(+), 10 deletions(-) create mode 100644 discord-relay/local-server.mjs create mode 100755 discord-relay/start-local.sh diff --git a/README.md b/README.md index 50c20e5..5ca310f 100644 --- a/README.md +++ b/README.md @@ -224,17 +224,20 @@ 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. Open Discord desktop app -3. In the button's Property Inspector, enter Discord application `client_id` -4. Configure `relay_url` (recommended) so your `client_secret` and refresh token stay server-side -5. Click **Authorize** and approve the Discord prompt -6. For voice/text channel actions, pick a guild and channel from the inspector dropdowns +2. Open Discord desktop app and keep it running +3. Create a Discord developer application and copy its **Client ID** +4. In Discord Developer Portal → **OAuth2**, add `http://127.0.0.1` to **Redirects** +4. In the button's Property Inspector, enter the Discord **Client ID** +5. Configure a relay URL (recommended) so `client_secret` and refresh tokens stay server-side +6. Click **Authorize** once and approve the Discord prompt +7. For voice/text channel actions, pick a guild and channel from the inspector dropdowns -Hosted relay quick start (free, Cloudflare Workers): +Cloudflare Workers free-tier relay (recommended): ```bash # from repo root cd discord-relay +npm i -g wrangler wrangler login wrangler kv namespace create DISCORD_SESSIONS wrangler kv namespace create DISCORD_SESSIONS --preview @@ -246,6 +249,36 @@ wrangler deploy Then paste the deployed Worker URL into the `Relay URL` field in the Discord inspector. If you set `RELAY_API_KEY`, also paste it into `Relay API Key`. +#### What clone-users must run for Discord + +- The app itself (`npm run electron:dev` in dev mode or installed packaged app) +- Discord desktop app (must be open for Discord RPC) +- No local relay process if you use deployed Cloudflare Worker + +#### No-host fallback options + +- **Local relay (zero hosting cost):** run one command from repo root and use local relay URL +- **Direct token mode (least secure):** leave `Relay URL` empty and provide `Client Secret` in inspector + +Security note: direct token mode exposes secrets to local plugin settings, while relay mode keeps them server-side. + +Local relay one-command startup: + +```bash +npm run discord-relay:local:setup +``` + +Then set `Relay URL` to `http://127.0.0.1:8787` in the Discord inspector. + +Manual (non-interactive) startup also works: + +```bash +export DISCORD_CLIENT_SECRET="your-discord-client-secret" +export DISCORD_RELAY_MASTER_KEY="long-random-local-passphrase" +export RELAY_API_KEY="optional-local-relay-key" +npm run discord-relay:local +``` + --- ## Installation diff --git a/discord-plugin/com.discord.streamdeck.sdPlugin/bin/discord-rpc.cjs b/discord-plugin/com.discord.streamdeck.sdPlugin/bin/discord-rpc.cjs index 0d174d2..7049da9 100644 --- a/discord-plugin/com.discord.streamdeck.sdPlugin/bin/discord-rpc.cjs +++ b/discord-plugin/com.discord.streamdeck.sdPlugin/bin/discord-rpc.cjs @@ -14,6 +14,7 @@ const OPCODE_PONG = 4 const REQUEST_TIMEOUT_MS = 12_000 const RELAY_TIMEOUT_MS = 12_000 +const DEFAULT_DISCORD_REDIRECT_URI = 'http://127.0.0.1' async function postJson(url, payload, headers = {}) { const controller = new AbortController() @@ -353,10 +354,11 @@ class DiscordRpcClient { } } -async function exchangeAuthCode({ clientId, clientSecret, code, relayUrl, relayApiKey, relaySessionId }) { +async function exchangeAuthCode({ clientId, clientSecret, code, relayUrl, relayApiKey, relaySessionId, redirectUri }) { const relayBaseUrl = normalizeRelayUrl(relayUrl) const relayKey = String(relayApiKey || '').trim() const relaySession = String(relaySessionId || '').trim() + const redirect = String(redirectUri || DEFAULT_DISCORD_REDIRECT_URI).trim() if (relayBaseUrl) { if (!clientId) throw new Error('Missing client ID for relay exchange') @@ -364,7 +366,7 @@ async function exchangeAuthCode({ clientId, clientSecret, code, relayUrl, relayA const relayPayload = await postJson( `${relayBaseUrl}/oauth/discord/exchange`, - { clientId, code, sessionId: relaySession || null }, + { clientId, code, sessionId: relaySession || null, redirectUri: redirect }, relayKey ? { 'x-relay-key': relayKey } : {} ) @@ -387,6 +389,7 @@ async function exchangeAuthCode({ clientId, clientSecret, code, relayUrl, relayA client_secret: clientSecret, grant_type: 'authorization_code', code, + redirect_uri: redirect, }) const res = await fetch('https://discord.com/api/oauth2/token', { diff --git a/discord-plugin/com.discord.streamdeck.sdPlugin/bin/plugin.cjs b/discord-plugin/com.discord.streamdeck.sdPlugin/bin/plugin.cjs index ee2111c..3310a4a 100644 --- a/discord-plugin/com.discord.streamdeck.sdPlugin/bin/plugin.cjs +++ b/discord-plugin/com.discord.streamdeck.sdPlugin/bin/plugin.cjs @@ -13,6 +13,7 @@ const ACTION_PTT = 'com.discord.streamdeck.ptt' const ACTION_PTM = 'com.discord.streamdeck.ptm' const HOLD_ACTIONS = new Set([ACTION_PTT, ACTION_PTM]) +const DEFAULT_DISCORD_REDIRECT_URI = 'http://127.0.0.1' const rpc = new DiscordRpcClient({ logger: console }) @@ -34,6 +35,7 @@ function sanitizeSettings(settings = {}) { relayUrl: String(settings.relayUrl || '').trim(), relayApiKey: String(settings.relayApiKey || '').trim(), relaySessionId: String(settings.relaySessionId || '').trim(), + redirectUri: String(settings.redirectUri || DEFAULT_DISCORD_REDIRECT_URI).trim(), accessToken: String(settings.accessToken || '').trim(), refreshToken: String(settings.refreshToken || '').trim(), expiresAt: Number(settings.expiresAt || 0), @@ -257,6 +259,7 @@ async function handleInspectorMessage(actionUUID, payload) { relayUrl: s.relayUrl, relayApiKey: s.relayApiKey, relaySessionId: s.relaySessionId, + redirectUri: s.redirectUri, }) await rpc.authenticate(token.accessToken) diff --git a/discord-relay/README.md b/discord-relay/README.md index 9c37ed7..d4811c2 100644 --- a/discord-relay/README.md +++ b/discord-relay/README.md @@ -10,12 +10,36 @@ The Stream Deck Discord plugin calls this relay for OAuth token exchange and ref - `POST /oauth/discord/access` - `POST /oauth/discord/revoke` +The local server (`../discord-relay/local-server.mjs`) exposes the same endpoints for zero-host deployments. + ## Required bindings and secrets 1. Cloudflare KV namespace bound as `DISCORD_SESSIONS` 1. Secret: `DISCORD_CLIENT_SECRET` 1. Optional secret: `RELAY_API_KEY` +## Cloudflare Free Tier Plan + +This relay is designed for low-cost personal/small-team usage and works well on the Cloudflare Workers free tier. + +What users run locally: + +- Tech Stack Stream Deck app +- Discord desktop app + +What runs in Cloudflare: + +- OAuth code exchange (`/oauth/discord/exchange`) +- Access token refresh (`/oauth/discord/access`) +- Session revoke (`/oauth/discord/revoke`) +- Refresh-token session storage in KV (`DISCORD_SESSIONS`) + +This lets users click **Authorize** once in Discord, while keeping client secret and refresh tokens off their machine. + +Discord application requirement: + +- In Discord Developer Portal → **OAuth2**, add `http://127.0.0.1` to the app Redirects list. + ## Local dev 1. Install Wrangler @@ -32,6 +56,37 @@ The Stream Deck Discord plugin calls this relay for OAuth token exchange and ref 1. Start worker - `wrangler dev` +When running `wrangler dev`, use the local URL shown in terminal (usually `http://127.0.0.1:8787`) as the plugin `Relay URL`. + +## Local encrypted relay (no hosting cost) + +Run from the project root: + +```bash +npm run discord-relay:local:setup +``` + +Or run non-interactive mode: + +```bash +export DISCORD_CLIENT_SECRET="your-discord-client-secret" +export DISCORD_RELAY_MASTER_KEY="long-random-local-passphrase" +export RELAY_API_KEY="optional-local-relay-key" +npm run discord-relay:local +``` + +Defaults: + +- Listens on `http://127.0.0.1:8787` +- Encrypted session store at `~/.config/tech-stack-streamdeck/discord-relay-sessions.enc.json` +- Session TTL defaults to 30 days + +Optional overrides: + +- `DISCORD_RELAY_PORT` +- `DISCORD_RELAY_STORE` +- `SESSION_TTL_SECONDS` + ## Deploy 1. `wrangler deploy` @@ -39,8 +94,32 @@ The Stream Deck Discord plugin calls this relay for OAuth token exchange and ref 1. Put relay URL into the Discord action inspector 1. Put `RELAY_API_KEY` into the plugin setting if you enabled it +## Clone User Setup (Discord) + +1. Clone and run the main app +1. Install the Discord plugin package into `~/.config/tech-stack-streamdeck/plugins/` +1. Open Discord desktop app +1. In the plugin inspector, set: + - `Client ID` + - `Relay URL` (deployed Worker URL) + - `Relay API Key` (if enabled) +1. Click **Authorize** once + +After this, token refresh is automatic through relay session state. + ## Security notes - In hosted mode, the plugin should not store `clientSecret` or `refreshToken`. - The relay stores refresh tokens in KV with TTL (`SESSION_TTL_SECONDS`). - Rotate `RELAY_API_KEY` and `DISCORD_CLIENT_SECRET` regularly. + +## No-hosting-cost fallback + +If you do not want any hosted service cost, run the relay only on your machine: + +1. Export `DISCORD_CLIENT_SECRET` +1. Export `DISCORD_RELAY_MASTER_KEY` +1. Run `npm run discord-relay:local:setup` (or `npm run discord-relay:local` if env vars are already exported) +1. Put `http://127.0.0.1:8787` into plugin `Relay URL` + +This keeps costs at zero, but the local relay must be running while using Discord actions. diff --git a/discord-relay/local-server.mjs b/discord-relay/local-server.mjs new file mode 100644 index 0000000..a44041c --- /dev/null +++ b/discord-relay/local-server.mjs @@ -0,0 +1,336 @@ +import crypto from 'node:crypto' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { createServer } from 'node:http' + +const PORT = Number(process.env.DISCORD_RELAY_PORT || 8787) +const RELAY_API_KEY = String(process.env.RELAY_API_KEY || '').trim() +const DISCORD_CLIENT_SECRET = String(process.env.DISCORD_CLIENT_SECRET || '').trim() +const SESSION_TTL_SECONDS = clampTtlSeconds(process.env.SESSION_TTL_SECONDS) +const MASTER_KEY = String(process.env.DISCORD_RELAY_MASTER_KEY || '').trim() + +const STORE_PATH = String( + process.env.DISCORD_RELAY_STORE || + path.join(os.homedir(), '.config', 'tech-stack-streamdeck', 'discord-relay-sessions.enc.json') +) + +if (!DISCORD_CLIENT_SECRET) { + console.error('[discord-relay-local] DISCORD_CLIENT_SECRET is required') + process.exit(1) +} + +if (!MASTER_KEY) { + console.error('[discord-relay-local] DISCORD_RELAY_MASTER_KEY is required') + process.exit(1) +} + +const store = createEncryptedStore(STORE_PATH, MASTER_KEY) + +const server = createServer(async (request, response) => { + try { + const url = new URL(request.url || '/', `http://${request.headers.host || '127.0.0.1'}`) + + if (request.method === 'GET' && url.pathname === '/health') { + return sendJson(response, 200, { + ok: true, + service: 'discord-relay-local', + store: STORE_PATH, + }) + } + + if (request.method !== 'POST') { + return sendJson(response, 405, { error: 'Method not allowed' }) + } + + const authError = checkRelayAuth(request) + if (authError) return sendJson(response, authError.status, { error: authError.message }) + + if (url.pathname === '/oauth/discord/exchange') { + const body = await readJsonBody(request) + return handleExchange(response, body) + } + + if (url.pathname === '/oauth/discord/access') { + const body = await readJsonBody(request) + return handleAccess(response, body) + } + + if (url.pathname === '/oauth/discord/revoke') { + const body = await readJsonBody(request) + return handleRevoke(response, body) + } + + return sendJson(response, 404, { error: 'Not found' }) + } catch (err) { + return sendJson(response, 500, { error: err?.message || 'Unhandled relay error' }) + } +}) + +server.listen(PORT, '127.0.0.1', () => { + console.log('[discord-relay-local] running at http://127.0.0.1:%d', PORT) + console.log('[discord-relay-local] using encrypted store: %s', STORE_PATH) +}) + +async function handleExchange(response, body) { + const clientId = String(body?.clientId || '').trim() + const code = String(body?.code || '').trim() + const existingSessionId = String(body?.sessionId || '').trim() + const redirectUri = String(body?.redirectUri || process.env.DISCORD_REDIRECT_URI || 'http://127.0.0.1').trim() + + if (!clientId) return sendJson(response, 400, { error: 'Missing clientId' }) + if (!code) return sendJson(response, 400, { error: 'Missing authorization code' }) + + const discordPayload = await exchangeWithDiscord({ + clientId, + clientSecret: DISCORD_CLIENT_SECRET, + grantType: 'authorization_code', + code, + redirectUri, + }) + + const sessionId = existingSessionId || crypto.randomUUID() + + await store.writeSession(sessionId, { + clientId, + refreshToken: String(discordPayload.refresh_token || ''), + scope: discordPayload.scope || null, + tokenType: discordPayload.token_type || null, + updatedAt: Date.now(), + expiresAt: Date.now() + (SESSION_TTL_SECONDS * 1000), + }) + + return sendJson(response, 200, toClientTokenResponse(discordPayload, sessionId)) +} + +async function handleAccess(response, body) { + const clientId = String(body?.clientId || '').trim() + const sessionId = String(body?.sessionId || '').trim() + const legacyRefreshToken = String(body?.refreshToken || '').trim() + + if (!clientId) return sendJson(response, 400, { error: 'Missing clientId' }) + + let refreshToken = legacyRefreshToken + let session = null + + if (!refreshToken && sessionId) { + session = await store.readSession(sessionId) + if (!session) return sendJson(response, 404, { error: 'Relay session not found' }) + if (session.clientId !== clientId) return sendJson(response, 400, { error: 'Session client mismatch' }) + refreshToken = String(session.refreshToken || '') + } + + if (!refreshToken) { + return sendJson(response, 400, { error: 'Missing refresh token or relay session' }) + } + + const discordPayload = await exchangeWithDiscord({ + clientId, + clientSecret: DISCORD_CLIENT_SECRET, + grantType: 'refresh_token', + refreshToken, + }) + + if (sessionId) { + await store.writeSession(sessionId, { + clientId, + refreshToken: String(discordPayload.refresh_token || refreshToken), + scope: discordPayload.scope || session?.scope || null, + tokenType: discordPayload.token_type || session?.tokenType || null, + updatedAt: Date.now(), + expiresAt: Date.now() + (SESSION_TTL_SECONDS * 1000), + }) + } + + return sendJson(response, 200, toClientTokenResponse(discordPayload, sessionId || null)) +} + +async function handleRevoke(response, body) { + const sessionId = String(body?.sessionId || '').trim() + if (!sessionId) return sendJson(response, 400, { error: 'Missing sessionId' }) + + await store.deleteSession(sessionId) + return sendJson(response, 200, { revoked: true, sessionId }) +} + +function checkRelayAuth(request) { + if (!RELAY_API_KEY) return null + + const incomingKey = String(request.headers['x-relay-key'] || '').trim() + if (incomingKey && incomingKey === RELAY_API_KEY) return null + + return { status: 401, message: 'Unauthorized relay request' } +} + +async function exchangeWithDiscord({ clientId, clientSecret, grantType, code, refreshToken, redirectUri }) { + const params = new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + grant_type: grantType, + }) + + if (code) params.set('code', code) + if (grantType === 'authorization_code' && redirectUri) params.set('redirect_uri', redirectUri) + if (refreshToken) params.set('refresh_token', refreshToken) + + const res = await fetch('https://discord.com/api/oauth2/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params, + }) + + const payload = await safeJson(res) + if (!res.ok) { + const reason = payload?.error_description || payload?.error || `Discord HTTP ${res.status}` + throw new Error(`Discord token exchange failed: ${reason}`) + } + + return payload +} + +function toClientTokenResponse(discordPayload, sessionId) { + return { + accessToken: String(discordPayload.access_token || ''), + expiresAt: Date.now() + (Number(discordPayload.expires_in || 0) * 1000), + scope: discordPayload.scope || null, + tokenType: discordPayload.token_type || null, + sessionId: sessionId || null, + } +} + +function sendJson(response, status, payload) { + response.statusCode = status + response.setHeader('content-type', 'application/json; charset=utf-8') + response.setHeader('cache-control', 'no-store') + response.end(JSON.stringify(payload)) +} + +async function readJsonBody(request) { + const chunks = [] + for await (const chunk of request) chunks.push(Buffer.from(chunk)) + + const raw = Buffer.concat(chunks).toString('utf8') + if (!raw.trim()) return {} + + try { + return JSON.parse(raw) + } catch { + throw new Error('Invalid JSON body') + } +} + +async function safeJson(response) { + try { + return await response.json() + } catch { + return null + } +} + +function clampTtlSeconds(raw) { + const fallback = 60 * 60 * 24 * 30 + const value = Number(raw) + if (!Number.isFinite(value)) return fallback + if (value < 60) return 60 + if (value > 60 * 60 * 24 * 90) return 60 * 60 * 24 * 90 + return Math.floor(value) +} + +function createEncryptedStore(filePath, masterKey) { + const dir = path.dirname(filePath) + + return { + async readAll() { + await fs.mkdir(dir, { recursive: true }) + + let raw = '' + try { + raw = await fs.readFile(filePath, 'utf8') + } catch (err) { + if (err?.code === 'ENOENT') return {} + throw err + } + + if (!raw.trim()) return {} + + const container = JSON.parse(raw) + const key = deriveKey(masterKey, container.salt) + const decipher = crypto.createDecipheriv( + 'aes-256-gcm', + key, + Buffer.from(container.iv, 'base64') + ) + decipher.setAuthTag(Buffer.from(container.tag, 'base64')) + + const plain = Buffer.concat([ + decipher.update(Buffer.from(container.data, 'base64')), + decipher.final(), + ]) + + const sessions = JSON.parse(plain.toString('utf8')) + return pruneExpiredSessions(sessions) + }, + + async writeAll(sessions) { + await fs.mkdir(dir, { recursive: true }) + + const payload = Buffer.from(JSON.stringify(pruneExpiredSessions(sessions)), 'utf8') + const salt = crypto.randomBytes(16) + const iv = crypto.randomBytes(12) + const key = deriveKey(masterKey, salt) + + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv) + const encrypted = Buffer.concat([cipher.update(payload), cipher.final()]) + const tag = cipher.getAuthTag() + + const container = { + v: 1, + salt: salt.toString('base64'), + iv: iv.toString('base64'), + tag: tag.toString('base64'), + data: encrypted.toString('base64'), + } + + await fs.writeFile(filePath, JSON.stringify(container), 'utf8') + }, + + async readSession(sessionId) { + const sessions = await this.readAll() + return sessions[sessionId] || null + }, + + async writeSession(sessionId, data) { + const sessions = await this.readAll() + sessions[sessionId] = data + await this.writeAll(sessions) + }, + + async deleteSession(sessionId) { + const sessions = await this.readAll() + delete sessions[sessionId] + await this.writeAll(sessions) + }, + } +} + +function deriveKey(masterKey, saltOrBase64) { + const salt = Buffer.isBuffer(saltOrBase64) + ? saltOrBase64 + : Buffer.from(String(saltOrBase64 || ''), 'base64') + + return crypto.scryptSync(masterKey, salt, 32) +} + +function pruneExpiredSessions(sessions) { + const now = Date.now() + const next = {} + + for (const [sessionId, session] of Object.entries(sessions || {})) { + if (!session || typeof session !== 'object') continue + const expiresAt = Number(session.expiresAt || 0) + if (expiresAt && expiresAt < now) continue + next[sessionId] = session + } + + return next +} diff --git a/discord-relay/start-local.sh b/discord-relay/start-local.sh new file mode 100755 index 0000000..32d1833 --- /dev/null +++ b/discord-relay/start-local.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "Discord local relay setup" +echo "-------------------------" + +if ! command -v node >/dev/null 2>&1; then + echo "Error: node is not installed or not on PATH." + exit 1 +fi + +default_port="${DISCORD_RELAY_PORT:-8787}" +default_store="${DISCORD_RELAY_STORE:-$HOME/.config/tech-stack-streamdeck/discord-relay-sessions.enc.json}" + +echo +printf "Discord client secret: " +IFS= read -rs discord_client_secret +echo +if [[ -z "$discord_client_secret" ]]; then + echo "Error: DISCORD_CLIENT_SECRET is required." + exit 1 +fi + +echo +printf "Master key (used to encrypt local sessions): " +IFS= read -rs relay_master_key +echo +if [[ -z "$relay_master_key" ]]; then + echo "Error: DISCORD_RELAY_MASTER_KEY is required." + exit 1 +fi + +echo +printf "Relay API key (optional, press Enter to skip): " +IFS= read -rs relay_api_key +echo + +echo +printf "Relay port [%s]: " "$default_port" +IFS= read -r relay_port_input +relay_port="${relay_port_input:-$default_port}" + +printf "Encrypted session store path [%s]: " "$default_store" +IFS= read -r relay_store_input +relay_store="${relay_store_input:-$default_store}" + +export DISCORD_CLIENT_SECRET="$discord_client_secret" +export DISCORD_RELAY_MASTER_KEY="$relay_master_key" +export DISCORD_RELAY_PORT="$relay_port" +export DISCORD_RELAY_STORE="$relay_store" + +if [[ -n "$relay_api_key" ]]; then + export RELAY_API_KEY="$relay_api_key" +else + unset RELAY_API_KEY 2>/dev/null || true +fi + +echo +echo "Starting local relay on http://127.0.0.1:${DISCORD_RELAY_PORT}" +echo "Use this URL in the Discord inspector Relay URL field." + +action_dir="$(cd "$(dirname "$0")" && pwd)" +repo_root="$(cd "$action_dir/.." && pwd)" + +cd "$repo_root" +exec node discord-relay/local-server.mjs diff --git a/discord-relay/worker.mjs b/discord-relay/worker.mjs index 75de8fb..652410e 100644 --- a/discord-relay/worker.mjs +++ b/discord-relay/worker.mjs @@ -52,6 +52,7 @@ async function handleExchange(request, env) { const clientId = String(body?.clientId || '').trim() const code = String(body?.code || '').trim() const existingSessionId = String(body?.sessionId || '').trim() + const redirectUri = String(body?.redirectUri || env.DISCORD_REDIRECT_URI || 'http://127.0.0.1').trim() if (!clientId) return json({ error: 'Missing clientId' }, 400) if (!code) return json({ error: 'Missing authorization code' }, 400) @@ -61,6 +62,7 @@ async function handleExchange(request, env) { clientSecret: requiredSecret(env), grantType: 'authorization_code', code, + redirectUri, }) const sessionId = existingSessionId || crypto.randomUUID() @@ -125,7 +127,7 @@ async function handleRevoke(request, env) { return json({ revoked: true, sessionId }) } -async function exchangeWithDiscord({ clientId, clientSecret, grantType, code, refreshToken }) { +async function exchangeWithDiscord({ clientId, clientSecret, grantType, code, refreshToken, redirectUri }) { const params = new URLSearchParams({ client_id: clientId, client_secret: clientSecret, @@ -133,6 +135,7 @@ async function exchangeWithDiscord({ clientId, clientSecret, grantType, code, re }) if (code) params.set('code', code) + if (grantType === 'authorization_code' && redirectUri) params.set('redirect_uri', redirectUri) if (refreshToken) params.set('refresh_token', refreshToken) const res = await fetch('https://discord.com/api/oauth2/token', { diff --git a/package.json b/package.json index 30c465e..01521f2 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,9 @@ "electron:build": "vite build && electron-builder --linux", "package:local": "npm run build && npx electron-builder --linux dir --publish=never", "test:local": "npm run package:local && ./dist-electron/linux-unpacked/tech-stack-streamdeck", - "test:stress": "npm run package:local && SLEEP_STRESS=1 ./dist-electron/linux-unpacked/tech-stack-streamdeck" + "test:stress": "npm run package:local && SLEEP_STRESS=1 ./dist-electron/linux-unpacked/tech-stack-streamdeck", + "discord-relay:local": "node discord-relay/local-server.mjs", + "discord-relay:local:setup": "bash discord-relay/start-local.sh" }, "build": { "appId": "com.techstack.streamdeck",