diff --git a/rp-app/.gitignore b/rp-app/.gitignore new file mode 100644 index 0000000000..94510244f1 --- /dev/null +++ b/rp-app/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.DS_Store +*.log diff --git a/rp-app/README.md b/rp-app/README.md new file mode 100644 index 0000000000..daf64a9fbc --- /dev/null +++ b/rp-app/README.md @@ -0,0 +1,88 @@ +# Discord Rich Presence Controller + +A simple Electron app to set custom Discord Rich Presence status. + +## Setup + +1. Install dependencies: + ```bash + npm install + ``` + +2. Run the app: + ```bash + npm start + ``` + +## Usage + +1. **Get a Discord Application ID**: + - Go to [Discord Developer Portal](https://discord.com/developers/applications) + - Create a new application (or use an existing one) + - Copy the "Application ID" from the General Information page + +2. **Set up Rich Presence Assets** (optional): + - In your Discord application, go to "Rich Presence" → "Art Assets" + - Upload images you want to use for large/small icons + - Note the asset names for use in the app + +3. **Connect and Customize**: + - Paste your Application ID in the app + - Click "Connect" + - Fill in the fields you want to display + - Click "Update Presence" + +## Fields + +| Field | Description | Max Length | +|-------|-------------|------------| +| Details | First line of text | 128 chars | +| State | Second line of text | 128 chars | +| Large Image | Asset name or image URL | - | +| Large Image Text | Hover text for large image | 128 chars | +| Small Image | Asset name or image URL | - | +| Small Image Text | Hover text for small image | 128 chars | +| Button 1/2 Label | Button text | 32 chars | +| Button 1/2 URL | Button link | Valid URL | + +## Activity Types + +- **Playing** - "Playing {details}" +- **Streaming** - "Streaming {details}" +- **Listening** - "Listening to {details}" +- **Watching** - "Watching {details}" +- **Competing** - "Competing in {details}" + +## Timestamps + +- **Elapsed**: Shows "XX:XX elapsed" +- **Remaining**: Shows "XX:XX left" (requires duration) + +## Notes + +- Discord must be running for Rich Presence to work +- Buttons are only visible to other users (not yourself) +- Image URLs must be publicly accessible HTTPS URLs +- Changes may take a few seconds to appear in Discord + +## Architecture + +This app follows the same Discord RPC implementation pattern as [YouTube Music Desktop](https://github.com/pear-devs/pear-desktop): + +``` +src/ +├── main/ +│ ├── index.js # Electron main process +│ ├── discord-service.js # Discord RPC service +│ ├── timer-manager.js # Timer management +│ └── constants.js # Constants +├── renderer/ +│ ├── index.html # UI +│ ├── styles.css # Styles +│ └── renderer.js # UI logic +└── preload.js # IPC bridge +``` + +## License + +MIT diff --git a/rp-app/package.json b/rp-app/package.json new file mode 100644 index 0000000000..e0010b8ffc --- /dev/null +++ b/rp-app/package.json @@ -0,0 +1,18 @@ +{ + "name": "rp", + "version": "1.0.0", + "description": "Custom Discord Rich Presence Controller", + "main": "src/main/index.js", + "scripts": { + "start": "electron .", + "dev": "electron ." + }, + "author": "", + "license": "MIT", + "devDependencies": { + "electron": "^33.0.0" + }, + "dependencies": { + "@xhayper/discord-rpc": "^1.3.0" + } +} diff --git a/rp-app/src/main/constants.js b/rp-app/src/main/constants.js new file mode 100644 index 0000000000..646024fbdf --- /dev/null +++ b/rp-app/src/main/constants.js @@ -0,0 +1,8 @@ +/** + * Enum for keys used in TimerManager. + */ +const TimerKey = { + DiscordConnectRetry: 'discordConnectRetry', +}; + +module.exports = { TimerKey }; diff --git a/rp-app/src/main/discord-service.js b/rp-app/src/main/discord-service.js new file mode 100644 index 0000000000..078e204efd --- /dev/null +++ b/rp-app/src/main/discord-service.js @@ -0,0 +1,319 @@ +const { Client: DiscordClient } = require('@xhayper/discord-rpc'); +const { TimerManager } = require('./timer-manager'); +const { TimerKey } = require('./constants'); + +/** + * Discord Rich Presence Service + * Handles connection and activity updates to Discord + */ +class DiscordService { + constructor() { + this.rpc = null; + this.clientId = null; + this.ready = false; + this.autoReconnect = true; + this.timerManager = new TimerManager(); + this.currentActivity = null; + this.onStatusChange = null; + } + + /** + * Initialize the Discord RPC client with a client ID + * @param {string} clientId - Discord Application ID + */ + init(clientId) { + if (this.rpc) { + this.disconnect(); + } + + this.clientId = clientId; + this.rpc = new DiscordClient({ clientId }); + + this.rpc.on('connected', () => { + console.log('[Discord] Connected'); + this._notifyStatus('connected'); + }); + + this.rpc.on('ready', () => { + this.ready = true; + console.log('[Discord] Ready'); + this._notifyStatus('ready'); + + // If we have a pending activity, set it now + if (this.currentActivity) { + this.updateActivity(this.currentActivity); + } + }); + + this.rpc.on('disconnected', () => { + this.ready = false; + console.log('[Discord] Disconnected'); + this._notifyStatus('disconnected'); + + if (this.autoReconnect) { + this._connectRecursive(); + } + }); + } + + /** + * Notify status change to callback + * @param {string} status + */ + _notifyStatus(status) { + if (this.onStatusChange) { + this.onStatusChange(status); + } + } + + /** + * Attempts to connect to Discord RPC after a delay + */ + _connectWithRetry() { + return new Promise((resolve, reject) => { + this.timerManager.set( + TimerKey.DiscordConnectRetry, + () => { + if (!this.autoReconnect || (this.rpc && this.rpc.isConnected)) { + this.timerManager.clear(TimerKey.DiscordConnectRetry); + if (this.rpc && this.rpc.isConnected) resolve(); + else reject(new Error('Auto-reconnect disabled or already connected.')); + return; + } + + this.rpc + .login() + .then(() => { + this.timerManager.clear(TimerKey.DiscordConnectRetry); + resolve(); + }) + .catch(() => { + this._connectRecursive(); + }); + }, + 5000 + ); + }); + } + + /** + * Recursively attempts to connect + */ + _connectRecursive() { + if (!this.autoReconnect || (this.rpc && this.rpc.isConnected)) { + this.timerManager.clear(TimerKey.DiscordConnectRetry); + return; + } + this._connectWithRetry(); + } + + /** + * Connect to Discord + */ + connect() { + if (!this.rpc) { + throw new Error('Discord client not initialized. Call init() first.'); + } + + if (this.rpc.isConnected) { + console.log('[Discord] Already connected'); + return; + } + + this.autoReconnect = true; + this.timerManager.clear(TimerKey.DiscordConnectRetry); + + this.rpc.login().catch((err) => { + console.error('[Discord] Connection failed:', err.message); + this._notifyStatus('error'); + + if (this.autoReconnect) { + this._connectRecursive(); + } + }); + } + + /** + * Disconnect from Discord + */ + disconnect() { + this.autoReconnect = false; + this.timerManager.clear(TimerKey.DiscordConnectRetry); + + if (this.rpc && this.rpc.isConnected) { + try { + this.rpc.destroy(); + } catch (e) { + // Ignored + } + } + + this.ready = false; + this.currentActivity = null; + this._notifyStatus('disconnected'); + } + + /** + * Update Discord Rich Presence activity + * @param {Object} activity - Activity object + */ + updateActivity(activity) { + this.currentActivity = activity; + + if (!this.rpc || !this.ready) { + console.log('[Discord] Not ready, activity cached for later'); + return; + } + + // Build the activity payload + const payload = this._buildActivityPayload(activity); + + this.rpc.user + ?.setActivity(payload) + .then(() => { + console.log('[Discord] Activity updated'); + this._notifyStatus('activity_updated'); + }) + .catch((err) => { + console.error('[Discord] Failed to set activity:', err.message); + }); + } + + /** + * Build Discord activity payload from user input + * @param {Object} activity + */ + _buildActivityPayload(activity) { + const payload = {}; + + // Activity type (Playing, Listening, Watching, Competing) + if (activity.type !== undefined) { + payload.type = activity.type; + } + + // Status display type - controls what shows in "Listening to X" / "Playing X" + // 0 = App Name, 1 = State field, 2 = Details field + if (activity.statusDisplayType !== undefined) { + payload.statusDisplayType = activity.statusDisplayType; + } + + // Details (first line) - min 2 chars required + if (activity.details && activity.details.trim()) { + payload.details = this._padToMinLength(this._truncate(activity.details, 128)); + } + // Details URL (makes details clickable) + if (activity.detailsUrl && activity.detailsUrl.trim()) { + payload.detailsUrl = activity.detailsUrl; + } + + // State (second line) - min 2 chars required + if (activity.state && activity.state.trim()) { + payload.state = this._padToMinLength(this._truncate(activity.state, 128)); + } + // State URL (makes state clickable) + if (activity.stateUrl && activity.stateUrl.trim()) { + payload.stateUrl = activity.stateUrl; + } + + // Large image + if (activity.largeImageKey && activity.largeImageKey.trim()) { + payload.largeImageKey = activity.largeImageKey; + } + if (activity.largeImageText && activity.largeImageText.trim()) { + payload.largeImageText = this._padToMinLength(this._truncate(activity.largeImageText, 128)); + } + + // Small image + if (activity.smallImageKey && activity.smallImageKey.trim()) { + payload.smallImageKey = activity.smallImageKey; + } + if (activity.smallImageText && activity.smallImageText.trim()) { + payload.smallImageText = this._padToMinLength(this._truncate(activity.smallImageText, 128)); + } + + // Timestamps + if (activity.useTimestamp) { + if (activity.timestampMode === 'elapsed') { + payload.startTimestamp = Math.floor(Date.now() / 1000); + } else if (activity.timestampMode === 'remaining' && activity.endTime) { + payload.startTimestamp = Math.floor(Date.now() / 1000); + payload.endTimestamp = Math.floor((Date.now() + activity.endTime * 1000) / 1000); + } + } + + // Buttons (max 2) + const buttons = []; + if (activity.button1Label && activity.button1Url) { + buttons.push({ + label: this._truncate(activity.button1Label, 32), + url: activity.button1Url, + }); + } + if (activity.button2Label && activity.button2Url) { + buttons.push({ + label: this._truncate(activity.button2Label, 32), + url: activity.button2Url, + }); + } + if (buttons.length > 0) { + payload.buttons = buttons; + } + + return payload; + } + + /** + * Truncate string to max length + */ + _truncate(str, maxLength) { + if (!str) return str; + if (str.length <= maxLength) return str; + return str.substring(0, maxLength - 3) + '...'; + } + + /** + * Pad string to minimum length (Discord requires min 2 chars) + * Uses Unicode Hangul filler character (invisible) + */ + _padToMinLength(str, minLength = 2) { + if (!str) return str; + const FILLER = '\u3164'; // Hangul filler (invisible) + if (str.length > 0 && str.length < minLength) { + return str + FILLER.repeat(minLength - str.length); + } + return str; + } + + /** + * Clear Discord activity + */ + clearActivity() { + this.currentActivity = null; + + if (this.rpc && this.ready) { + this.rpc.user?.clearActivity(); + console.log('[Discord] Activity cleared'); + this._notifyStatus('activity_cleared'); + } + } + + /** + * Check if connected + */ + isConnected() { + return this.rpc && this.rpc.isConnected && this.ready; + } + + /** + * Cleanup + */ + cleanup() { + this.disconnect(); + this.timerManager.clearAll(); + } +} + +// Singleton instance +const discordService = new DiscordService(); + +module.exports = { discordService, DiscordService }; diff --git a/rp-app/src/main/index.js b/rp-app/src/main/index.js new file mode 100644 index 0000000000..677335ac10 --- /dev/null +++ b/rp-app/src/main/index.js @@ -0,0 +1,101 @@ +const { app, BrowserWindow, ipcMain } = require('electron'); +const path = require('path'); +const { discordService } = require('./discord-service'); + +let mainWindow = null; + +function createWindow() { + mainWindow = new BrowserWindow({ + width: 500, + height: 750, + resizable: true, + webPreferences: { + preload: path.join(__dirname, '..', 'preload.js'), + contextIsolation: true, + nodeIntegration: false, + }, + backgroundColor: '#1a1a2e', + title: 'Discord Rich Presence', + }); + + mainWindow.loadFile(path.join(__dirname, '..', 'renderer', 'index.html')); + + // Set up status change callback + discordService.onStatusChange = (status) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('discord:status', status); + } + }; +} + +// IPC Handlers +ipcMain.handle('discord:init', (_, clientId) => { + try { + discordService.init(clientId); + return { success: true }; + } catch (err) { + return { success: false, error: err.message }; + } +}); + +ipcMain.handle('discord:connect', () => { + try { + discordService.connect(); + return { success: true }; + } catch (err) { + return { success: false, error: err.message }; + } +}); + +ipcMain.handle('discord:disconnect', () => { + try { + discordService.disconnect(); + return { success: true }; + } catch (err) { + return { success: false, error: err.message }; + } +}); + +ipcMain.handle('discord:updateActivity', (_, activity) => { + try { + discordService.updateActivity(activity); + return { success: true }; + } catch (err) { + return { success: false, error: err.message }; + } +}); + +ipcMain.handle('discord:clearActivity', () => { + try { + discordService.clearActivity(); + return { success: true }; + } catch (err) { + return { success: false, error: err.message }; + } +}); + +ipcMain.handle('discord:isConnected', () => { + return discordService.isConnected(); +}); + +// App lifecycle +app.whenReady().then(() => { + createWindow(); + + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) { + createWindow(); + } + }); +}); + +app.on('window-all-closed', () => { + discordService.cleanup(); + if (process.platform !== 'darwin') { + app.quit(); + } +}); + +app.on('before-quit', () => { + discordService.cleanup(); +}); diff --git a/rp-app/src/main/timer-manager.js b/rp-app/src/main/timer-manager.js new file mode 100644 index 0000000000..58ffb156a8 --- /dev/null +++ b/rp-app/src/main/timer-manager.js @@ -0,0 +1,43 @@ +/** + * Manages NodeJS Timers, ensuring only one timer exists per key. + */ +class TimerManager { + constructor() { + this.timers = new Map(); + } + + /** + * Sets a timer for a given key, clearing any existing timer with the same key. + * @param {string} key - The unique key for the timer. + * @param {Function} fn - The function to execute after the delay. + * @param {number} delay - The delay in milliseconds. + */ + set(key, fn, delay) { + this.clear(key); + this.timers.set(key, setTimeout(fn, delay)); + } + + /** + * Clears the timer associated with the given key. + * @param {string} key - The key of the timer to clear. + */ + clear(key) { + const timer = this.timers.get(key); + if (timer) { + clearTimeout(timer); + this.timers.delete(key); + } + } + + /** + * Clears all managed timers. + */ + clearAll() { + for (const timer of this.timers.values()) { + clearTimeout(timer); + } + this.timers.clear(); + } +} + +module.exports = { TimerManager }; diff --git a/rp-app/src/preload.js b/rp-app/src/preload.js new file mode 100644 index 0000000000..931c17677c --- /dev/null +++ b/rp-app/src/preload.js @@ -0,0 +1,13 @@ +const { contextBridge, ipcRenderer } = require('electron'); + +contextBridge.exposeInMainWorld('discord', { + init: (clientId) => ipcRenderer.invoke('discord:init', clientId), + connect: () => ipcRenderer.invoke('discord:connect'), + disconnect: () => ipcRenderer.invoke('discord:disconnect'), + updateActivity: (activity) => ipcRenderer.invoke('discord:updateActivity', activity), + clearActivity: () => ipcRenderer.invoke('discord:clearActivity'), + isConnected: () => ipcRenderer.invoke('discord:isConnected'), + onStatus: (callback) => { + ipcRenderer.on('discord:status', (_, status) => callback(status)); + }, +}); diff --git a/rp-app/src/renderer/index.html b/rp-app/src/renderer/index.html new file mode 100644 index 0000000000..9f07dc50a7 --- /dev/null +++ b/rp-app/src/renderer/index.html @@ -0,0 +1,172 @@ + + +
+ + + +