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 6cd5594..4457e07 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",