10 Commits
Author SHA1 Message Date
yuzu 181a8f4325 🔧 chore: Rename to Discord Presence Relay 2026-07-25 13:42:39 +07:00
yuzu d046257205 feat: locking existing primary 2026-07-25 12:23:52 +07:00
yuzu 587fddb01d 🔧 chore: Update dependencies 2026-07-25 02:32:33 +07:00
yuzu 6f9221698a feat: primary promote and locking 2026-07-25 02:08:36 +07:00
yuzu a961132e2c 👷 ci: drop v prefix 2026-07-25 01:00:59 +07:00
yuzuandGitHub fcf1e5e4c3 📚 Update README 2026-07-21 03:07:42 +07:00
yuzuandGitHub ba31771a0e 📚 docs: update README.md 2026-07-21 03:07:09 +07:00
yuzuandGitHub 55dfb9ad67 📚 docs: Update FUNDING 2026-07-13 02:41:52 +07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
0ba8041a5a 📦 build(deps-dev): bump @types/node from 25.9.3 to 26.1.1 (#9)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 25.9.3 to 26.1.1.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 26.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-12 18:14:01 +07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
209da80f69 📦 build(deps-dev): bump electron from 42.4.0 to 43.1.0 (#10)
Bumps [electron](https://github.com/electron/electron) from 42.4.0 to 43.1.0.
- [Release notes](https://github.com/electron/electron/releases)
- [Commits](https://github.com/electron/electron/compare/v42.4.0...v43.1.0)

---
updated-dependencies:
- dependency-name: electron
  dependency-version: 42.4.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-12 17:48:05 +07:00
14 changed files with 1208 additions and 728 deletions
+1
View File
@@ -0,0 +1 @@
github: [YuzuZensai]
+2 -2
View File
@@ -5,7 +5,7 @@ on:
branches:
- main
tags:
- 'v*'
- '[0-9]*.[0-9]*.[0-9]*'
pull_request:
permissions:
@@ -102,7 +102,7 @@ jobs:
release:
needs: build
if: startsWith(github.ref, 'refs/tags/v')
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
permissions:
contents: write
+15 -15
View File
@@ -1,11 +1,11 @@
# Discord RPC Relay
# 🪩 Discord-Presence-Relay
![Screenshot](assets/screenshot.png)
Mirrors Discord Rich Presence (RPC) activity to every running Discord
Mirrors Discord Rich Presence activity to every running Discord
instance (stable, PTB, Canary, ...), so apps that only connect to the
primary `discord-ipc-0` socket show their presence everywhere.
![Screenshot](assets/screenshot.png)
It works by taking over `discord-ipc-0`, passing all traffic through to the
real primary instance unchanged, and forwarding the handshake and
`SET_ACTIVITY` frames to the other detected instances.
@@ -15,16 +15,16 @@ Linux and macOS only.
## Install
Download the latest build for your platform from the
[Releases page](https://github.com/YuzuZensai/Discord-RPC-Relay/releases).
[Releases page](https://github.com/YuzuZensai/Discord-Presence-Relay/releases).
### Linux (AppImage)
1. Download `discord-rpc-relay-<version>.AppImage`.
1. Download `discord-presence-relay-<version>.AppImage`.
2. Make it executable and run it:
```bash
chmod +x discord-rpc-relay-*.AppImage
./discord-rpc-relay-*.AppImage
chmod +x discord-presence-relay-*.AppImage
./discord-presence-relay-*.AppImage
```
The app runs from the tray. To launch it automatically and have it appear in
@@ -34,24 +34,24 @@ settings once it's running.
### Linux (.deb)
1. Download `discord-rpc-relay-<version>.deb`.
1. Download `discord-presence-relay-<version>.deb`.
2. Install it:
```bash
sudo apt install ./discord-rpc-relay-*.deb
sudo apt install ./discord-presence-relay-*.deb
```
3. Launch it from your application menu ("Discord RPC Relay"), or from a
3. Launch it from your application menu ("Discord Presence Relay"), or from a
terminal:
```bash
discord-rpc-relay
discord-presence-relay
```
### macOS
1. Download `discord-rpc-relay-<version>.dmg` and open it.
2. Drag **Discord RPC Relay** into the **Applications** folder.
1. Download `discord-presence-relay-<version>.dmg` and open it.
2. Drag **Discord Presence Relay** into the **Applications** folder.
3. Clear the Gatekeeper quarantine attribute before first launch (see below),
then open the app from Applications/Launchpad/Spotlight as usual.
@@ -66,7 +66,7 @@ developer"). After installing, clear the quarantine attribute from the
Terminal:
```bash
xattr -cr /Applications/Discord\ RPC\ Relay.app
xattr -cr /Applications/Discord\ Presence\ Relay.app
```
Then open the app normally.
+3 -3
View File
@@ -15,8 +15,8 @@ process.env.BUILD_VERSION = getBuildVersion()
* @type {import('electron-builder').Configuration}
*/
export default {
appId: 'cafe.kirameki.discord-rpc-relay',
productName: 'Discord RPC Relay',
appId: 'cafe.kirameki.discord-presence-relay',
productName: 'Discord Presence Relay',
files: [
'!**/.vscode/*',
'!src/*',
@@ -28,7 +28,7 @@ export default {
],
asarUnpack: ['resources/**'],
win: {
executableName: 'discord-rpc-relay'
executableName: 'discord-presence-relay'
},
nsis: {
artifactName: '${name}-${env.BUILD_VERSION}-setup.${ext}',
+15 -15
View File
@@ -1,9 +1,9 @@
{
"name": "discord-rpc-relay",
"name": "discord-presence-relay",
"version": "1.0.0",
"description": "Mirrors Discord Rich Presence (RPC) to multiple running Discord instances",
"description": "Mirrors Discord Rich Presence to multiple running Discord instances",
"main": "./out/main/index.js",
"packageManager": "pnpm@11.7.0+sha512.19cc852c120c7125760f2443ee6be0ca5b40f9f50598de1a09a1f177503e010e57c23c77646e01e761de59bf874fb22a3398c33ab9691fc13eb946b6f0f4d620",
"packageManager": "pnpm@11.17.0+sha512.cca3cea332ad254bb84145f966d19f4879615210346fc92c79a047f23a0d7b3cca3c3792f0076ba1f1831d277efbcf0a9119b31a9a60eca7fb3d6231f331ef72",
"author": "",
"private": true,
"scripts": {
@@ -25,27 +25,27 @@
"dependencies": {
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"koffi": "^3.0.2",
"lucide-react": "^1.18.0",
"react": "^19.2.7",
"react-dom": "^19.2.7"
"koffi": "^3.1.2",
"lucide-react": "^1.25.0",
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
"devDependencies": {
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
"@electron-toolkit/eslint-config-ts": "^3.1.0",
"@electron-toolkit/tsconfig": "^2.0.0",
"@tailwindcss/vite": "^4.3.1",
"@types/node": "^25.9.3",
"@tailwindcss/vite": "^4.3.3",
"@types/node": "^26.1.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0",
"electron": "^42.4.0",
"@vitejs/plugin-react": "^6.0.3",
"electron": "^43.2.0",
"electron-builder": "^26.15.3",
"electron-vite": "^5.0.0",
"eslint": "^10.5.0",
"prettier": "^3.8.4",
"tailwindcss": "^4.3.1",
"eslint": "^10.7.0",
"prettier": "^3.9.6",
"tailwindcss": "^4.3.3",
"typescript": "^6.0.3",
"vite": "^7.3.5"
"vite": "^8.1.5"
}
}
+713 -657
View File
File diff suppressed because it is too large Load Diff
+21 -6
View File
@@ -15,6 +15,7 @@ interface Settings {
disabledMirrors: number[]
blacklistedApps: BlacklistedApp[]
startMinimized: boolean
lockedPrimary: string | null
}
function parseBlacklistedApps(raw: unknown): BlacklistedApp[] {
@@ -33,10 +34,11 @@ function loadSettings(): Settings {
return {
disabledMirrors: Array.isArray(data?.disabledMirrors) ? data.disabledMirrors : [],
blacklistedApps: parseBlacklistedApps(data?.blacklistedApps),
startMinimized: data?.startMinimized === true
startMinimized: data?.startMinimized === true,
lockedPrimary: typeof data?.lockedPrimary === 'string' ? data.lockedPrimary : null
}
} catch {
return { disabledMirrors: [], blacklistedApps: [], startMinimized: false }
return { disabledMirrors: [], blacklistedApps: [], startMinimized: false, lockedPrimary: null }
}
}
@@ -51,7 +53,7 @@ const LINUX_AUTOSTART_DESKTOP_FILE = join(
app.getPath('home'),
'.config',
'autostart',
'cafe.kirameki.discord-rpc-relay.desktop'
'cafe.kirameki.discord-presence-relay.desktop'
)
function getLinuxAutostart(): boolean {
@@ -69,7 +71,7 @@ function setLinuxAutostart(enabled: boolean, startMinimized: boolean): boolean {
const desktopEntry = [
'[Desktop Entry]',
'Type=Application',
'Name=Discord RPC Relay',
'Name=Discord Presence Relay',
`Exec=${exec}`,
'Terminal=false',
'X-GNOME-Autostart-enabled=true'
@@ -150,7 +152,7 @@ function createWindow(): void {
function createTray(): void {
const trayIcon = nativeImage.createFromPath(icon).resize({ width: 24, height: 24 })
tray = new Tray(trayIcon)
tray.setToolTip('Discord RPC Relay')
tray.setToolTip('Discord Presence Relay')
updateTrayMenu(relay.getStatus())
@@ -208,7 +210,7 @@ app.on('second-instance', () => {
})
app.whenReady().then(() => {
electronApp.setAppUserModelId('cafe.kirameki.discord-rpc-relay')
electronApp.setAppUserModelId('cafe.kirameki.discord-presence-relay')
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
@@ -217,6 +219,7 @@ app.whenReady().then(() => {
const settings = loadSettings()
relay.setDisabledMirrors(settings.disabledMirrors)
relay.setBlacklistedApps(settings.blacklistedApps)
relay.setLockedPrimary(settings.lockedPrimary)
ipcMain.handle('relay:get-version', () => ({
version: app.getVersion(),
@@ -265,6 +268,18 @@ app.whenReady().then(() => {
saveSettings({ ...current, blacklistedApps: relay.getBlacklistedApps() })
return status
})
ipcMain.handle('relay:unlock-primary', () => {
const status = relay.unlockPrimary()
const current = loadSettings()
saveSettings({ ...current, lockedPrimary: relay.getLockedPrimary() })
return status
})
ipcMain.handle('relay:promote-to-primary', async (_e, index: number) => {
const status = await relay.promoteToPrimary(index)
const current = loadSettings()
saveSettings({ ...current, lockedPrimary: relay.getLockedPrimary() })
return status
})
relay.on('status', (status: RelayStatus) => {
updateTrayMenu(status)
+151 -7
View File
@@ -1,7 +1,7 @@
import * as fs from 'fs'
import * as net from 'net'
import * as path from 'path'
import { execFileSync } from 'child_process'
import { execFileSync, spawn } from 'child_process'
import { MAX_SOCKETS, type ClaimedSocket, type ProcessInfo, type RelayPlatform } from './types'
const REAL_PREFIX = 'discord-ipc-real-'
@@ -9,9 +9,27 @@ const FAKE_INDEX = 0
const ALIVE_CHECK_TIMEOUT_MS = 1000
const STALE_SOCKET_MIN_AGE_MS = 10_000
let darwinTempDir: string | null | undefined
// $TMPDIR can be overridden by a shell, so ask the OS where Discord really binds.
function resolveDarwinTempDir(): string | null {
if (darwinTempDir !== undefined) return darwinTempDir
try {
darwinTempDir = execFileSync('getconf', ['DARWIN_USER_TEMP_DIR'], {
encoding: 'utf8',
timeout: 1000
}).trim()
} catch {
darwinTempDir = null
}
return darwinTempDir
}
function runtimeDir(): string {
if (process.env.XDG_RUNTIME_DIR) return process.env.XDG_RUNTIME_DIR
if (process.platform === 'darwin') return process.env.TMPDIR ?? '/tmp'
if (process.platform === 'darwin') {
return resolveDarwinTempDir() ?? process.env.TMPDIR ?? '/tmp'
}
return `/run/user/${process.getuid?.() ?? 0}`
}
@@ -30,6 +48,10 @@ export class PosixPlatform implements RelayPlatform {
return ipcPath(FAKE_INDEX)
}
ipcSocketPath(index: number): string {
return ipcPath(index)
}
removeFakeSocket(fakePath: string): void {
if (fs.existsSync(fakePath)) fs.unlinkSync(fakePath)
}
@@ -118,14 +140,129 @@ export class PosixPlatform implements RelayPlatform {
return isSocketAlive(socketPath)
}
async removeStaleIpcSocket(index: number): Promise<void> {
const p = ipcPath(index)
if (!fs.existsSync(p)) return
if (await isSocketAlive(p)) return
try {
fs.unlinkSync(p)
} catch {
// Already gone or replaced.
}
}
getInstanceProcess(index: number): ProcessInfo | null {
// /proc/net/unix reports the original bind path even after the file is renamed on disk
// lsof/procfs still report the original bind path even after we rename to -real-N.
return findSocketOwner(ipcPath(index))
}
getPeerProcess(fd: number): ProcessInfo | null {
return findPeerProcess(fd)
}
killProcess(pid: number): boolean {
try {
process.kill(pid, 'SIGTERM')
return true
} catch {
return false
}
}
isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0)
return true
} catch (err) {
// EPERM means it's alive, we just aren't allowed to touch it.
return (err as NodeJS.ErrnoException).code === 'EPERM'
}
}
launchExecutable(executable: string): boolean {
try {
// Point Discord at the temp dir we watch, whatever TMPDIR we inherited.
const env = { ...process.env }
if (process.platform === 'darwin') {
// Spawning the .app binary directly just dies, so let `open` launch it.
const canonical = resolveDarwinTempDir()
if (canonical) env.TMPDIR = canonical
const appPath = executable.includes('.app/')
? executable.slice(0, executable.indexOf('.app/') + '.app'.length)
: executable
const child = spawn('open', ['-a', appPath], { detached: true, stdio: 'ignore', env })
child.unref()
return true
}
const child = spawn(executable, [], { detached: true, stdio: 'ignore', env })
child.unref()
return true
} catch {
return false
}
}
}
// The pid holding the socket is usually a helper; walk up to the real app process.
function resolveMainAppProcess(pid: number): ProcessInfo | null {
if (process.platform !== 'darwin') return null
const exe = executablePath(pid)
const bundle = bundleRoot(exe)
if (!bundle) return null
let mainPid = pid
let mainExe = exe
for (let depth = 0; depth < 20; depth++) {
const ppid = parentPid(mainPid)
if (ppid === null || ppid <= 1) break
const parentExe = executablePath(ppid)
if (bundleRoot(parentExe) !== bundle) break
mainPid = ppid
mainExe = parentExe
}
return { pid: mainPid, name: path.basename(mainExe ?? String(mainPid)), executable: mainExe }
}
function bundleRoot(exe: string | null): string | null {
if (!exe) return null
const idx = exe.indexOf('.app/Contents/')
return idx >= 0 ? exe.slice(0, idx + '.app'.length) : null
}
function parentPid(pid: number): number | null {
try {
const out = execFileSync('ps', ['-p', String(pid), '-o', 'ppid='], {
encoding: 'utf8',
timeout: 1000
}).trim()
const ppid = parseInt(out, 10)
return Number.isNaN(ppid) ? null : ppid
} catch {
return null
}
}
function executablePath(pid: number): string | null {
try {
if (process.platform === 'linux') {
return fs.readlinkSync(`/proc/${pid}/exe`)
}
if (process.platform === 'darwin') {
const out = execFileSync('ps', ['-p', String(pid), '-o', 'comm='], {
encoding: 'utf8',
timeout: 1000
}).trim()
return out || null
}
} catch {
return null
}
return null
}
function isSocketAlive(socketPath: string): Promise<boolean> {
@@ -176,7 +313,13 @@ function locateSocketOwnerMacOS(socketPath: string): ProcessInfo | null {
currentName = line.slice(1)
} else if (line.startsWith('n') && line.slice(1) === socketPath) {
if (currentPid > 0 && currentPid !== process.pid) {
return { pid: currentPid, name: currentName }
return (
resolveMainAppProcess(currentPid) ?? {
pid: currentPid,
name: currentName,
executable: executablePath(currentPid)
}
)
}
}
}
@@ -236,7 +379,8 @@ function findProcessForInodes(inodes: Set<string>): ProcessInfo | null {
}
const match = /^socket:\[(\d+)\]$/.exec(link)
if (match && inodes.has(match[1])) {
return { pid: parseInt(pidStr, 10), name: processName(pidStr) }
const pid = parseInt(pidStr, 10)
return { pid, name: processName(pidStr), executable: executablePath(pid) }
}
}
}
@@ -299,7 +443,7 @@ function findPeerProcess(localFd: number): ProcessInfo | null {
if (fn(localFd, SOL_LOCAL, LOCAL_PEERPID, optval, optlen) !== 0) return null
const pid = optval.readInt32LE(0)
if (pid <= 0) return null
return { pid, name: macProcessName(pid) }
return { pid, name: macProcessName(pid), executable: executablePath(pid) }
}
const optval = Buffer.alloc(UCRED_SIZE)
@@ -311,7 +455,7 @@ function findPeerProcess(localFd: number): ProcessInfo | null {
const pid = optval.readInt32LE(0)
if (pid <= 0) return null
return { pid, name: processName(String(pid)) }
return { pid, name: processName(String(pid)), executable: executablePath(pid) }
}
function macProcessName(pid: number): string {
+11
View File
@@ -3,6 +3,7 @@ export const MAX_SOCKETS = 10
export interface ProcessInfo {
pid: number
name: string
executable: string | null
}
export interface ClaimedSocket {
@@ -15,6 +16,8 @@ export interface RelayPlatform {
fakeSocketPath(): string
ipcSocketPath(index: number): string
removeFakeSocket(fakePath: string): void
finalizeFakeSocket(fakePath: string): void
@@ -34,7 +37,15 @@ export interface RelayPlatform {
isSocketAlive(socketPath: string): Promise<boolean>
removeStaleIpcSocket(index: number): Promise<void>
getInstanceProcess(index: number): ProcessInfo | null
getPeerProcess(fd: number): ProcessInfo | null
killProcess(pid: number): boolean
isProcessAlive(pid: number): boolean
launchExecutable(executable: string): boolean
}
+20
View File
@@ -9,6 +9,10 @@ export class UnsupportedPlatform implements RelayPlatform {
throw NOT_SUPPORTED
}
ipcSocketPath(): string {
throw NOT_SUPPORTED
}
removeFakeSocket(): void {
throw NOT_SUPPORTED
}
@@ -45,6 +49,10 @@ export class UnsupportedPlatform implements RelayPlatform {
return false
}
async removeStaleIpcSocket(): Promise<void> {
throw NOT_SUPPORTED
}
getInstanceProcess(): ProcessInfo | null {
return null
}
@@ -52,4 +60,16 @@ export class UnsupportedPlatform implements RelayPlatform {
getPeerProcess(): ProcessInfo | null {
return null
}
killProcess(): boolean {
return false
}
isProcessAlive(): boolean {
return false
}
launchExecutable(): boolean {
return false
}
}
+156 -3
View File
@@ -11,6 +11,11 @@ const MIRROR_RECONNECT_DELAY_MS = 2000
const SERVER_CLOSE_TIMEOUT_MS = 2000
const ASSET_FETCH_RETRY_MS = 60_000
const PROCESS_EXIT_TIMEOUT_MS = 8000
const PROCESS_EXIT_POLL_MS = 200
const SLOT_BIND_TIMEOUT_MS = 20_000
const SLOT_BIND_POLL_MS = 300
interface AppAsset {
id: string
type: number
@@ -46,6 +51,10 @@ async function getAppAssetMap(appId: string): Promise<Map<string, string>> {
return map
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function clearActivityPayload(pid: number): Buffer {
return Buffer.from(
JSON.stringify({ cmd: 'SET_ACTIVITY', args: { pid, activity: null }, nonce: randomUUID() }),
@@ -82,6 +91,7 @@ export interface RelayInstance {
path: string
isPrimary: boolean
enabled: boolean
locked: boolean
process: ProcessInfo | null
}
@@ -132,6 +142,9 @@ export interface RelayStatus {
instances: RelayInstance[]
connectedClients: ConnectedClient[]
blacklistedApps: BlacklistedApp[]
lockedPrimary: string | null
reordering: boolean
primaryOutOfOrder: boolean
error: string | null
}
@@ -166,15 +179,21 @@ export class RpcRelay extends EventEmitter {
private fakeOwned = false
private lastError: string | null = null
private restarting = false
private lockedPrimary: string | null = null
private reordering = false
getStatus(): RelayStatus {
const instances: RelayInstance[] = this.claimed.map(({ index, path: socketPath }, i) => ({
const instances: RelayInstance[] = this.claimed.map(({ index, path: socketPath }, i) => {
const process = platform.getInstanceProcess(index)
return {
index,
path: socketPath,
isPrimary: i === 0,
enabled: i === 0 || !this.disabledMirrors.has(index),
process: platform.getInstanceProcess(index)
}))
locked: this.isLockedInstance(process),
process
}
})
return {
running: this.running,
@@ -189,10 +208,27 @@ export class RpcRelay extends EventEmitter {
activity: s.activity
})),
blacklistedApps: this.getBlacklistedApps(),
lockedPrimary: this.lockedPrimary,
reordering: this.reordering,
primaryOutOfOrder: this.primaryOutOfOrder(instances),
error: this.lastError
}
}
private isLockedInstance(process: ProcessInfo | null): boolean {
return (
this.lockedPrimary !== null &&
process?.executable != null &&
process.executable === this.lockedPrimary
)
}
private primaryOutOfOrder(instances: RelayInstance[]): boolean {
if (this.lockedPrimary === null) return false
const locked = instances.find((inst) => inst.locked)
return locked !== undefined && !locked.isPrimary
}
setMirrorEnabled(index: number, enabled: boolean): RelayStatus {
if (index === this.primaryIndex()) return this.getStatus()
@@ -214,6 +250,33 @@ export class RpcRelay extends EventEmitter {
this.disabledMirrors = new Set(indices)
}
getLockedPrimary(): string | null {
return this.lockedPrimary
}
setLockedPrimary(executable: string | null): void {
this.lockedPrimary = executable
this.emitStatus()
}
unlockPrimary(): RelayStatus {
this.lockedPrimary = null
this.emitStatus()
return this.getStatus()
}
async promoteToPrimary(index: number): Promise<RelayStatus> {
const process = platform.getInstanceProcess(index)
if (!process?.executable) {
this.lastError = 'Cannot resolve this Discords executable path'
this.emitStatus()
return this.getStatus()
}
this.lockedPrimary = process.executable
this.emitStatus()
return this.reorderForPrimary()
}
getBlacklistedApps(): BlacklistedApp[] {
return [...this.blacklistedApps].map(([id, name]) => ({ id, name }))
}
@@ -462,6 +525,17 @@ export class RpcRelay extends EventEmitter {
}
if (changed) this.emitStatus()
if (this.lockedPrimary !== null) {
const lockedSlot = this.claimed.findIndex(
(c) => platform.getInstanceProcess(c.index)?.executable === this.lockedPrimary
)
if (lockedSlot > 0) {
this.discovering = false
await this.reorderForPrimary()
return
}
}
} catch (err) {
this.lastError = err instanceof Error ? err.message : String(err)
this.emitStatus()
@@ -588,6 +662,85 @@ export class RpcRelay extends EventEmitter {
}
}
private async reorderForPrimary(): Promise<RelayStatus> {
if (this.reordering) return this.getStatus()
if (this.lockedPrimary === null) return this.getStatus()
const lockedSlot = this.claimed.findIndex(
(c) => platform.getInstanceProcess(c.index)?.executable === this.lockedPrimary
)
if (lockedSlot < 0) {
this.lastError = 'Locked Discord is not currently running'
this.emitStatus()
return this.getStatus()
}
if (lockedSlot === 0) return this.getStatus()
this.reordering = true
this.lastError = null
this.emitStatus()
try {
// Grab what we need now before stop() wipes claimed below.
const others: string[] = []
const toKill: number[] = []
const freeIndices: number[] = []
for (let i = 0; i <= lockedSlot; i++) {
freeIndices.push(this.claimed[i].index)
const proc = platform.getInstanceProcess(this.claimed[i].index)
if (!proc) continue
toKill.push(proc.pid)
if (proc.executable && proc.executable !== this.lockedPrimary) {
others.push(proc.executable)
}
}
const lockedExecutable = this.lockedPrimary
await this.stop()
for (const pid of toKill) platform.killProcess(pid)
await this.waitForProcessesExit(toKill)
for (const index of freeIndices) {
await platform.removeStaleIpcSocket(index)
}
if (!platform.launchExecutable(lockedExecutable)) {
throw new Error('Failed to relaunch the locked Discord')
}
await this.waitForSlotBound(0)
for (const executable of others) platform.launchExecutable(executable)
} catch (err) {
this.lastError = err instanceof Error ? err.message : String(err)
} finally {
this.reordering = false
await this.start().catch((err) => {
this.lastError = err instanceof Error ? err.message : String(err)
})
this.emitStatus()
}
return this.getStatus()
}
private async waitForProcessesExit(pids: number[]): Promise<void> {
const deadline = Date.now() + PROCESS_EXIT_TIMEOUT_MS
while (Date.now() < deadline) {
if (pids.every((pid) => !platform.isProcessAlive(pid))) return
await delay(PROCESS_EXIT_POLL_MS)
}
}
private async waitForSlotBound(index: number): Promise<void> {
const deadline = Date.now() + SLOT_BIND_TIMEOUT_MS
while (Date.now() < deadline) {
if (await platform.isSocketAlive(platform.ipcSocketPath(index))) return
await delay(SLOT_BIND_POLL_MS)
}
throw new Error('Timed out waiting for the locked Discord to start')
}
private mirrorFrame(session: ClientSession, frame: Frame): void {
const payload = frame.op !== OP_HANDSHAKE ? parseFramePayload(frame) : null
const isSetActivity = payload?.cmd === 'SET_ACTIVITY'
+3
View File
@@ -18,6 +18,9 @@ const api = {
ipcRenderer.invoke('relay:set-mirror-enabled', index, enabled),
setAppBlacklisted: (appId: string, blacklisted: boolean): Promise<RelayStatus> =>
ipcRenderer.invoke('relay:set-app-blacklisted', appId, blacklisted),
unlockPrimary: (): Promise<RelayStatus> => ipcRenderer.invoke('relay:unlock-primary'),
promoteToPrimary: (index: number): Promise<RelayStatus> =>
ipcRenderer.invoke('relay:promote-to-primary', index),
onStatus: (callback: (status: RelayStatus) => void): (() => void) => {
const listener = (_e: unknown, status: RelayStatus): void => callback(status)
ipcRenderer.on('relay:status', listener)
+22 -2
View File
@@ -12,6 +12,9 @@ const EMPTY_STATUS: RelayStatus = {
instances: [],
connectedClients: [],
blacklistedApps: [],
lockedPrimary: null,
reordering: false,
primaryOutOfOrder: false,
error: null
}
@@ -266,6 +269,14 @@ export function App(): React.JSX.Element {
setStatus(await window.api.setAppBlacklisted(appId, blacklisted))
}
const onUnlockPrimary = async (): Promise<void> => {
setStatus(await window.api.unlockPrimary())
}
const onPromote = async (index: number): Promise<void> => {
setStatus(await window.api.promoteToPrimary(index))
}
const onToggleAutostart = async (enabled: boolean): Promise<void> => {
setAutostart(await window.api.setAutostart(enabled))
}
@@ -277,7 +288,7 @@ export function App(): React.JSX.Element {
if (status.unsupported) {
return (
<div className="flex flex-col h-screen items-center justify-center p-5 gap-3 text-center">
<h1 className="text-lg font-semibold">Discord RPC Relay</h1>
<h1 className="text-lg font-semibold">Discord Presence Relay</h1>
<p className="text-sm text-zinc-400">Windows is not supported.</p>
</div>
)
@@ -364,7 +375,7 @@ export function App(): React.JSX.Element {
return (
<div className="flex flex-col h-screen p-5 gap-4">
<header className="flex items-center justify-between gap-3">
<h1 className="text-lg font-semibold">Discord RPC Relay</h1>
<h1 className="text-lg font-semibold">Discord Presence Relay</h1>
<Button
variant="ghost"
onClick={() => setView('settings')}
@@ -411,13 +422,22 @@ export function App(): React.JSX.Element {
<InstanceRow
key={instance.index}
instance={instance}
reordering={status.reordering}
onToggleMirror={onToggleMirror}
onUnlock={onUnlockPrimary}
onPromote={onPromote}
/>
))
) : (
<div className="text-zinc-500">No Discord instances detected</div>
)}
</div>
{status.reordering && (
<div className="mt-1 text-xs text-amber-300/90">
Restarting Discord clients to promote the new primary
</div>
)}
</section>
<section className="rounded-xl bg-zinc-800/60 border border-zinc-700 p-4 flex flex-col gap-2">
+67 -10
View File
@@ -1,6 +1,7 @@
import { Info } from 'lucide-react'
import { ArrowUpToLine, Lock, LockOpen } from 'lucide-react'
import type { RelayInstance } from '../../../main/relay'
import { Toggle } from './Toggle'
import { Button } from './Button'
function shortPath(p: string): string {
return p.split('/').pop() ?? p
@@ -8,14 +9,71 @@ function shortPath(p: string): string {
interface Props {
instance: RelayInstance
reordering: boolean
onToggleMirror: (index: number, enabled: boolean) => Promise<void>
onUnlock: () => Promise<void>
onPromote: (index: number) => Promise<void>
}
export function InstanceRow({ instance, onToggleMirror }: Props): React.JSX.Element {
export function InstanceRow({
instance,
reordering,
onToggleMirror,
onUnlock,
onPromote
}: Props): React.JSX.Element {
const procLabel = instance.process
? `${instance.process.name} (pid ${instance.process.pid})`
: 'unknown process'
const canPromote = instance.process?.executable != null
const promoteButton = (
<Button
variant="ghost"
className="p-1 shrink-0"
disabled={!canPromote || reordering}
onClick={() => onPromote(instance.index)}
aria-label="Promote to primary"
title={
!canPromote
? 'Cannot resolve this Discords executable path'
: 'Make this Discord primary: restarts Discord clients so it starts first.'
}
>
<ArrowUpToLine className="w-4 h-4 text-emerald-400" />
</Button>
)
const unlockButton = (
<Button
variant="ghost"
className="p-1 shrink-0"
onClick={() => onUnlock()}
aria-label="Unlock primary"
title="Locked as primary. Click to unlock."
>
<Lock className="w-4 h-4 text-amber-400" />
</Button>
)
const lockButton = (
<Button
variant="ghost"
className="p-1 shrink-0"
disabled={!canPromote || reordering}
onClick={() => onPromote(instance.index)}
aria-label="Lock as primary"
title={
!canPromote
? 'Cannot resolve this Discords executable path'
: 'Lock this Discord as primary so it stays first even after restarts.'
}
>
<LockOpen className="w-4 h-4 text-zinc-400 hover:text-zinc-100 transition-colors" />
</Button>
)
const info = (
<div className="flex flex-col">
<div className="flex items-center gap-2">
@@ -29,6 +87,7 @@ export function InstanceRow({ instance, onToggleMirror }: Props): React.JSX.Elem
mirror #{instance.index}
</span>
)}
{instance.locked && <span className="text-xs text-amber-400">locked</span>}
</div>
<span className="text-xs text-zinc-500">{procLabel}</span>
</div>
@@ -38,14 +97,9 @@ export function InstanceRow({ instance, onToggleMirror }: Props): React.JSX.Elem
return (
<div className="flex items-center justify-between gap-2">
{info}
<Info className="w-4 h-4 text-zinc-400 hover:text-zinc-100 cursor-help transition-colors">
<title>
The primary instance gets full passthrough and can&apos;t be turned off. It&apos;s
always the Discord instance that started first (discord-ipc-0). To make a different
instance primary, close this one first so the other claims that slot, then restart the
relay.
</title>
</Info>
<div className="flex items-center gap-1 shrink-0">
{instance.locked ? unlockButton : lockButton}
</div>
</div>
)
}
@@ -53,10 +107,13 @@ export function InstanceRow({ instance, onToggleMirror }: Props): React.JSX.Elem
return (
<div className="flex items-center justify-between gap-2">
{info}
<div className="flex items-center gap-1 shrink-0">
{promoteButton}
<Toggle
checked={instance.enabled}
onChange={(checked) => onToggleMirror(instance.index, checked)}
/>
</div>
</div>
)
}