Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 2 additions & 82 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,108 +21,29 @@ 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
with:
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
Expand All @@ -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:
Expand Down
23 changes: 20 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

---

Expand Down
91 changes: 89 additions & 2 deletions discord-plugin/com.discord.streamdeck.sdPlugin/bin/discord-rpc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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')
Expand Down
58 changes: 47 additions & 11 deletions discord-plugin/com.discord.streamdeck.sdPlugin/bin/plugin.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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)
Expand All @@ -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: '',
},
})

Expand Down Expand Up @@ -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',
Expand Down
Loading