13 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
yuzu 7ea3354bf6 feat: Per-client sessions, app blacklist, robustness rework 2026-07-12 14:28:46 +07:00
yuzuandGitHub 4674142f52 📚 docs: LICENSE 2026-06-16 03:50:18 +07:00
yuzuGitHubCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
998e7d6fa8 👷 ci: set workflow permissions
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-06-16 03:44:02 +07:00
19 changed files with 1919 additions and 957 deletions
+1
View File
@@ -0,0 +1 @@
github: [YuzuZensai]
+5 -2
View File
@@ -5,9 +5,12 @@ on:
branches: branches:
- main - main
tags: tags:
- 'v*' - '[0-9]*.[0-9]*.[0-9]*'
pull_request: pull_request:
permissions:
contents: read
jobs: jobs:
check: check:
strategy: strategy:
@@ -99,7 +102,7 @@ jobs:
release: release:
needs: build needs: build
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: write contents: write
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 YuzuZensai
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+15 -15
View File
@@ -1,11 +1,11 @@
# Discord RPC Relay # 🪩 Discord-Presence-Relay
![Screenshot](assets/screenshot.png) Mirrors Discord Rich Presence activity to every running Discord
Mirrors Discord Rich Presence (RPC) activity to every running Discord
instance (stable, PTB, Canary, ...), so apps that only connect to the instance (stable, PTB, Canary, ...), so apps that only connect to the
primary `discord-ipc-0` socket show their presence everywhere. 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 It works by taking over `discord-ipc-0`, passing all traffic through to the
real primary instance unchanged, and forwarding the handshake and real primary instance unchanged, and forwarding the handshake and
`SET_ACTIVITY` frames to the other detected instances. `SET_ACTIVITY` frames to the other detected instances.
@@ -15,16 +15,16 @@ Linux and macOS only.
## Install ## Install
Download the latest build for your platform from the 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) ### Linux (AppImage)
1. Download `discord-rpc-relay-<version>.AppImage`. 1. Download `discord-presence-relay-<version>.AppImage`.
2. Make it executable and run it: 2. Make it executable and run it:
```bash ```bash
chmod +x discord-rpc-relay-*.AppImage chmod +x discord-presence-relay-*.AppImage
./discord-rpc-relay-*.AppImage ./discord-presence-relay-*.AppImage
``` ```
The app runs from the tray. To launch it automatically and have it appear in 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) ### Linux (.deb)
1. Download `discord-rpc-relay-<version>.deb`. 1. Download `discord-presence-relay-<version>.deb`.
2. Install it: 2. Install it:
```bash ```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: terminal:
```bash ```bash
discord-rpc-relay discord-presence-relay
``` ```
### macOS ### macOS
1. Download `discord-rpc-relay-<version>.dmg` and open it. 1. Download `discord-presence-relay-<version>.dmg` and open it.
2. Drag **Discord RPC Relay** into the **Applications** folder. 2. Drag **Discord Presence Relay** into the **Applications** folder.
3. Clear the Gatekeeper quarantine attribute before first launch (see below), 3. Clear the Gatekeeper quarantine attribute before first launch (see below),
then open the app from Applications/Launchpad/Spotlight as usual. then open the app from Applications/Launchpad/Spotlight as usual.
@@ -66,7 +66,7 @@ developer"). After installing, clear the quarantine attribute from the
Terminal: Terminal:
```bash ```bash
xattr -cr /Applications/Discord\ RPC\ Relay.app xattr -cr /Applications/Discord\ Presence\ Relay.app
``` ```
Then open the app normally. Then open the app normally.
+3 -3
View File
@@ -15,8 +15,8 @@ process.env.BUILD_VERSION = getBuildVersion()
* @type {import('electron-builder').Configuration} * @type {import('electron-builder').Configuration}
*/ */
export default { export default {
appId: 'cafe.kirameki.discord-rpc-relay', appId: 'cafe.kirameki.discord-presence-relay',
productName: 'Discord RPC Relay', productName: 'Discord Presence Relay',
files: [ files: [
'!**/.vscode/*', '!**/.vscode/*',
'!src/*', '!src/*',
@@ -28,7 +28,7 @@ export default {
], ],
asarUnpack: ['resources/**'], asarUnpack: ['resources/**'],
win: { win: {
executableName: 'discord-rpc-relay' executableName: 'discord-presence-relay'
}, },
nsis: { nsis: {
artifactName: '${name}-${env.BUILD_VERSION}-setup.${ext}', 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", "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", "main": "./out/main/index.js",
"packageManager": "pnpm@11.7.0+sha512.19cc852c120c7125760f2443ee6be0ca5b40f9f50598de1a09a1f177503e010e57c23c77646e01e761de59bf874fb22a3398c33ab9691fc13eb946b6f0f4d620", "packageManager": "pnpm@11.17.0+sha512.cca3cea332ad254bb84145f966d19f4879615210346fc92c79a047f23a0d7b3cca3c3792f0076ba1f1831d277efbcf0a9119b31a9a60eca7fb3d6231f331ef72",
"author": "", "author": "",
"private": true, "private": true,
"scripts": { "scripts": {
@@ -25,27 +25,27 @@
"dependencies": { "dependencies": {
"@electron-toolkit/preload": "^3.0.2", "@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0", "@electron-toolkit/utils": "^4.0.0",
"koffi": "^3.0.2", "koffi": "^3.1.2",
"lucide-react": "^1.18.0", "lucide-react": "^1.25.0",
"react": "^19.2.7", "react": "^19.2.8",
"react-dom": "^19.2.7" "react-dom": "^19.2.8"
}, },
"devDependencies": { "devDependencies": {
"@electron-toolkit/eslint-config-prettier": "^3.0.0", "@electron-toolkit/eslint-config-prettier": "^3.0.0",
"@electron-toolkit/eslint-config-ts": "^3.1.0", "@electron-toolkit/eslint-config-ts": "^3.1.0",
"@electron-toolkit/tsconfig": "^2.0.0", "@electron-toolkit/tsconfig": "^2.0.0",
"@tailwindcss/vite": "^4.3.1", "@tailwindcss/vite": "^4.3.3",
"@types/node": "^25.9.3", "@types/node": "^26.1.1",
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0", "@vitejs/plugin-react": "^6.0.3",
"electron": "^42.4.0", "electron": "^43.2.0",
"electron-builder": "^26.15.3", "electron-builder": "^26.15.3",
"electron-vite": "^5.0.0", "electron-vite": "^5.0.0",
"eslint": "^10.5.0", "eslint": "^10.7.0",
"prettier": "^3.8.4", "prettier": "^3.9.6",
"tailwindcss": "^4.3.1", "tailwindcss": "^4.3.3",
"typescript": "^6.0.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
+1
View File
@@ -7,6 +7,7 @@ onlyBuiltDependencies:
minimumReleaseAge: 4320 minimumReleaseAge: 4320
allowBuilds: allowBuilds:
electron: true
electron-winstaller: true electron-winstaller: true
esbuild: true esbuild: true
koffi: true koffi: true
+72 -12
View File
@@ -3,7 +3,7 @@ import { join } from 'path'
import * as fs from 'fs' import * as fs from 'fs'
import { electronApp, optimizer, is } from '@electron-toolkit/utils' import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset' import icon from '../../resources/icon.png?asset'
import { relay, RelayStatus } from './relay' import { relay, BlacklistedApp, RelayStatus } from './relay'
declare const __COMMIT_HASH__: string declare const __COMMIT_HASH__: string
@@ -13,7 +13,18 @@ function settingsPath(): string {
interface Settings { interface Settings {
disabledMirrors: number[] disabledMirrors: number[]
blacklistedApps: BlacklistedApp[]
startMinimized: boolean startMinimized: boolean
lockedPrimary: string | null
}
function parseBlacklistedApps(raw: unknown): BlacklistedApp[] {
if (!Array.isArray(raw)) return []
return raw.flatMap((entry): BlacklistedApp[] =>
entry && typeof entry.id === 'string'
? [{ id: entry.id, name: typeof entry.name === 'string' ? entry.name : null }]
: []
)
} }
function loadSettings(): Settings { function loadSettings(): Settings {
@@ -22,22 +33,27 @@ function loadSettings(): Settings {
const data = JSON.parse(raw) const data = JSON.parse(raw)
return { return {
disabledMirrors: Array.isArray(data?.disabledMirrors) ? data.disabledMirrors : [], disabledMirrors: Array.isArray(data?.disabledMirrors) ? data.disabledMirrors : [],
startMinimized: data?.startMinimized === true blacklistedApps: parseBlacklistedApps(data?.blacklistedApps),
startMinimized: data?.startMinimized === true,
lockedPrimary: typeof data?.lockedPrimary === 'string' ? data.lockedPrimary : null
} }
} catch { } catch {
return { disabledMirrors: [], startMinimized: false } return { disabledMirrors: [], blacklistedApps: [], startMinimized: false, lockedPrimary: null }
} }
} }
function saveSettings(settings: Settings): void { function saveSettings(settings: Settings): void {
fs.writeFileSync(settingsPath(), JSON.stringify(settings), 'utf8') const target = settingsPath()
const tmp = `${target}.tmp`
fs.writeFileSync(tmp, JSON.stringify(settings), 'utf8')
fs.renameSync(tmp, target)
} }
const LINUX_AUTOSTART_DESKTOP_FILE = join( const LINUX_AUTOSTART_DESKTOP_FILE = join(
app.getPath('home'), app.getPath('home'),
'.config', '.config',
'autostart', 'autostart',
'cafe.kirameki.discord-rpc-relay.desktop' 'cafe.kirameki.discord-presence-relay.desktop'
) )
function getLinuxAutostart(): boolean { function getLinuxAutostart(): boolean {
@@ -55,7 +71,7 @@ function setLinuxAutostart(enabled: boolean, startMinimized: boolean): boolean {
const desktopEntry = [ const desktopEntry = [
'[Desktop Entry]', '[Desktop Entry]',
'Type=Application', 'Type=Application',
'Name=Discord RPC Relay', 'Name=Discord Presence Relay',
`Exec=${exec}`, `Exec=${exec}`,
'Terminal=false', 'Terminal=false',
'X-GNOME-Autostart-enabled=true' 'X-GNOME-Autostart-enabled=true'
@@ -136,7 +152,7 @@ function createWindow(): void {
function createTray(): void { function createTray(): void {
const trayIcon = nativeImage.createFromPath(icon).resize({ width: 24, height: 24 }) const trayIcon = nativeImage.createFromPath(icon).resize({ width: 24, height: 24 })
tray = new Tray(trayIcon) tray = new Tray(trayIcon)
tray.setToolTip('Discord RPC Relay') tray.setToolTip('Discord Presence Relay')
updateTrayMenu(relay.getStatus()) updateTrayMenu(relay.getStatus())
@@ -194,7 +210,7 @@ app.on('second-instance', () => {
}) })
app.whenReady().then(() => { app.whenReady().then(() => {
electronApp.setAppUserModelId('cafe.kirameki.discord-rpc-relay') electronApp.setAppUserModelId('cafe.kirameki.discord-presence-relay')
app.on('browser-window-created', (_, window) => { app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window) optimizer.watchWindowShortcuts(window)
@@ -202,6 +218,8 @@ app.whenReady().then(() => {
const settings = loadSettings() const settings = loadSettings()
relay.setDisabledMirrors(settings.disabledMirrors) relay.setDisabledMirrors(settings.disabledMirrors)
relay.setBlacklistedApps(settings.blacklistedApps)
relay.setLockedPrimary(settings.lockedPrimary)
ipcMain.handle('relay:get-version', () => ({ ipcMain.handle('relay:get-version', () => ({
version: app.getVersion(), version: app.getVersion(),
@@ -244,6 +262,24 @@ app.whenReady().then(() => {
saveSettings({ ...current, disabledMirrors: relay.getDisabledMirrors() }) saveSettings({ ...current, disabledMirrors: relay.getDisabledMirrors() })
return status return status
}) })
ipcMain.handle('relay:set-app-blacklisted', (_e, appId: string, blacklisted: boolean) => {
const status = relay.setAppBlacklisted(appId, blacklisted)
const current = loadSettings()
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) => { relay.on('status', (status: RelayStatus) => {
updateTrayMenu(status) updateTrayMenu(status)
@@ -260,16 +296,40 @@ app.whenReady().then(() => {
}) })
}) })
app.on('window-all-closed', () => { app.on('window-all-closed', () => {})
// Keep running in the tray
})
let stopping = false let stopping = false
const QUIT_TIMEOUT_MS = 5000
app.on('before-quit', (e) => { app.on('before-quit', (e) => {
quitting = true quitting = true
if (stopping) return if (stopping) return
stopping = true stopping = true
e.preventDefault() e.preventDefault()
relay.stop().finally(() => app.exit(0)) const timeout = new Promise<void>((resolve) => setTimeout(resolve, QUIT_TIMEOUT_MS))
Promise.race([relay.stop(), timeout])
.catch(() => {})
.finally(() => {
relay.emergencyRestoreSync()
app.exit(0)
})
})
// Electron doesn't reliably turn SIGTERM/SIGHUP into a quit on Linux.
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const) {
process.on(signal, () => {
quitting = true
app.quit()
})
}
process.on('uncaughtException', (err) => {
console.error('Uncaught exception:', err)
relay.emergencyRestoreSync()
app.exit(1)
})
process.on('unhandledRejection', (reason) => {
console.error('Unhandled rejection:', reason)
}) })
-2
View File
@@ -10,7 +10,6 @@ export interface Frame {
payload: Buffer payload: Buffer
} }
/** Buffers stream chunks and yields complete IPC frames as they arrive. */
export class FrameReader { export class FrameReader {
private buf = Buffer.alloc(0) private buf = Buffer.alloc(0)
@@ -39,7 +38,6 @@ export function encodeFrame(op: number, payload: Buffer): Buffer {
return Buffer.concat([header, payload]) return Buffer.concat([header, payload])
} }
/** Parses a non-handshake frame's JSON payload, returning null if it isn't JSON. */
export function parseFramePayload(frame: Frame): Record<string, unknown> | null { export function parseFramePayload(frame: Frame): Record<string, unknown> | null {
try { try {
return JSON.parse(frame.payload.toString('utf8')) return JSON.parse(frame.payload.toString('utf8'))
+10 -9
View File
@@ -1,10 +1,7 @@
import * as net from 'net' import * as net from 'net'
import { encodeFrame, FrameReader, OP_FRAME, OP_HANDSHAKE } from './ipc-protocol' import { encodeFrame, FrameReader, OP_FRAME, OP_HANDSHAKE } from './ipc-protocol'
/** const READY_TIMEOUT_MS = 10_000
* A lazily-established connection to a secondary Discord instance that
* mirrors the primary connection's handshake and SET_ACTIVITY frames.
*/
export class MirrorConnection { export class MirrorConnection {
private readonly sock: net.Socket private readonly sock: net.Socket
@@ -16,23 +13,28 @@ export class MirrorConnection {
constructor(socketPath: string, handshakePayload: Buffer, onClose: () => void) { constructor(socketPath: string, handshakePayload: Buffer, onClose: () => void) {
this.sock = net.createConnection(socketPath) this.sock = net.createConnection(socketPath)
const readyTimeout = setTimeout(() => this.sock.destroy(), READY_TIMEOUT_MS)
readyTimeout.unref()
this.sock.on('connect', () => { this.sock.on('connect', () => {
this.sock.write(encodeFrame(OP_HANDSHAKE, handshakePayload)) this.sock.write(encodeFrame(OP_HANDSHAKE, handshakePayload))
}) })
// Discord sends a READY dispatch after the handshake; only once that
// arrives will it accept further commands like SET_ACTIVITY.
this.sock.on('data', (chunk: Buffer) => { this.sock.on('data', (chunk: Buffer) => {
const hadFrames = this.reader.push(chunk).length > 0 const hadFrames = this.reader.push(chunk).length > 0
if (hadFrames && !this.ready) { if (hadFrames && !this.ready) {
this.ready = true this.ready = true
clearTimeout(readyTimeout)
for (const frame of this.pending.splice(0)) this.sock.write(frame) for (const frame of this.pending.splice(0)) this.sock.write(frame)
if (this.closeAfterPending) this.sock.end() if (this.closeAfterPending) this.sock.end()
} }
}) })
this.sock.on('error', onClose) this.sock.on('error', () => {})
this.sock.on('close', onClose) this.sock.on('close', () => {
clearTimeout(readyTimeout)
onClose()
})
} }
sendActivity(payload: Buffer): void { sendActivity(payload: Buffer): void {
@@ -44,7 +46,6 @@ export class MirrorConnection {
} }
} }
/** Sends a final frame (after the handshake) and closes the connection once it has been flushed. */
sendActivityAndClose(payload: Buffer): void { sendActivityAndClose(payload: Buffer): void {
const frame = encodeFrame(OP_FRAME, payload) const frame = encodeFrame(OP_FRAME, payload)
if (this.ready) { if (this.ready) {
+1
View File
@@ -2,6 +2,7 @@ import { PosixPlatform } from './posix'
import { UnsupportedPlatform } from './unsupported' import { UnsupportedPlatform } from './unsupported'
import type { RelayPlatform } from './types' import type { RelayPlatform } from './types'
export { MAX_SOCKETS } from './types'
export type { ClaimedSocket, ProcessInfo, RelayPlatform } from './types' export type { ClaimedSocket, ProcessInfo, RelayPlatform } from './types'
export const platform: RelayPlatform = export const platform: RelayPlatform =
+213 -30
View File
@@ -1,16 +1,35 @@
import * as fs from 'fs' import * as fs from 'fs'
import * as net from 'net' import * as net from 'net'
import * as path from 'path' import * as path from 'path'
import { execFileSync } from 'child_process' import { execFileSync, spawn } from 'child_process'
import type { ClaimedSocket, ProcessInfo, RelayPlatform } from './types' import { MAX_SOCKETS, type ClaimedSocket, type ProcessInfo, type RelayPlatform } from './types'
const REAL_PREFIX = 'discord-ipc-real-' const REAL_PREFIX = 'discord-ipc-real-'
const FAKE_INDEX = 0 const FAKE_INDEX = 0
const MAX_SOCKETS = 10 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 { function runtimeDir(): string {
if (process.env.XDG_RUNTIME_DIR) return process.env.XDG_RUNTIME_DIR 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}` return `/run/user/${process.getuid?.() ?? 0}`
} }
@@ -29,6 +48,10 @@ export class PosixPlatform implements RelayPlatform {
return ipcPath(FAKE_INDEX) return ipcPath(FAKE_INDEX)
} }
ipcSocketPath(index: number): string {
return ipcPath(index)
}
removeFakeSocket(fakePath: string): void { removeFakeSocket(fakePath: string): void {
if (fs.existsSync(fakePath)) fs.unlinkSync(fakePath) if (fs.existsSync(fakePath)) fs.unlinkSync(fakePath)
} }
@@ -43,65 +66,218 @@ export class PosixPlatform implements RelayPlatform {
async recoverLeftoverSockets(): Promise<void> { async recoverLeftoverSockets(): Promise<void> {
for (let i = 0; i < MAX_SOCKETS; i++) { for (let i = 0; i < MAX_SOCKETS; i++) {
const leftover = claimedPath(i) try {
const original = ipcPath(i) const leftover = claimedPath(i)
if (!fs.existsSync(leftover)) continue const original = ipcPath(i)
if (!fs.existsSync(leftover)) continue
if (!fs.existsSync(original)) { if (!fs.existsSync(original)) {
fs.renameSync(leftover, original) fs.renameSync(leftover, original)
continue continue
} }
if (!(await isSocketAlive(original))) { if (!(await isSocketAlive(original))) {
fs.unlinkSync(original) fs.unlinkSync(original)
fs.renameSync(leftover, original) fs.renameSync(leftover, original)
}
} catch {
// Another process may be touching the same files; keep recovering the rest.
} }
} }
} }
discoverAndClaim(): ClaimedSocket[] { async discoverAndClaim(): Promise<ClaimedSocket[]> {
const found: ClaimedSocket[] = [] const found: ClaimedSocket[] = []
for (let i = 0; i < MAX_SOCKETS; i++) { for (let i = 0; i < MAX_SOCKETS; i++) {
const claimed = this.discoverNewSocket(i) const claimed = await this.discoverNewSocket(i)
if (claimed) found.push(claimed) if (claimed) found.push(claimed)
} }
return found return found
} }
discoverNewSocket(index: number): ClaimedSocket | null { async discoverNewSocket(index: number): Promise<ClaimedSocket | null> {
const src = ipcPath(index) const src = ipcPath(index)
if (!fs.existsSync(src)) return null if (!fs.existsSync(src)) return null
if (!(await isSocketAlive(src))) {
try {
if (Date.now() - fs.statSync(src).mtimeMs > STALE_SOCKET_MIN_AGE_MS) fs.unlinkSync(src)
} catch {
// Already gone.
}
return null
}
const dst = claimedPath(index) const dst = claimedPath(index)
fs.renameSync(src, dst) try {
fs.renameSync(src, dst)
} catch {
return null // socket replaced between the check and the rename
}
return { index, path: dst } return { index, path: dst }
} }
restoreSocket(claimed: ClaimedSocket): void { restoreSocket(claimed: ClaimedSocket): void {
const original = ipcPath(claimed.index) try {
if (fs.existsSync(claimed.path) && !fs.existsSync(original)) { const original = ipcPath(claimed.index)
fs.renameSync(claimed.path, original) if (fs.existsSync(claimed.path) && !fs.existsSync(original)) {
fs.renameSync(claimed.path, original)
}
} catch {
// Best effort.
}
}
discardClaimedSocket(claimed: ClaimedSocket): void {
try {
fs.unlinkSync(claimed.path)
} catch {
// Already gone.
}
}
isSocketAlive(socketPath: string): Promise<boolean> {
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 { 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)) return findSocketOwner(ipcPath(index))
} }
getPeerProcess(fd: number): ProcessInfo | null { getPeerProcess(fd: number): ProcessInfo | null {
return findPeerProcess(fd) 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> { function isSocketAlive(socketPath: string): Promise<boolean> {
return new Promise((resolve) => { return new Promise((resolve) => {
const sock = net.createConnection(socketPath) const sock = net.createConnection(socketPath)
sock.once('connect', () => { let settled = false
const done = (alive: boolean): void => {
if (settled) return
settled = true
sock.destroy() sock.destroy()
resolve(true) resolve(alive)
}) }
sock.once('error', () => resolve(false)) sock.setTimeout(ALIVE_CHECK_TIMEOUT_MS, () => done(false))
sock.once('connect', () => done(true))
sock.once('error', () => done(false))
}) })
} }
@@ -137,7 +313,13 @@ function locateSocketOwnerMacOS(socketPath: string): ProcessInfo | null {
currentName = line.slice(1) currentName = line.slice(1)
} else if (line.startsWith('n') && line.slice(1) === socketPath) { } else if (line.startsWith('n') && line.slice(1) === socketPath) {
if (currentPid > 0 && currentPid !== process.pid) { if (currentPid > 0 && currentPid !== process.pid) {
return { pid: currentPid, name: currentName } return (
resolveMainAppProcess(currentPid) ?? {
pid: currentPid,
name: currentName,
executable: executablePath(currentPid)
}
)
} }
} }
} }
@@ -197,7 +379,8 @@ function findProcessForInodes(inodes: Set<string>): ProcessInfo | null {
} }
const match = /^socket:\[(\d+)\]$/.exec(link) const match = /^socket:\[(\d+)\]$/.exec(link)
if (match && inodes.has(match[1])) { 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) }
} }
} }
} }
@@ -260,7 +443,7 @@ function findPeerProcess(localFd: number): ProcessInfo | null {
if (fn(localFd, SOL_LOCAL, LOCAL_PEERPID, optval, optlen) !== 0) return null if (fn(localFd, SOL_LOCAL, LOCAL_PEERPID, optval, optlen) !== 0) return null
const pid = optval.readInt32LE(0) const pid = optval.readInt32LE(0)
if (pid <= 0) return null if (pid <= 0) return null
return { pid, name: macProcessName(pid) } return { pid, name: macProcessName(pid), executable: executablePath(pid) }
} }
const optval = Buffer.alloc(UCRED_SIZE) const optval = Buffer.alloc(UCRED_SIZE)
@@ -272,7 +455,7 @@ function findPeerProcess(localFd: number): ProcessInfo | null {
const pid = optval.readInt32LE(0) const pid = optval.readInt32LE(0)
if (pid <= 0) return null 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 { function macProcessName(pid: number): string {
+20 -14
View File
@@ -1,6 +1,9 @@
export const MAX_SOCKETS = 10
export interface ProcessInfo { export interface ProcessInfo {
pid: number pid: number
name: string name: string
executable: string | null
} }
export interface ClaimedSocket { export interface ClaimedSocket {
@@ -8,38 +11,41 @@ export interface ClaimedSocket {
path: string path: string
} }
/**
* Platform-specific primitives the relay needs to take over Discord's IPC
* sockets and identify the processes on either end of a connection.
*
* Implement this interface to add support for a new OS; `RpcRelay` itself
* contains no platform-specific code.
*/
export interface RelayPlatform { export interface RelayPlatform {
readonly isSupported: boolean readonly isSupported: boolean
fakeSocketPath(): string fakeSocketPath(): string
ipcSocketPath(index: number): string
removeFakeSocket(fakePath: string): void removeFakeSocket(fakePath: string): void
finalizeFakeSocket(fakePath: string): void finalizeFakeSocket(fakePath: string): void
/** Checks whether the fake socket file still exists on disk (a restarted Discord client can unlink and rebind it out from under us). */ /** Checks whether Discord has unlinked our fake socket. */
fakeSocketExists(fakePath: string): boolean fakeSocketExists(fakePath: string): boolean
/**
* Restores sockets left claimed from a previous run that crashed before
* it could call restoreSocket(), so a fresh start() can discover them.
*/
recoverLeftoverSockets(): Promise<void> recoverLeftoverSockets(): Promise<void>
discoverAndClaim(): ClaimedSocket[] discoverAndClaim(): Promise<ClaimedSocket[]>
discoverNewSocket(index: number): ClaimedSocket | null discoverNewSocket(index: number): Promise<ClaimedSocket | null>
restoreSocket(claimed: ClaimedSocket): void restoreSocket(claimed: ClaimedSocket): void
discardClaimedSocket(claimed: ClaimedSocket): void
isSocketAlive(socketPath: string): Promise<boolean>
removeStaleIpcSocket(index: number): Promise<void>
getInstanceProcess(index: number): ProcessInfo | null getInstanceProcess(index: number): ProcessInfo | null
getPeerProcess(fd: number): ProcessInfo | null getPeerProcess(fd: number): ProcessInfo | null
killProcess(pid: number): boolean
isProcessAlive(pid: number): boolean
launchExecutable(executable: string): boolean
} }
+30 -5
View File
@@ -2,9 +2,6 @@ import type { ClaimedSocket, ProcessInfo, RelayPlatform } from './types'
const NOT_SUPPORTED = new Error('Platform not supported') const NOT_SUPPORTED = new Error('Platform not supported')
/**
* Placeholder for platforms without a real implementation yet.
*/
export class UnsupportedPlatform implements RelayPlatform { export class UnsupportedPlatform implements RelayPlatform {
readonly isSupported = false readonly isSupported = false
@@ -12,6 +9,10 @@ export class UnsupportedPlatform implements RelayPlatform {
throw NOT_SUPPORTED throw NOT_SUPPORTED
} }
ipcSocketPath(): string {
throw NOT_SUPPORTED
}
removeFakeSocket(): void { removeFakeSocket(): void {
throw NOT_SUPPORTED throw NOT_SUPPORTED
} }
@@ -28,11 +29,11 @@ export class UnsupportedPlatform implements RelayPlatform {
throw NOT_SUPPORTED throw NOT_SUPPORTED
} }
discoverAndClaim(): ClaimedSocket[] { async discoverAndClaim(): Promise<ClaimedSocket[]> {
throw NOT_SUPPORTED throw NOT_SUPPORTED
} }
discoverNewSocket(): ClaimedSocket | null { async discoverNewSocket(): Promise<ClaimedSocket | null> {
throw NOT_SUPPORTED throw NOT_SUPPORTED
} }
@@ -40,6 +41,18 @@ export class UnsupportedPlatform implements RelayPlatform {
throw NOT_SUPPORTED throw NOT_SUPPORTED
} }
discardClaimedSocket(): void {
throw NOT_SUPPORTED
}
async isSocketAlive(): Promise<boolean> {
return false
}
async removeStaleIpcSocket(): Promise<void> {
throw NOT_SUPPORTED
}
getInstanceProcess(): ProcessInfo | null { getInstanceProcess(): ProcessInfo | null {
return null return null
} }
@@ -47,4 +60,16 @@ export class UnsupportedPlatform implements RelayPlatform {
getPeerProcess(): ProcessInfo | null { getPeerProcess(): ProcessInfo | null {
return null return null
} }
killProcess(): boolean {
return false
}
isProcessAlive(): boolean {
return false
}
launchExecutable(): boolean {
return false
}
} }
+520 -145
View File
@@ -3,11 +3,18 @@ import { EventEmitter } from 'events'
import * as net from 'net' import * as net from 'net'
import { encodeFrame, Frame, FrameReader, OP_HANDSHAKE, parseFramePayload } from './ipc-protocol' import { encodeFrame, Frame, FrameReader, OP_HANDSHAKE, parseFramePayload } from './ipc-protocol'
import { MirrorConnection } from './mirror-connection' import { MirrorConnection } from './mirror-connection'
import { platform, type ClaimedSocket, type ProcessInfo } from './platform' import { platform, MAX_SOCKETS, type ClaimedSocket, type ProcessInfo } from './platform'
const MAX_SOCKETS = 10
const DISCOVERY_INTERVAL_MS = 3000 const DISCOVERY_INTERVAL_MS = 3000
const WAITING_RETRY_INTERVAL_MS = 3000 const WAITING_RETRY_INTERVAL_MS = 3000
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 { interface AppAsset {
id: string id: string
@@ -15,27 +22,46 @@ interface AppAsset {
name: string name: string
} }
const appAssetCache = new Map<string, Map<string, string>>() interface AssetCacheEntry {
map: Map<string, string>
expires: number
}
const appAssetCache = new Map<string, AssetCacheEntry>()
async function getAppAssetMap(appId: string): Promise<Map<string, string>> { async function getAppAssetMap(appId: string): Promise<Map<string, string>> {
const cached = appAssetCache.get(appId) const cached = appAssetCache.get(appId)
if (cached) return cached if (cached && Date.now() < cached.expires) return cached.map
const map = new Map<string, string>() const map = new Map<string, string>()
let expires = Number.POSITIVE_INFINITY
try { try {
const res = await fetch(`https://discord.com/api/v9/oauth2/applications/${appId}/assets`) const res = await fetch(`https://discord.com/api/v9/oauth2/applications/${appId}/assets`)
if (res.ok) { if (res.ok) {
const assets = (await res.json()) as AppAsset[] const assets = (await res.json()) as AppAsset[]
for (const asset of assets) map.set(asset.name, asset.id) for (const asset of assets) map.set(asset.name, asset.id)
} else {
expires = Date.now() + ASSET_FETCH_RETRY_MS
} }
} catch { } catch {
// Network error: cache an empty map so we don't retry every frame. expires = Date.now() + ASSET_FETCH_RETRY_MS
} }
appAssetCache.set(appId, map) appAssetCache.set(appId, { map, expires })
return map 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() }),
'utf8'
)
}
async function resolveAssetImage( async function resolveAssetImage(
key: string | undefined, key: string | undefined,
appId: string | null appId: string | null
@@ -65,12 +91,21 @@ export interface RelayInstance {
path: string path: string
isPrimary: boolean isPrimary: boolean
enabled: boolean enabled: boolean
locked: boolean
process: ProcessInfo | null process: ProcessInfo | null
} }
export interface BlacklistedApp {
id: string
name: string | null
}
export interface ConnectedClient { export interface ConnectedClient {
id: number id: number
process: ProcessInfo | null process: ProcessInfo | null
appId: string | null
blacklisted: boolean
activity: LastActivity | null
} }
export interface ActivityAssets { export interface ActivityAssets {
@@ -106,51 +141,94 @@ export interface RelayStatus {
unsupported: boolean unsupported: boolean
instances: RelayInstance[] instances: RelayInstance[]
connectedClients: ConnectedClient[] connectedClients: ConnectedClient[]
lastActivity: LastActivity | null blacklistedApps: BlacklistedApp[]
lockedPrimary: string | null
reordering: boolean
primaryOutOfOrder: boolean
error: string | null error: string | null
} }
/** interface ClientSession {
* Takes over Discord's primary IPC socket (discord-ipc-0), passing every id: number
* frame through to the real primary instance unchanged, while mirroring the client: net.Socket
* handshake and SET_ACTIVITY frames to any other running Discord instances. primary: net.Socket
*/ process: ProcessInfo | null
handshakePayload: Buffer | null
appId: string | null
lastActivityPayload: Buffer | null
lastActivityPid: number | null
activity: LastActivity | null
mirrors: Map<number, MirrorConnection>
reconnectTimers: Map<number, NodeJS.Timeout>
closed: boolean
}
/** Hijacks discord-ipc-0, passthrough to primary, mirroring frames to other instances. */
export class RpcRelay extends EventEmitter { export class RpcRelay extends EventEmitter {
private server: net.Server | null = null private server: net.Server | null = null
private claimed: ClaimedSocket[] = [] private claimed: ClaimedSocket[] = []
private disabledMirrors = new Set<number>() private disabledMirrors = new Set<number>()
private connectedClients = new Map<number, ConnectedClient>() private blacklistedApps = new Map<string, string | null>()
private sessions = new Map<number, ClientSession>()
private nextClientId = 1 private nextClientId = 1
private discoveryTimer: NodeJS.Timeout | null = null private discoveryTimer: NodeJS.Timeout | null = null
private discovering = false
private waitingTimer: NodeJS.Timeout | null = null private waitingTimer: NodeJS.Timeout | null = null
private lastActivity: LastActivity | null = null
private running = false private running = false
private waiting = false private waiting = false
private fakeOwned = false
private lastError: string | null = null private lastError: string | null = null
private restarting = false private restarting = false
private mirrors = new Map<number, MirrorConnection>() private lockedPrimary: string | null = null
private lastActivityPid = new Map<number, number>() private reordering = false
getStatus(): RelayStatus { getStatus(): RelayStatus {
const instances: RelayInstance[] = this.claimed.map(({ index, path: socketPath }, i) => ({ const instances: RelayInstance[] = this.claimed.map(({ index, path: socketPath }, i) => {
index, const process = platform.getInstanceProcess(index)
path: socketPath, return {
isPrimary: i === 0, index,
enabled: i === 0 || !this.disabledMirrors.has(index), path: socketPath,
process: platform.getInstanceProcess(index) isPrimary: i === 0,
})) enabled: i === 0 || !this.disabledMirrors.has(index),
locked: this.isLockedInstance(process),
process
}
})
return { return {
running: this.running, running: this.running,
waiting: this.waiting, waiting: this.waiting,
unsupported: !platform.isSupported, unsupported: !platform.isSupported,
instances, instances,
connectedClients: [...this.connectedClients.values()], connectedClients: [...this.sessions.values()].map((s) => ({
lastActivity: this.lastActivity, id: s.id,
process: s.process,
appId: s.appId,
blacklisted: this.isBlacklisted(s),
activity: s.activity
})),
blacklistedApps: this.getBlacklistedApps(),
lockedPrimary: this.lockedPrimary,
reordering: this.reordering,
primaryOutOfOrder: this.primaryOutOfOrder(instances),
error: this.lastError 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 { setMirrorEnabled(index: number, enabled: boolean): RelayStatus {
if (index === this.primaryIndex()) return this.getStatus() if (index === this.primaryIndex()) return this.getStatus()
@@ -172,6 +250,87 @@ export class RpcRelay extends EventEmitter {
this.disabledMirrors = new Set(indices) 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 }))
}
setBlacklistedApps(apps: BlacklistedApp[]): void {
this.blacklistedApps = new Map(apps.map((a) => [a.id, a.name]))
}
setAppBlacklisted(appId: string, blacklisted: boolean): RelayStatus {
if (blacklisted) {
const session = [...this.sessions.values()].find((s) => s.appId === appId)
const name = session?.activity?.app ?? session?.process?.name ?? null
this.blacklistedApps.set(appId, name ?? this.blacklistedApps.get(appId) ?? null)
} else {
this.blacklistedApps.delete(appId)
}
for (const session of this.sessions.values()) {
if (session.appId !== appId) continue
if (blacklisted) {
this.clearSessionMirrors(session)
} else if (session.lastActivityPayload) {
for (let i = 1; i < this.claimed.length; i++) {
const target = this.claimed[i]
if (this.disabledMirrors.has(target.index)) continue
if (this.ensureMirror(session, target)) {
session.mirrors.get(target.index)?.sendActivity(session.lastActivityPayload)
}
}
}
}
this.emitStatus()
return this.getStatus()
}
private isBlacklisted(session: ClientSession): boolean {
return session.appId !== null && this.blacklistedApps.has(session.appId)
}
private clearSessionMirrors(session: ClientSession): void {
for (const timer of session.reconnectTimers.values()) clearTimeout(timer)
session.reconnectTimers.clear()
for (const mirror of session.mirrors.values()) {
if (session.lastActivityPid !== null) {
mirror.sendActivityAndClose(clearActivityPayload(session.lastActivityPid))
} else {
mirror.destroy()
}
}
session.mirrors.clear()
}
async start(): Promise<void> { async start(): Promise<void> {
if (this.running) return if (this.running) return
this.lastError = null this.lastError = null
@@ -183,34 +342,46 @@ export class RpcRelay extends EventEmitter {
} }
await platform.recoverLeftoverSockets() await platform.recoverLeftoverSockets()
this.claimed = platform.discoverAndClaim() const claimed = await platform.discoverAndClaim()
if (this.claimed.length === 0) { if (claimed.length === 0) {
this.waitForDiscord() this.waitForDiscord()
return return
} }
this.claimed = claimed
this.waiting = false this.waiting = false
this.stopWaitingTimer() this.stopWaitingTimer()
const fake = platform.fakeSocketPath() try {
platform.removeFakeSocket(fake) const fake = platform.fakeSocketPath()
platform.removeFakeSocket(fake)
this.server = net.createServer((sock) => this.handleClient(sock)) this.server = net.createServer((sock) => this.handleClient(sock))
this.server.on('error', (err) => { this.server.on('error', (err) => {
this.lastError = err.message this.lastError = err.message
this.emitStatus() this.emitStatus()
})
await new Promise<void>((resolve, reject) => {
this.server!.once('error', reject)
this.server!.listen(fake, () => {
this.server!.removeListener('error', reject)
resolve()
}) })
})
platform.finalizeFakeSocket(fake) await new Promise<void>((resolve, reject) => {
this.server!.once('error', reject)
this.server!.listen(fake, () => {
this.server!.removeListener('error', reject)
resolve()
})
})
platform.finalizeFakeSocket(fake)
this.fakeOwned = true
} catch (err) {
this.server?.close()
this.server = null
for (const c of this.claimed) platform.restoreSocket(c)
this.claimed = []
this.lastError = err instanceof Error ? err.message : String(err)
this.emitStatus()
throw err
}
this.running = true this.running = true
this.startDiscoveryTimer() this.startDiscoveryTimer()
@@ -228,23 +399,41 @@ export class RpcRelay extends EventEmitter {
this.running = false this.running = false
this.stopDiscoveryTimer() this.stopDiscoveryTimer()
for (const session of [...this.sessions.values()]) this.destroySession(session)
if (this.server) { if (this.server) {
await new Promise<void>((resolve) => this.server!.close(() => resolve())) const server = this.server
this.server = null this.server = null
await new Promise<void>((resolve) => {
const timeout = setTimeout(resolve, SERVER_CLOSE_TIMEOUT_MS)
server.close(() => {
clearTimeout(timeout)
resolve()
})
})
} }
platform.removeFakeSocket(platform.fakeSocketPath()) platform.removeFakeSocket(platform.fakeSocketPath())
this.fakeOwned = false
for (const claimed of this.claimed) platform.restoreSocket(claimed) for (const claimed of this.claimed) platform.restoreSocket(claimed)
for (const mirror of this.mirrors.values()) mirror.destroy()
this.mirrors.clear()
this.lastActivityPid.clear()
this.claimed = [] this.claimed = []
this.connectedClients.clear()
this.emitStatus() this.emitStatus()
} }
emergencyRestoreSync(): void {
try {
if (this.fakeOwned) {
platform.removeFakeSocket(platform.fakeSocketPath())
this.fakeOwned = false
}
} catch {
// Best effort.
}
for (const claimed of this.claimed) platform.restoreSocket(claimed)
this.claimed = []
}
private primaryIndex(): number | undefined { private primaryIndex(): number | undefined {
return this.claimed[0]?.index return this.claimed[0]?.index
} }
@@ -273,7 +462,7 @@ export class RpcRelay extends EventEmitter {
private startDiscoveryTimer(): void { private startDiscoveryTimer(): void {
this.stopDiscoveryTimer() this.stopDiscoveryTimer()
this.discoveryTimer = setInterval(() => this.discoverNewInstances(), DISCOVERY_INTERVAL_MS) this.discoveryTimer = setInterval(() => void this.discoverInstances(), DISCOVERY_INTERVAL_MS)
} }
private stopDiscoveryTimer(): void { private stopDiscoveryTimer(): void {
@@ -283,66 +472,115 @@ export class RpcRelay extends EventEmitter {
} }
} }
/** Picks up Discord instances launched after the relay started, and recovers from a stolen primary socket. */ /** Periodic: recover stolen primary, prune dead instances, discover new ones. */
private discoverNewInstances(): void { private async discoverInstances(): Promise<void> {
if (!this.running) return if (!this.running || this.discovering) return
this.discovering = true
try {
if (!platform.fakeSocketExists(platform.fakeSocketPath())) {
void this.restart()
return
}
if (!platform.fakeSocketExists(platform.fakeSocketPath())) { let changed = false
void this.restart()
return
}
const claimedIndices = new Set(this.claimed.map((c) => c.index)) for (const claimed of [...this.claimed]) {
let changed = false if (await platform.isSocketAlive(claimed.path)) continue
if (!this.running) return
for (let i = 1; i < MAX_SOCKETS; i++) { if (claimed === this.claimed[0]) {
if (claimedIndices.has(i)) continue void this.restart()
const found = platform.discoverNewSocket(i) return
if (found) { }
this.claimed.push(found)
this.claimed = this.claimed.filter((c) => c !== claimed)
platform.discardClaimedSocket(claimed)
for (const session of this.sessions.values()) {
this.dropSessionMirror(session, claimed.index)
}
changed = true changed = true
} }
}
if (changed) this.emitStatus() const claimedIndices = new Set(this.claimed.map((c) => c.index))
for (let i = 1; i < MAX_SOCKETS; i++) {
if (claimedIndices.has(i)) continue
const found = await platform.discoverNewSocket(i)
if (!found) continue
if (!this.running) {
platform.restoreSocket(found)
return
}
this.claimed.push(found)
changed = true
if (!this.disabledMirrors.has(found.index)) {
for (const session of this.sessions.values()) {
if (this.isBlacklisted(session)) continue
if (this.ensureMirror(session, found) && session.lastActivityPayload) {
session.mirrors.get(found.index)?.sendActivity(session.lastActivityPayload)
}
}
}
}
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()
} finally {
this.discovering = false
}
} }
private handleClient(client: net.Socket): void { private handleClient(client: net.Socket): void {
const clientId = this.nextClientId++ const primaryClaimed = this.claimed[0]
if (!primaryClaimed) {
client.destroy()
return
}
const fd = (client as unknown as { _handle?: { fd?: number } })._handle?.fd const fd = (client as unknown as { _handle?: { fd?: number } })._handle?.fd
this.connectedClients.set(clientId, { const session: ClientSession = {
id: clientId, id: this.nextClientId++,
process: fd !== undefined ? platform.getPeerProcess(fd) : null client,
}) primary: net.createConnection(primaryClaimed.path),
process: fd !== undefined ? platform.getPeerProcess(fd) : null,
handshakePayload: null,
appId: null,
lastActivityPayload: null,
lastActivityPid: null,
activity: null,
mirrors: new Map(),
reconnectTimers: new Map(),
closed: false
}
this.sessions.set(session.id, session)
this.emitStatus() this.emitStatus()
const primaryPath = this.claimed[0].path const { primary } = session
const primary = net.createConnection(primaryPath)
const clientReader = new FrameReader() const clientReader = new FrameReader()
const primaryReader = new FrameReader() const primaryReader = new FrameReader()
let handshakePayload: Buffer | null = null
const cleanup = (): void => {
client.destroy()
primary.destroy()
for (const mirror of this.mirrors.values()) mirror.destroy()
this.mirrors.clear()
this.lastActivityPid.clear()
this.connectedClients.delete(clientId)
this.emitStatus()
}
let clientAppId: string | null = null
let primaryConnected = false let primaryConnected = false
const pendingToPrimary: Buffer[] = [] const pendingToPrimary: Buffer[] = []
client.on('data', (chunk: Buffer) => { client.on('data', (chunk: Buffer) => {
for (const frame of clientReader.push(chunk)) { for (const frame of clientReader.push(chunk)) {
if (frame.op === OP_HANDSHAKE) { if (frame.op === OP_HANDSHAKE) {
handshakePayload = frame.payload session.handshakePayload = frame.payload
clientAppId = (parseFramePayload(frame)?.client_id as string) ?? null session.appId = (parseFramePayload(frame)?.client_id as string) ?? null
} }
const encoded = encodeFrame(frame.op, frame.payload) const encoded = encodeFrame(frame.op, frame.payload)
@@ -352,8 +590,8 @@ export class RpcRelay extends EventEmitter {
pendingToPrimary.push(encoded) pendingToPrimary.push(encoded)
} }
if (handshakePayload) this.mirrorFrame(frame, handshakePayload) if (session.handshakePayload) this.mirrorFrame(session, frame)
void this.recordActivity(frame, clientAppId) void this.recordActivity(session, frame)
} }
}) })
@@ -368,21 +606,48 @@ export class RpcRelay extends EventEmitter {
}) })
}) })
const cleanup = (): void => this.destroySession(session)
client.on('error', cleanup) client.on('error', cleanup)
client.on('close', cleanup)
primary.on('close', cleanup)
primary.on('error', (err) => { primary.on('error', (err) => {
cleanup() cleanup()
if ((err as NodeJS.ErrnoException).code === 'ECONNREFUSED') { const code = (err as NodeJS.ErrnoException).code
if (code === 'ECONNREFUSED' || code === 'ENOENT') {
void this.restart() void this.restart()
} else { } else {
this.lastError = `Primary connection error: ${err.message}` this.lastError = `Primary connection error: ${err.message}`
this.emitStatus() this.emitStatus()
} }
}) })
client.on('close', cleanup)
primary.on('close', cleanup)
} }
/** Restarts the relay, picking up any Discord instance that has replaced its IPC socket. */ private destroySession(session: ClientSession): void {
if (session.closed) return
session.closed = true
session.client.destroy()
session.primary.destroy()
for (const timer of session.reconnectTimers.values()) clearTimeout(timer)
session.reconnectTimers.clear()
for (const mirror of session.mirrors.values()) mirror.destroy()
session.mirrors.clear()
this.sessions.delete(session.id)
this.emitStatus()
}
private dropSessionMirror(session: ClientSession, index: number): void {
const timer = session.reconnectTimers.get(index)
if (timer) {
clearTimeout(timer)
session.reconnectTimers.delete(index)
}
session.mirrors.get(index)?.destroy()
session.mirrors.delete(index)
}
private async restart(): Promise<void> { private async restart(): Promise<void> {
if (this.restarting) return if (this.restarting) return
this.restarting = true this.restarting = true
@@ -397,79 +662,185 @@ export class RpcRelay extends EventEmitter {
} }
} }
/** Forwards the handshake and SET_ACTIVITY frames to every enabled mirror instance. */ private async reorderForPrimary(): Promise<RelayStatus> {
private mirrorFrame(frame: Frame, handshakePayload: Buffer): void { 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 payload = frame.op !== OP_HANDSHAKE ? parseFramePayload(frame) : null
const isSetActivity = payload?.cmd === 'SET_ACTIVITY' const isSetActivity = payload?.cmd === 'SET_ACTIVITY'
if (frame.op !== OP_HANDSHAKE && !isSetActivity) return if (frame.op !== OP_HANDSHAKE && !isSetActivity) return
if (isSetActivity) { if (isSetActivity) {
session.lastActivityPayload = frame.payload
const pid = (payload!.args as { pid?: number } | undefined)?.pid const pid = (payload!.args as { pid?: number } | undefined)?.pid
if (typeof pid === 'number') { if (typeof pid === 'number') session.lastActivityPid = pid
for (let i = 1; i < this.claimed.length; i++) {
this.lastActivityPid.set(this.claimed[i].index, pid)
}
}
} }
if (this.isBlacklisted(session)) return
for (let i = 1; i < this.claimed.length; i++) { for (let i = 1; i < this.claimed.length; i++) {
const { index, path: mirrorPath } = this.claimed[i] const { index } = this.claimed[i]
if (this.disabledMirrors.has(index)) { if (this.disabledMirrors.has(index)) {
this.mirrors.get(index)?.destroy() this.dropSessionMirror(session, index)
this.mirrors.delete(index)
continue continue
} }
let mirror = this.mirrors.get(index) this.ensureMirror(session, this.claimed[i])
if (!mirror) { if (frame.op !== OP_HANDSHAKE) session.mirrors.get(index)?.sendActivity(frame.payload)
const newMirror: MirrorConnection = new MirrorConnection( }
mirrorPath, }
handshakePayload,
() => { private ensureMirror(session: ClientSession, target: ClaimedSocket): boolean {
if (this.mirrors.get(index) === newMirror) this.mirrors.delete(index) if (session.closed || !session.handshakePayload) return false
} if (session.mirrors.has(target.index)) return false
)
mirror = newMirror const mirror: MirrorConnection = new MirrorConnection(
this.mirrors.set(index, mirror) target.path,
if (frame.op === OP_HANDSHAKE) continue // handshake already sent on connect session.handshakePayload,
() => {
if (session.mirrors.get(target.index) === mirror) {
session.mirrors.delete(target.index)
this.scheduleMirrorReconnect(session, target.index)
}
}
)
session.mirrors.set(target.index, mirror)
return true
}
private scheduleMirrorReconnect(session: ClientSession, index: number): void {
if (session.closed || !this.running) return
if (session.reconnectTimers.has(index)) return
const timer = setTimeout(() => {
session.reconnectTimers.delete(index)
if (session.closed || !this.running) return
if (this.disabledMirrors.has(index) || this.isBlacklisted(session)) return
if (!session.lastActivityPayload) return
const target = this.claimed.find((c, i) => i > 0 && c.index === index)
if (!target) return
if (this.ensureMirror(session, target)) {
session.mirrors.get(index)?.sendActivity(session.lastActivityPayload)
}
}, MIRROR_RECONNECT_DELAY_MS)
session.reconnectTimers.set(index, timer)
}
private clearMirrorActivity(index: number): void {
for (const session of this.sessions.values()) {
const timer = session.reconnectTimers.get(index)
if (timer) {
clearTimeout(timer)
session.reconnectTimers.delete(index)
} }
if (frame.op !== OP_HANDSHAKE) mirror.sendActivity(frame.payload) const mirror = session.mirrors.get(index)
if (!mirror) continue
if (session.lastActivityPid !== null) {
mirror.sendActivityAndClose(clearActivityPayload(session.lastActivityPid))
} else {
mirror.destroy()
}
session.mirrors.delete(index)
} }
} }
/** Sends a SET_ACTIVITY frame clearing the presence on a mirror, then disconnects it. */ private async recordActivity(session: ClientSession, frame: Frame): Promise<void> {
private clearMirrorActivity(index: number): void {
const mirror = this.mirrors.get(index)
if (!mirror) return
const pid = this.lastActivityPid.get(index)
if (pid !== undefined) {
const clearPayload = Buffer.from(
JSON.stringify({
cmd: 'SET_ACTIVITY',
args: { pid, activity: null },
nonce: randomUUID()
}),
'utf8'
)
mirror.sendActivityAndClose(clearPayload)
} else {
mirror.destroy()
}
this.mirrors.delete(index)
this.lastActivityPid.delete(index)
}
private async recordActivity(frame: Frame, appId: string | null): Promise<void> {
if (frame.op === OP_HANDSHAKE) return if (frame.op === OP_HANDSHAKE) return
const data = parseFramePayload(frame) const data = parseFramePayload(frame)
if (data?.cmd !== 'SET_ACTIVITY') return if (data?.cmd !== 'SET_ACTIVITY') return
const activity = (data.args as { activity?: Record<string, unknown> })?.activity ?? {} const at = Date.now()
const appId = session.appId
const activity = (data.args as { activity?: Record<string, unknown> | null })?.activity
if (!activity) {
session.activity = null
this.emitStatus()
return
}
const rawAssets = activity.assets as Record<string, string> | undefined const rawAssets = activity.assets as Record<string, string> | undefined
const assets: ActivityAssets | null = rawAssets const assets: ActivityAssets | null = rawAssets
@@ -494,14 +865,18 @@ export class RpcRelay extends EventEmitter {
? rawButtons.map((b) => ({ label: b.label, url: b.url })) ? rawButtons.map((b) => ({ label: b.label, url: b.url }))
: [] : []
this.lastActivity = { // Prevent stale network-delayed frames from overwriting newer ones.
if (session.closed) return
if (session.activity && session.activity.at > at) return
session.activity = {
app: (activity.name as string) ?? null, app: (activity.name as string) ?? null,
details: (activity.details as string) ?? null, details: (activity.details as string) ?? null,
state: (activity.state as string) ?? null, state: (activity.state as string) ?? null,
assets, assets,
timestamps, timestamps,
buttons, buttons,
at: Date.now() at
} }
this.emitStatus() this.emitStatus()
} }
+5
View File
@@ -16,6 +16,11 @@ const api = {
ipcRenderer.invoke('relay:set-start-minimized', enabled), ipcRenderer.invoke('relay:set-start-minimized', enabled),
setMirrorEnabled: (index: number, enabled: boolean): Promise<RelayStatus> => setMirrorEnabled: (index: number, enabled: boolean): Promise<RelayStatus> =>
ipcRenderer.invoke('relay:set-mirror-enabled', index, enabled), 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) => { onStatus: (callback: (status: RelayStatus) => void): (() => void) => {
const listener = (_e: unknown, status: RelayStatus): void => callback(status) const listener = (_e: unknown, status: RelayStatus): void => callback(status)
ipcRenderer.on('relay:status', listener) ipcRenderer.on('relay:status', listener)
+203 -34
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { ChevronLeft, Info, Settings } from 'lucide-react' import { ChevronLeft, ChevronRight, Eye, EyeOff, Info, Settings } from 'lucide-react'
import type { RelayStatus } from '../../main/relay' import type { ConnectedClient, LastActivity, RelayStatus } from '../../main/relay'
import { InstanceRow } from './components/InstanceRow' import { InstanceRow } from './components/InstanceRow'
import { Toggle } from './components/Toggle' import { Toggle } from './components/Toggle'
import { Button, LinkButton } from './components/Button' import { Button, LinkButton } from './components/Button'
@@ -11,7 +11,10 @@ const EMPTY_STATUS: RelayStatus = {
unsupported: false, unsupported: false,
instances: [], instances: [],
connectedClients: [], connectedClients: [],
lastActivity: null, blacklistedApps: [],
lockedPrimary: null,
reordering: false,
primaryOutOfOrder: false,
error: null error: null
} }
@@ -38,13 +41,7 @@ function formatDuration(ms: number): string {
return `${minutes}:${String(seconds).padStart(2, '0')}` return `${minutes}:${String(seconds).padStart(2, '0')}`
} }
function ActivityPreview({ function ActivityPreview({ activity }: { activity: LastActivity }): React.JSX.Element | null {
activity
}: {
activity: RelayStatus['lastActivity']
}): React.JSX.Element | null {
if (!activity) return null
const { app, details, state, assets, timestamps, buttons, at } = activity const { app, details, state, assets, timestamps, buttons, at } = activity
const elapsed = timestamps ? formatElapsed(timestamps.start, timestamps.end) : null const elapsed = timestamps ? formatElapsed(timestamps.start, timestamps.end) : null
@@ -61,7 +58,7 @@ function ActivityPreview({
/> />
) : ( ) : (
<div className="w-16 h-16 rounded-lg bg-zinc-700 flex items-center justify-center text-zinc-500 text-xs"> <div className="w-16 h-16 rounded-lg bg-zinc-700 flex items-center justify-center text-zinc-500 text-xs">
{app ? app.slice(0, 2).toUpperCase() : ''} {app ? app.slice(0, 2).toUpperCase() : '-'}
</div> </div>
)} )}
{assets?.smallImage && ( {assets?.smallImage && (
@@ -99,6 +96,137 @@ function ActivityPreview({
) )
} }
type ToggleBlacklist = (appId: string, blacklisted: boolean) => void
function ClientRow({
client,
onToggleBlacklist
}: {
client: ConnectedClient
onToggleBlacklist: ToggleBlacklist
}): React.JSX.Element {
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<span className="text-zinc-300 truncate">
{client.process
? `${client.process.name} (pid ${client.process.pid})`
: 'unknown process'}
</span>
<Button
variant="ghost"
className="p-1 shrink-0"
disabled={!client.appId}
onClick={() => client.appId && onToggleBlacklist(client.appId, !client.blacklisted)}
aria-label={client.blacklisted ? 'Enable mirroring' : 'Disable mirroring'}
title={
!client.appId
? 'Waiting for handshake'
: client.blacklisted
? 'Blacklisted - not mirrored. Click to mirror again.'
: 'Mirrored. Click to blacklist this app.'
}
>
{client.blacklisted ? (
<EyeOff className="w-4 h-4 text-red-400" />
) : (
<Eye className="w-4 h-4" />
)}
</Button>
</div>
{client.blacklisted && <div className="text-xs text-red-400/90">Blacklisted</div>}
{client.activity && (
<div
className={`rounded-lg bg-zinc-900/60 border p-3 ${
client.blacklisted ? 'border-red-900/60 opacity-50' : 'border-zinc-700/60'
}`}
>
<ActivityPreview activity={client.activity} />
</div>
)}
</div>
)
}
function ClientCarousel({
clients,
onToggleBlacklist
}: {
clients: ConnectedClient[]
onToggleBlacklist: ToggleBlacklist
}): React.JSX.Element {
const scrollRef = useRef<HTMLDivElement>(null)
const [index, setIndex] = useState(0)
const current = Math.min(index, clients.length - 1)
const scrollTo = (i: number): void => {
scrollRef.current?.scrollTo({ left: i * scrollRef.current.clientWidth, behavior: 'smooth' })
}
const onScroll = (): void => {
const el = scrollRef.current
if (el) setIndex(Math.round(el.scrollLeft / el.clientWidth))
}
return (
<div className="flex flex-col gap-2">
<div
ref={scrollRef}
onScroll={onScroll}
className="flex overflow-x-auto snap-x snap-mandatory [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{clients.map((client) => (
<div key={client.id} className="w-full shrink-0 snap-center">
<ClientRow client={client} onToggleBlacklist={onToggleBlacklist} />
</div>
))}
</div>
{clients.length > 1 && (
<div className="flex items-center justify-between">
<Button
variant="ghost"
className="p-1"
onClick={() => scrollTo(current - 1)}
disabled={current === 0}
aria-label="Previous client"
>
<ChevronLeft className="w-4 h-4" />
</Button>
<div className="flex gap-1.5">
{clients.map((client, i) => (
<button
key={client.id}
onClick={() => scrollTo(i)}
aria-label={`Show client ${i + 1}`}
className={`w-1.5 h-1.5 rounded-full transition-colors ${
client.blacklisted
? i === current
? 'bg-red-400'
: 'bg-red-900 hover:bg-red-700'
: i === current
? 'bg-zinc-300'
: 'bg-zinc-600 hover:bg-zinc-500'
}`}
/>
))}
</div>
<Button
variant="ghost"
className="p-1"
onClick={() => scrollTo(current + 1)}
disabled={current === clients.length - 1}
aria-label="Next client"
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
)}
</div>
)
}
export function App(): React.JSX.Element { export function App(): React.JSX.Element {
const [status, setStatus] = useState<RelayStatus>(EMPTY_STATUS) const [status, setStatus] = useState<RelayStatus>(EMPTY_STATUS)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@@ -137,6 +265,18 @@ export function App(): React.JSX.Element {
setStatus(await window.api.setMirrorEnabled(index, enabled)) setStatus(await window.api.setMirrorEnabled(index, enabled))
} }
const onToggleBlacklist = async (appId: string, blacklisted: boolean): Promise<void> => {
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> => { const onToggleAutostart = async (enabled: boolean): Promise<void> => {
setAutostart(await window.api.setAutostart(enabled)) setAutostart(await window.api.setAutostart(enabled))
} }
@@ -148,7 +288,7 @@ export function App(): React.JSX.Element {
if (status.unsupported) { if (status.unsupported) {
return ( return (
<div className="flex flex-col h-screen items-center justify-center p-5 gap-3 text-center"> <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> <p className="text-sm text-zinc-400">Windows is not supported.</p>
</div> </div>
) )
@@ -196,6 +336,33 @@ export function App(): React.JSX.Element {
</div> </div>
</section> </section>
<section className="rounded-xl bg-zinc-800/60 border border-zinc-700 p-4 flex flex-col gap-2">
<h2 className="text-sm text-zinc-400 mb-1">Blacklisted apps</h2>
<div className="flex flex-col gap-1.5 text-sm">
{status.blacklistedApps.length > 0 ? (
status.blacklistedApps.map((app) => (
<div key={app.id} className="flex items-center justify-between gap-2">
<div className="flex flex-col min-w-0">
<span className="text-zinc-300 truncate">{app.name ?? 'Unknown app'}</span>
<span className="text-xs text-zinc-500 truncate">{app.id}</span>
</div>
<Button
variant="ghost"
className="p-1 shrink-0"
onClick={() => onToggleBlacklist(app.id, false)}
aria-label={`Remove ${app.name ?? app.id} from blacklist`}
title="Remove from blacklist and mirror again"
>
<EyeOff className="w-4 h-4 text-red-400" />
</Button>
</div>
))
) : (
<div className="text-zinc-500">No blacklisted apps</div>
)}
</div>
</section>
{appVersion && ( {appVersion && (
<p className="text-xs text-zinc-500 mt-auto"> <p className="text-xs text-zinc-500 mt-auto">
Version {appVersion.version} ({appVersion.commit}) Version {appVersion.version} ({appVersion.commit})
@@ -208,7 +375,7 @@ export function App(): React.JSX.Element {
return ( return (
<div className="flex flex-col h-screen p-5 gap-4"> <div className="flex flex-col h-screen p-5 gap-4">
<header className="flex items-center justify-between gap-3"> <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 <Button
variant="ghost" variant="ghost"
onClick={() => setView('settings')} onClick={() => setView('settings')}
@@ -255,43 +422,45 @@ export function App(): React.JSX.Element {
<InstanceRow <InstanceRow
key={instance.index} key={instance.index}
instance={instance} instance={instance}
reordering={status.reordering}
onToggleMirror={onToggleMirror} onToggleMirror={onToggleMirror}
onUnlock={onUnlockPrimary}
onPromote={onPromote}
/> />
)) ))
) : ( ) : (
<div className="text-zinc-500">No Discord instances detected</div> <div className="text-zinc-500">No Discord instances detected</div>
)} )}
</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>
<section className="rounded-xl bg-zinc-800/60 border border-zinc-700 p-4 flex flex-col gap-2"> <section className="rounded-xl bg-zinc-800/60 border border-zinc-700 p-4 flex flex-col gap-2">
<h2 className="text-sm text-zinc-400 mb-1">Connected RPC Clients</h2> <div className="flex items-center justify-between mb-1">
<div className="flex flex-col gap-1.5 text-sm"> <h2 className="text-sm text-zinc-400">Connected RPC Clients</h2>
{status.connectedClients.length > 0 && (
<span className="text-xs text-zinc-500 bg-zinc-700/60 rounded-full px-2 py-0.5">
{status.connectedClients.length}
</span>
)}
</div>
<div className="text-sm">
{status.connectedClients.length > 0 ? ( {status.connectedClients.length > 0 ? (
status.connectedClients.map((client) => ( <ClientCarousel
<div key={client.id} className="flex items-center justify-between gap-2"> clients={status.connectedClients}
<span className="text-zinc-300"> onToggleBlacklist={onToggleBlacklist}
{client.process />
? `${client.process.name} (pid ${client.process.pid})`
: 'unknown process'}
</span>
</div>
))
) : ( ) : (
<div className="text-zinc-500">No clients connected</div> <div className="text-zinc-500">No clients connected</div>
)} )}
</div> </div>
</section> </section>
<section className="rounded-xl bg-zinc-800/60 border border-zinc-700 p-4 flex flex-col gap-2">
<h2 className="text-sm text-zinc-400 mb-1">Last Mirrored Activity</h2>
{status.lastActivity ? (
<ActivityPreview activity={status.lastActivity} />
) : (
<div className="text-sm text-zinc-300">No activity yet</div>
)}
</section>
<p className="text-xs text-zinc-500 mt-auto"> <p className="text-xs text-zinc-500 mt-auto">
Apps using Discord Rich Presence need to be restarted after toggling the relay to pick up Apps using Discord Rich Presence need to be restarted after toggling the relay to pick up
the change. the change.
+71 -14
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 type { RelayInstance } from '../../../main/relay'
import { Toggle } from './Toggle' import { Toggle } from './Toggle'
import { Button } from './Button'
function shortPath(p: string): string { function shortPath(p: string): string {
return p.split('/').pop() ?? p return p.split('/').pop() ?? p
@@ -8,14 +9,71 @@ function shortPath(p: string): string {
interface Props { interface Props {
instance: RelayInstance instance: RelayInstance
reordering: boolean
onToggleMirror: (index: number, enabled: boolean) => Promise<void> 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 const procLabel = instance.process
? `${instance.process.name} (pid ${instance.process.pid})` ? `${instance.process.name} (pid ${instance.process.pid})`
: 'unknown process' : '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 = ( const info = (
<div className="flex flex-col"> <div className="flex flex-col">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -29,6 +87,7 @@ export function InstanceRow({ instance, onToggleMirror }: Props): React.JSX.Elem
mirror #{instance.index} mirror #{instance.index}
</span> </span>
)} )}
{instance.locked && <span className="text-xs text-amber-400">locked</span>}
</div> </div>
<span className="text-xs text-zinc-500">{procLabel}</span> <span className="text-xs text-zinc-500">{procLabel}</span>
</div> </div>
@@ -38,14 +97,9 @@ export function InstanceRow({ instance, onToggleMirror }: Props): React.JSX.Elem
return ( return (
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
{info} {info}
<Info className="w-4 h-4 text-zinc-400 hover:text-zinc-100 cursor-help transition-colors"> <div className="flex items-center gap-1 shrink-0">
<title> {instance.locked ? unlockButton : lockButton}
The primary instance gets full passthrough and can&apos;t be turned off. It&apos;s </div>
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> </div>
) )
} }
@@ -53,10 +107,13 @@ export function InstanceRow({ instance, onToggleMirror }: Props): React.JSX.Elem
return ( return (
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
{info} {info}
<Toggle <div className="flex items-center gap-1 shrink-0">
checked={instance.enabled} {promoteButton}
onChange={(checked) => onToggleMirror(instance.index, checked)} <Toggle
/> checked={instance.enabled}
onChange={(checked) => onToggleMirror(instance.index, checked)}
/>
</div>
</div> </div>
) )
} }