diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 976bd10..4255179 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,23 +21,16 @@ permissions: contents: read jobs: - # ─── Job 1: Run all unit tests (+ bump version on push events) ──────────────── + # ─── Job 1: Run all unit tests ──────────────────────────────────────────────── test: name: Unit Tests runs-on: ubuntu-22.04 - # Version bump needs write access on push events; PRs only need read permissions: - contents: write - - outputs: - new-version: ${{ steps.bump.outputs.new-version }} + contents: read steps: - name: Checkout uses: actions/checkout@v6 - with: - # Use GITHUB_TOKEN so the bot can push the version bump commit back - token: ${{ secrets.GITHUB_TOKEN }} - name: Setup Node.js uses: actions/setup-node@v6 @@ -45,84 +38,12 @@ jobs: node-version: '24' cache: 'npm' - # ── Version bump (push events only, not PRs, not uat) ───────────────────── - # Display version format: RELEASE.BETA.ALPHA.BUILD (stored in build.extraMetadata.version) - # Semver format: RELEASE.BETA.ALPHA (stored in version — required by electron-builder) - # - # develop → increment BUILD (4th digit only) - # alpha → increment ALPHA (3rd digit), reset BUILD to 0 - # beta → increment BETA (2nd digit), reset ALPHA and BUILD to 0 - # 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' && github.actor != 'dependabot[bot]' - run: | - # Read the 4-part display version from extraMetadata - EXT_VERSION=$(node -p "require('./package.json').build.extraMetadata.version") - IFS='.' read -r RELEASE BETA ALPHA BUILD <<< "$EXT_VERSION" - - case "${{ github.ref_name }}" in - develop) - BUILD=$((BUILD + 1)) - ;; - alpha) - ALPHA=$((ALPHA + 1)) - BUILD=0 - ;; - beta) - BETA=$((BETA + 1)) - ALPHA=0 - BUILD=0 - ;; - main) - RELEASE=$((RELEASE + 1)) - BETA=0 - ALPHA=0 - BUILD=0 - ;; - esac - - NEW_EXT_VERSION="${RELEASE}.${BETA}.${ALPHA}.${BUILD}" - # 3-part semver for electron-builder (strips the BUILD digit) - NEW_VERSION="${RELEASE}.${BETA}.${ALPHA}" - - node -e " - const fs = require('fs'); - const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); - pkg.version = '${NEW_VERSION}'; - pkg.build.extraMetadata.version = '${NEW_EXT_VERSION}'; - fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); - " - echo "new-version=${NEW_EXT_VERSION}" >> "$GITHUB_OUTPUT" - echo "Bumped to: ${NEW_EXT_VERSION} (semver: ${NEW_VERSION})" - - name: Install dependencies run: npm ci - name: Run unit tests run: npm run test:run - # 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' && github.actor != 'dependabot[bot]' && steps.bump.outputs.new-version != '' - run: | - NEW_VERSION="${{ steps.bump.outputs.new-version }}" - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add package.json - # Only commit if package.json actually changed (idempotent guard) - git diff --cached --quiet || git commit -m "chore: bump version to ${NEW_VERSION} [skip ci]" - git push - - # 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' && github.actor != 'dependabot[bot]' && steps.bump.outputs.new-version != '' - run: | - TAG="release/${{ steps.bump.outputs.new-version }}" - git tag "$TAG" - git push origin "$TAG" - # ─── Job 2: Build the .deb installer (only runs when all tests pass) ────────── build: name: Build Linux DEB @@ -135,7 +56,6 @@ jobs: actions: write # required to list and delete artifacts steps: - # Checkout the branch tip (picks up the version bump commit from the test job) - name: Checkout uses: actions/checkout@v6 with: diff --git a/README.md b/README.md index 109c083..50c20e5 100644 --- a/README.md +++ b/README.md @@ -225,9 +225,26 @@ The Scene, Scene Collection, Source, Input, and Transition pickers in the Proper 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` 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 +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 + +Hosted relay quick start (free, Cloudflare Workers): + +```bash +# from repo root +cd discord-relay +wrangler login +wrangler kv namespace create DISCORD_SESSIONS +wrangler kv namespace create DISCORD_SESSIONS --preview +# paste generated KV IDs into discord-relay/wrangler.toml +wrangler secret put DISCORD_CLIENT_SECRET +wrangler secret put RELAY_API_KEY +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`. --- 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 c7a2797..0d174d2 100644 --- a/discord-plugin/com.discord.streamdeck.sdPlugin/bin/discord-rpc.cjs +++ b/discord-plugin/com.discord.streamdeck.sdPlugin/bin/discord-rpc.cjs @@ -13,6 +13,45 @@ const OPCODE_PING = 3 const OPCODE_PONG = 4 const REQUEST_TIMEOUT_MS = 12_000 +const RELAY_TIMEOUT_MS = 12_000 + +async function postJson(url, payload, headers = {}) { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), RELAY_TIMEOUT_MS) + + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...headers }, + body: JSON.stringify(payload || {}), + signal: controller.signal, + }) + + let data = null + try { data = await res.json() } catch {} + + if (!res.ok) { + const reason = data?.error || data?.message || `HTTP ${res.status}` + throw new Error(reason) + } + + return data || {} + } catch (err) { + if (err?.name === 'AbortError') throw new Error('Relay request timed out') + throw err + } finally { + clearTimeout(timeout) + } +} + +function normalizeRelayUrl(relayUrl) { + const base = String(relayUrl || '').trim().replace(/\/+$/, '') + if (!base) return '' + if (!/^https?:\/\//i.test(base)) { + throw new Error('Relay URL must start with http:// or https://') + } + return base +} function makeNonce() { return crypto.randomBytes(8).toString('hex') @@ -314,7 +353,31 @@ class DiscordRpcClient { } } -async function exchangeAuthCode({ clientId, clientSecret, code }) { +async function exchangeAuthCode({ clientId, clientSecret, code, relayUrl, relayApiKey, relaySessionId }) { + const relayBaseUrl = normalizeRelayUrl(relayUrl) + const relayKey = String(relayApiKey || '').trim() + const relaySession = String(relaySessionId || '').trim() + + if (relayBaseUrl) { + if (!clientId) throw new Error('Missing client ID for relay exchange') + if (!code) throw new Error('Missing authorization code from Discord') + + const relayPayload = await postJson( + `${relayBaseUrl}/oauth/discord/exchange`, + { clientId, code, sessionId: relaySession || null }, + relayKey ? { 'x-relay-key': relayKey } : {} + ) + + return { + accessToken: relayPayload.accessToken || '', + refreshToken: relayPayload.refreshToken || null, + sessionId: relayPayload.sessionId || relaySession || null, + expiresAt: Number(relayPayload.expiresAt || 0), + scope: relayPayload.scope || null, + tokenType: relayPayload.tokenType || null, + } + } + 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') @@ -349,7 +412,31 @@ async function exchangeAuthCode({ clientId, clientSecret, code }) { } } -async function refreshAccessToken({ clientId, clientSecret, refreshToken }) { +async function refreshAccessToken({ clientId, clientSecret, refreshToken, relayUrl, relayApiKey, relaySessionId }) { + const relayBaseUrl = normalizeRelayUrl(relayUrl) + const relayKey = String(relayApiKey || '').trim() + const relaySession = String(relaySessionId || '').trim() + + if (relayBaseUrl) { + if (!clientId) throw new Error('Missing client ID for relay refresh') + if (!relaySession && !refreshToken) throw new Error('Missing relay session or refresh token') + + const relayPayload = await postJson( + `${relayBaseUrl}/oauth/discord/access`, + { clientId, sessionId: relaySession || null, refreshToken: refreshToken || null }, + relayKey ? { 'x-relay-key': relayKey } : {} + ) + + return { + accessToken: relayPayload.accessToken || '', + refreshToken: relayPayload.refreshToken || refreshToken || null, + sessionId: relayPayload.sessionId || relaySession || null, + expiresAt: Number(relayPayload.expiresAt || 0), + scope: relayPayload.scope || null, + tokenType: relayPayload.tokenType || null, + } + } + 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') diff --git a/discord-plugin/com.discord.streamdeck.sdPlugin/bin/plugin.cjs b/discord-plugin/com.discord.streamdeck.sdPlugin/bin/plugin.cjs index a7c96d9..ee2111c 100644 --- a/discord-plugin/com.discord.streamdeck.sdPlugin/bin/plugin.cjs +++ b/discord-plugin/com.discord.streamdeck.sdPlugin/bin/plugin.cjs @@ -31,6 +31,9 @@ function sanitizeSettings(settings = {}) { return { clientId: String(settings.clientId || '').trim(), clientSecret: String(settings.clientSecret || '').trim(), + relayUrl: String(settings.relayUrl || '').trim(), + relayApiKey: String(settings.relayApiKey || '').trim(), + relaySessionId: String(settings.relaySessionId || '').trim(), accessToken: String(settings.accessToken || '').trim(), refreshToken: String(settings.refreshToken || '').trim(), expiresAt: Number(settings.expiresAt || 0), @@ -60,6 +63,33 @@ async function ensureAuthenticated(actionUUID, settings) { clientId: s.clientId, clientSecret: s.clientSecret, refreshToken: s.refreshToken, + relayUrl: s.relayUrl, + relayApiKey: s.relayApiKey, + relaySessionId: s.relaySessionId, + }) + + await rpc.authenticate(refreshed.accessToken) + + const patch = { + accessToken: refreshed.accessToken, + expiresAt: refreshed.expiresAt, + scope: refreshed.scope, + tokenType: refreshed.tokenType, + } + if (refreshed.refreshToken) patch.refreshToken = refreshed.refreshToken + if (refreshed.sessionId) patch.relaySessionId = refreshed.sessionId + if (s.relayUrl) { patch.clientSecret = ''; patch.refreshToken = '' } + + sendToInspector(actionUUID, { type: 'patchSettings', patch }) + return { ...s, ...refreshed } + } + + if (s.relayUrl && s.relaySessionId) { + const refreshed = await refreshAccessToken({ + clientId: s.clientId, + relayUrl: s.relayUrl, + relayApiKey: s.relayApiKey, + relaySessionId: s.relaySessionId, }) await rpc.authenticate(refreshed.accessToken) @@ -68,10 +98,12 @@ async function ensureAuthenticated(actionUUID, settings) { type: 'patchSettings', patch: { accessToken: refreshed.accessToken, - refreshToken: refreshed.refreshToken, expiresAt: refreshed.expiresAt, scope: refreshed.scope, tokenType: refreshed.tokenType, + relaySessionId: refreshed.sessionId || s.relaySessionId, + clientSecret: '', + refreshToken: '', }, }) @@ -222,19 +254,23 @@ async function handleInspectorMessage(actionUUID, payload) { clientId: s.clientId, clientSecret: s.clientSecret, code: auth.code, + relayUrl: s.relayUrl, + relayApiKey: s.relayApiKey, + relaySessionId: s.relaySessionId, }) 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, - }, - }) + const patch = { + accessToken: token.accessToken, + expiresAt: token.expiresAt, + scope: token.scope, + tokenType: token.tokenType, + } + if (token.refreshToken) patch.refreshToken = token.refreshToken + if (token.sessionId) patch.relaySessionId = token.sessionId + if (s.relayUrl) { patch.clientSecret = ''; patch.refreshToken = '' } + + sendToInspector(actionUUID, { type: 'patchSettings', patch }) sendToInspector(actionUUID, { type: 'status', diff --git a/discord-plugin/com.discord.streamdeck.sdPlugin/ui/inspector.html b/discord-plugin/com.discord.streamdeck.sdPlugin/ui/inspector.html index c5a507a..c3a5e2d 100644 --- a/discord-plugin/com.discord.streamdeck.sdPlugin/ui/inspector.html +++ b/discord-plugin/com.discord.streamdeck.sdPlugin/ui/inspector.html @@ -140,8 +140,14 @@