mirror of
https://github.com/YuzuZensai/Discord-Presence-Relay.git
synced 2026-09-13 10:49:02 +00:00
✨ feat: initial commit
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
node_modules
|
||||
dist
|
||||
out
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
*.log*
|
||||
*.tsbuildinfo
|
||||
.env
|
||||
.env.*
|
||||
.vscode
|
||||
.idea
|
||||
@@ -0,0 +1,6 @@
|
||||
out
|
||||
dist
|
||||
pnpm-lock.yaml
|
||||
LICENSE.md
|
||||
tsconfig.json
|
||||
tsconfig.*.json
|
||||
@@ -0,0 +1,4 @@
|
||||
singleQuote: true
|
||||
semi: false
|
||||
printWidth: 100
|
||||
trailingComma: none
|
||||
@@ -0,0 +1,34 @@
|
||||
# discord-rpc-relay
|
||||
|
||||
A minimal Electron application with TypeScript
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
- [VSCode](https://code.visualstudio.com/) + [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) + [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)
|
||||
|
||||
## Project Setup
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
$ pnpm install
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
$ pnpm dev
|
||||
```
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
# For windows
|
||||
$ pnpm build:win
|
||||
|
||||
# For macOS
|
||||
$ pnpm build:mac
|
||||
|
||||
# For Linux
|
||||
$ pnpm build:linux
|
||||
```
|
||||
@@ -0,0 +1,43 @@
|
||||
appId: cafe.kirameki.discord-rpc-relay
|
||||
productName: Discord RPC Relay
|
||||
directories:
|
||||
buildResources: build
|
||||
files:
|
||||
- '!**/.vscode/*'
|
||||
- '!src/*'
|
||||
- '!electron.vite.config.{js,ts,mjs,cjs}'
|
||||
- '!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
|
||||
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
|
||||
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
|
||||
asarUnpack:
|
||||
- resources/**
|
||||
win:
|
||||
executableName: discord-rpc-relay
|
||||
nsis:
|
||||
artifactName: ${name}-${version}-setup.${ext}
|
||||
shortcutName: ${productName}
|
||||
uninstallDisplayName: ${productName}
|
||||
createDesktopShortcut: always
|
||||
mac:
|
||||
entitlementsInherit: build/entitlements.mac.plist
|
||||
extendInfo:
|
||||
- NSCameraUsageDescription: Application requests access to the device's camera.
|
||||
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
|
||||
- NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
|
||||
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
|
||||
notarize: false
|
||||
dmg:
|
||||
artifactName: ${name}-${version}.${ext}
|
||||
linux:
|
||||
target:
|
||||
- AppImage
|
||||
- snap
|
||||
- deb
|
||||
maintainer: electronjs.org
|
||||
category: Utility
|
||||
appImage:
|
||||
artifactName: ${name}-${version}.${ext}
|
||||
npmRebuild: false
|
||||
publish:
|
||||
provider: generic
|
||||
url: https://example.com/auto-updates
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
plugins: [externalizeDepsPlugin()]
|
||||
},
|
||||
preload: {
|
||||
plugins: [externalizeDepsPlugin()]
|
||||
},
|
||||
renderer: {
|
||||
plugins: [tailwindcss(), react()]
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'eslint/config'
|
||||
import tseslint from '@electron-toolkit/eslint-config-ts'
|
||||
import eslintConfigPrettier from '@electron-toolkit/eslint-config-prettier'
|
||||
|
||||
export default defineConfig(
|
||||
{ ignores: ['**/node_modules', '**/dist', '**/out'] },
|
||||
tseslint.configs.recommended,
|
||||
eslintConfigPrettier
|
||||
)
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"name": "discord-rpc-relay",
|
||||
"version": "1.0.0",
|
||||
"description": "Mirrors Discord Rich Presence (RPC) to multiple running Discord instances",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"format": "prettier --write .",
|
||||
"lint": "eslint --cache .",
|
||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
||||
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
||||
"start": "electron-vite preview",
|
||||
"dev": "electron-vite dev",
|
||||
"build": "npm run typecheck && electron-vite build",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"build:unpack": "npm run build && electron-builder --dir",
|
||||
"build:win": "npm run build && electron-builder --win",
|
||||
"build:mac": "npm run build && electron-builder --mac",
|
||||
"build:linux": "npm run build && electron-builder --linux"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"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": "^22.19.1",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"electron": "^39.2.6",
|
||||
"electron-builder": "^26.0.12",
|
||||
"electron-vite": "^5.0.0",
|
||||
"eslint": "^9.39.1",
|
||||
"prettier": "^3.7.4",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.6"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"electron",
|
||||
"esbuild",
|
||||
"koffi"
|
||||
]
|
||||
}
|
||||
}
|
||||
Generated
+4883
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
@@ -0,0 +1,179 @@
|
||||
import { app, shell, BrowserWindow, ipcMain, Tray, Menu, nativeImage } from 'electron'
|
||||
import { join } from 'path'
|
||||
import * as fs from 'fs'
|
||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||
import icon from '../../resources/icon.png?asset'
|
||||
import { relay, RelayStatus } from './relay'
|
||||
|
||||
function settingsPath(): string {
|
||||
return join(app.getPath('userData'), 'settings.json')
|
||||
}
|
||||
|
||||
function loadDisabledMirrors(): number[] {
|
||||
try {
|
||||
const raw = fs.readFileSync(settingsPath(), 'utf8')
|
||||
const data = JSON.parse(raw)
|
||||
return Array.isArray(data?.disabledMirrors) ? data.disabledMirrors : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function saveDisabledMirrors(indices: number[]): void {
|
||||
fs.writeFileSync(settingsPath(), JSON.stringify({ disabledMirrors: indices }), 'utf8')
|
||||
}
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let tray: Tray | null = null
|
||||
let quitting = false
|
||||
|
||||
function createWindow(): void {
|
||||
if (mainWindow) {
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
return
|
||||
}
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 420,
|
||||
height: 760,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
resizable: true,
|
||||
...(process.platform === 'linux' ? { icon } : {}),
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
sandbox: false
|
||||
}
|
||||
})
|
||||
|
||||
mainWindow.on('ready-to-show', () => {
|
||||
mainWindow?.show()
|
||||
})
|
||||
|
||||
mainWindow.on('close', (e) => {
|
||||
if (!quitting) {
|
||||
e.preventDefault()
|
||||
mainWindow?.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null
|
||||
})
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||
shell.openExternal(details.url)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
||||
} else {
|
||||
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
||||
}
|
||||
}
|
||||
|
||||
function createTray(): void {
|
||||
const trayIcon = nativeImage.createFromPath(icon).resize({ width: 24, height: 24 })
|
||||
tray = new Tray(trayIcon)
|
||||
tray.setToolTip('Discord RPC Relay')
|
||||
|
||||
updateTrayMenu(relay.getStatus())
|
||||
|
||||
tray.on('click', () => {
|
||||
createWindow()
|
||||
})
|
||||
}
|
||||
|
||||
function updateTrayMenu(status: RelayStatus): void {
|
||||
if (!tray) return
|
||||
|
||||
const menu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: status.running ? 'Relay: Running' : 'Relay: Stopped',
|
||||
enabled: false
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: status.running ? 'Stop Relay' : 'Start Relay',
|
||||
click: async () => {
|
||||
try {
|
||||
if (status.running) {
|
||||
await relay.stop()
|
||||
} else {
|
||||
await relay.start()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Show Window',
|
||||
click: () => createWindow()
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Quit',
|
||||
click: () => {
|
||||
quitting = true
|
||||
app.quit()
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
tray.setContextMenu(menu)
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
electronApp.setAppUserModelId('cafe.kirameki.discord-rpc-relay')
|
||||
|
||||
app.on('browser-window-created', (_, window) => {
|
||||
optimizer.watchWindowShortcuts(window)
|
||||
})
|
||||
|
||||
relay.setDisabledMirrors(loadDisabledMirrors())
|
||||
|
||||
ipcMain.handle('relay:get-status', () => relay.getStatus())
|
||||
ipcMain.handle('relay:start', () => relay.start())
|
||||
ipcMain.handle('relay:stop', () => relay.stop())
|
||||
ipcMain.handle('relay:get-autostart', () => app.getLoginItemSettings().openAtLogin)
|
||||
ipcMain.handle('relay:set-autostart', (_e, enabled: boolean) => {
|
||||
app.setLoginItemSettings({ openAtLogin: enabled })
|
||||
return app.getLoginItemSettings().openAtLogin
|
||||
})
|
||||
ipcMain.handle('relay:set-mirror-enabled', (_e, index: number, enabled: boolean) => {
|
||||
const status = relay.setMirrorEnabled(index, enabled)
|
||||
saveDisabledMirrors(relay.getDisabledMirrors())
|
||||
return status
|
||||
})
|
||||
|
||||
relay.on('status', (status: RelayStatus) => {
|
||||
updateTrayMenu(status)
|
||||
mainWindow?.webContents.send('relay:status', status)
|
||||
})
|
||||
|
||||
createTray()
|
||||
createWindow()
|
||||
|
||||
relay.start().catch((err) => console.error('Failed to start relay:', err))
|
||||
|
||||
app.on('activate', function () {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||
})
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
// Keep running in the tray
|
||||
})
|
||||
|
||||
let stopping = false
|
||||
|
||||
app.on('before-quit', (e) => {
|
||||
quitting = true
|
||||
if (stopping) return
|
||||
stopping = true
|
||||
e.preventDefault()
|
||||
relay.stop().finally(() => app.exit(0))
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Discord IPC frames
|
||||
* https://discord.com/developers/docs/topics/rpc#payloads
|
||||
*/
|
||||
export const OP_HANDSHAKE = 0
|
||||
export const OP_FRAME = 1
|
||||
|
||||
export interface Frame {
|
||||
op: number
|
||||
payload: Buffer
|
||||
}
|
||||
|
||||
/** Buffers stream chunks and yields complete IPC frames as they arrive. */
|
||||
export class FrameReader {
|
||||
private buf = Buffer.alloc(0)
|
||||
|
||||
push(chunk: Buffer): Frame[] {
|
||||
this.buf = Buffer.concat([this.buf, chunk])
|
||||
const frames: Frame[] = []
|
||||
|
||||
for (;;) {
|
||||
if (this.buf.length < 8) break
|
||||
const op = this.buf.readUInt32LE(0)
|
||||
const len = this.buf.readUInt32LE(4)
|
||||
if (this.buf.length < 8 + len) break
|
||||
|
||||
frames.push({ op, payload: Buffer.from(this.buf.subarray(8, 8 + len)) })
|
||||
this.buf = this.buf.subarray(8 + len)
|
||||
}
|
||||
|
||||
return frames
|
||||
}
|
||||
}
|
||||
|
||||
export function encodeFrame(op: number, payload: Buffer): Buffer {
|
||||
const header = Buffer.alloc(8)
|
||||
header.writeUInt32LE(op, 0)
|
||||
header.writeUInt32LE(payload.length, 4)
|
||||
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 {
|
||||
try {
|
||||
return JSON.parse(frame.payload.toString('utf8'))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import * as net from 'net'
|
||||
import { encodeFrame, FrameReader, OP_FRAME, OP_HANDSHAKE } from './ipc-protocol'
|
||||
|
||||
/**
|
||||
* A lazily-established connection to a secondary Discord instance that
|
||||
* mirrors the primary connection's handshake and SET_ACTIVITY frames.
|
||||
*/
|
||||
export class MirrorConnection {
|
||||
private readonly sock: net.Socket
|
||||
private readonly reader = new FrameReader()
|
||||
|
||||
constructor(socketPath: string, handshakePayload: Buffer, onClose: () => void) {
|
||||
this.sock = net.createConnection(socketPath)
|
||||
|
||||
this.sock.on('connect', () => {
|
||||
this.sock.write(encodeFrame(OP_HANDSHAKE, handshakePayload))
|
||||
})
|
||||
|
||||
// Drain responses; the mirror connection only needs to look like a real client.
|
||||
this.sock.on('data', (chunk) => this.reader.push(chunk))
|
||||
|
||||
this.sock.on('error', onClose)
|
||||
this.sock.on('close', onClose)
|
||||
}
|
||||
|
||||
sendActivity(payload: Buffer): void {
|
||||
if (this.sock.writable) {
|
||||
this.sock.write(encodeFrame(OP_FRAME, payload))
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.sock.destroy()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { PosixPlatform } from './posix'
|
||||
import { UnsupportedPlatform } from './unsupported'
|
||||
import type { RelayPlatform } from './types'
|
||||
|
||||
export type { ClaimedSocket, ProcessInfo, RelayPlatform } from './types'
|
||||
|
||||
export const platform: RelayPlatform =
|
||||
process.platform === 'win32' ? new UnsupportedPlatform() : new PosixPlatform()
|
||||
@@ -0,0 +1,255 @@
|
||||
import * as fs from 'fs'
|
||||
import * as net from 'net'
|
||||
import * as path from 'path'
|
||||
import type { ClaimedSocket, ProcessInfo, RelayPlatform } from './types'
|
||||
|
||||
const REAL_PREFIX = 'discord-ipc-real-'
|
||||
const FAKE_INDEX = 0
|
||||
const MAX_SOCKETS = 10
|
||||
|
||||
function runtimeDir(): string {
|
||||
if (process.env.XDG_RUNTIME_DIR) return process.env.XDG_RUNTIME_DIR
|
||||
if (process.platform === 'darwin') return process.env.TMPDIR ?? '/tmp'
|
||||
return `/run/user/${process.getuid?.() ?? 0}`
|
||||
}
|
||||
|
||||
function ipcPath(index: number): string {
|
||||
return path.join(runtimeDir(), `discord-ipc-${index}`)
|
||||
}
|
||||
|
||||
function claimedPath(index: number): string {
|
||||
return path.join(runtimeDir(), `${REAL_PREFIX}${index}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Linux + macOS implementation. Discord listens on Unix domain sockets named
|
||||
* discord-ipc-0..9 in the runtime directory. We take over index 0 by renaming
|
||||
* the real socket out of the way and binding our own server in its place,
|
||||
* connecting through to the renamed socket for passthrough.
|
||||
*/
|
||||
export class PosixPlatform implements RelayPlatform {
|
||||
readonly isSupported = true
|
||||
|
||||
fakeSocketPath(): string {
|
||||
return ipcPath(FAKE_INDEX)
|
||||
}
|
||||
|
||||
removeFakeSocket(fakePath: string): void {
|
||||
if (fs.existsSync(fakePath)) fs.unlinkSync(fakePath)
|
||||
}
|
||||
|
||||
finalizeFakeSocket(fakePath: string): void {
|
||||
fs.chmodSync(fakePath, 0o777)
|
||||
}
|
||||
|
||||
fakeSocketExists(fakePath: string): boolean {
|
||||
return fs.existsSync(fakePath)
|
||||
}
|
||||
|
||||
async recoverLeftoverSockets(): Promise<void> {
|
||||
for (let i = 0; i < MAX_SOCKETS; i++) {
|
||||
const leftover = claimedPath(i)
|
||||
const original = ipcPath(i)
|
||||
if (!fs.existsSync(leftover)) continue
|
||||
|
||||
if (!fs.existsSync(original)) {
|
||||
fs.renameSync(leftover, original)
|
||||
continue
|
||||
}
|
||||
|
||||
// The original path may be a dead fake socket left behind by a
|
||||
// relay process that died without cleaning up. If nothing is
|
||||
// listening there, remove it and restore the real socket.
|
||||
if (!(await isSocketAlive(original))) {
|
||||
fs.unlinkSync(original)
|
||||
fs.renameSync(leftover, original)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
discoverAndClaim(): ClaimedSocket[] {
|
||||
const found: ClaimedSocket[] = []
|
||||
for (let i = 0; i < MAX_SOCKETS; i++) {
|
||||
const claimed = this.discoverNewSocket(i)
|
||||
if (claimed) found.push(claimed)
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
discoverNewSocket(index: number): ClaimedSocket | null {
|
||||
const src = ipcPath(index)
|
||||
if (!fs.existsSync(src)) return null
|
||||
|
||||
const dst = claimedPath(index)
|
||||
fs.renameSync(src, dst)
|
||||
return { index, path: dst }
|
||||
}
|
||||
|
||||
restoreSocket(claimed: ClaimedSocket): void {
|
||||
const original = ipcPath(claimed.index)
|
||||
if (fs.existsSync(claimed.path) && !fs.existsSync(original)) {
|
||||
fs.renameSync(claimed.path, original)
|
||||
}
|
||||
}
|
||||
|
||||
getInstanceProcess(index: number): ProcessInfo | null {
|
||||
// The kernel reports a Unix socket's *original* bind path in
|
||||
// /proc/net/unix even after the file is renamed on disk, so look up
|
||||
// owners by the pre-rename discord-ipc-N path rather than the claimed one.
|
||||
return findSocketOwner(ipcPath(index))
|
||||
}
|
||||
|
||||
getPeerProcess(fd: number): ProcessInfo | null {
|
||||
return findPeerProcess(fd)
|
||||
}
|
||||
}
|
||||
|
||||
function isSocketAlive(socketPath: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const sock = net.createConnection(socketPath)
|
||||
sock.once('connect', () => {
|
||||
sock.destroy()
|
||||
resolve(true)
|
||||
})
|
||||
sock.once('error', () => resolve(false))
|
||||
})
|
||||
}
|
||||
|
||||
const SOCKET_OWNER_CACHE_MS = 2000
|
||||
const socketOwnerCache = new Map<string, { value: ProcessInfo | null; at: number }>()
|
||||
|
||||
/**
|
||||
* Identifies the process listening on a Unix socket file by cross-referencing
|
||||
* /proc/net/unix (path -> inode) with /proc/*\/fd (inode -> pid). Linux only;
|
||||
* results are cached briefly since this scans every process's open fds.
|
||||
*/
|
||||
function findSocketOwner(socketPath: string): ProcessInfo | null {
|
||||
if (process.platform !== 'linux') return null
|
||||
|
||||
const cached = socketOwnerCache.get(socketPath)
|
||||
if (cached && Date.now() - cached.at < SOCKET_OWNER_CACHE_MS) {
|
||||
return cached.value
|
||||
}
|
||||
|
||||
const result = locateSocketOwner(socketPath)
|
||||
socketOwnerCache.set(socketPath, { value: result, at: Date.now() })
|
||||
return result
|
||||
}
|
||||
|
||||
function locateSocketOwner(socketPath: string): ProcessInfo | null {
|
||||
// Multiple sockets can be bound to the same path (our own fake server
|
||||
// shares discord-ipc-0's path with the real Discord socket after rename),
|
||||
// so check every matching inode and skip the one owned by this process.
|
||||
for (const inode of findInodesForPath(socketPath)) {
|
||||
const owner = findProcessForInode(inode)
|
||||
if (owner && owner.pid !== process.pid) return owner
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function findInodesForPath(socketPath: string): string[] {
|
||||
const inodes: string[] = []
|
||||
try {
|
||||
const unixTable = fs.readFileSync('/proc/net/unix', 'utf8')
|
||||
for (const line of unixTable.split('\n')) {
|
||||
if (line.endsWith(` ${socketPath}`)) {
|
||||
inodes.push(line.trim().split(/\s+/)[6])
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return inodes
|
||||
}
|
||||
return inodes
|
||||
}
|
||||
|
||||
function findProcessForInode(inode: string): ProcessInfo | null {
|
||||
return findProcessForInodes(new Set([inode]))
|
||||
}
|
||||
|
||||
function findProcessForInodes(inodes: Set<string>): ProcessInfo | null {
|
||||
try {
|
||||
for (const pidStr of fs.readdirSync('/proc')) {
|
||||
if (!/^\d+$/.test(pidStr)) continue
|
||||
if (parseInt(pidStr, 10) === process.pid) continue
|
||||
|
||||
const fdDir = `/proc/${pidStr}/fd`
|
||||
let fds: string[]
|
||||
try {
|
||||
fds = fs.readdirSync(fdDir)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const fd of fds) {
|
||||
let link: string
|
||||
try {
|
||||
link = fs.readlinkSync(path.join(fdDir, fd))
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
const match = /^socket:\[(\d+)\]$/.exec(link)
|
||||
if (match && inodes.has(match[1])) {
|
||||
return { pid: parseInt(pidStr, 10), name: processName(pidStr) }
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function processName(pidStr: string): string {
|
||||
try {
|
||||
return fs.readFileSync(`/proc/${pidStr}/comm`, 'utf8').trim()
|
||||
} catch {
|
||||
return pidStr
|
||||
}
|
||||
}
|
||||
|
||||
interface GetSockOpt {
|
||||
(sockfd: number, level: number, optname: number, optval: Buffer, optlen: Buffer): number
|
||||
}
|
||||
|
||||
let getsockopt: GetSockOpt | null | undefined
|
||||
|
||||
function loadGetSockOpt(): GetSockOpt | null {
|
||||
if (getsockopt !== undefined) return getsockopt
|
||||
|
||||
if (process.platform !== 'linux') {
|
||||
getsockopt = null
|
||||
return getsockopt
|
||||
}
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const koffi = require('koffi')
|
||||
const libc = koffi.load('libc.so.6')
|
||||
getsockopt = libc.func(
|
||||
'int getsockopt(int sockfd, int level, int optname, void *optval, int *optlen)'
|
||||
) as GetSockOpt
|
||||
} catch {
|
||||
getsockopt = null
|
||||
}
|
||||
return getsockopt
|
||||
}
|
||||
|
||||
const SOL_SOCKET = 1
|
||||
const SO_PEERCRED = 17
|
||||
const UCRED_SIZE = 12 // struct ucred { pid_t pid; uid_t uid; gid_t gid; }
|
||||
|
||||
function findPeerProcess(localFd: number): ProcessInfo | null {
|
||||
const fn = loadGetSockOpt()
|
||||
if (!fn) return null
|
||||
|
||||
const optval = Buffer.alloc(UCRED_SIZE)
|
||||
const optlen = Buffer.alloc(4)
|
||||
optlen.writeInt32LE(UCRED_SIZE, 0)
|
||||
|
||||
if (fn(localFd, SOL_SOCKET, SO_PEERCRED, optval, optlen) !== 0) return null
|
||||
|
||||
const pid = optval.readInt32LE(0)
|
||||
if (pid <= 0) return null
|
||||
|
||||
return { pid, name: processName(String(pid)) }
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
export interface ProcessInfo {
|
||||
pid: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface ClaimedSocket {
|
||||
index: number
|
||||
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 {
|
||||
readonly isSupported: boolean
|
||||
|
||||
fakeSocketPath(): string
|
||||
|
||||
removeFakeSocket(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). */
|
||||
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>
|
||||
|
||||
discoverAndClaim(): ClaimedSocket[]
|
||||
|
||||
discoverNewSocket(index: number): ClaimedSocket | null
|
||||
|
||||
restoreSocket(claimed: ClaimedSocket): void
|
||||
|
||||
getInstanceProcess(index: number): ProcessInfo | null
|
||||
|
||||
getPeerProcess(fd: number): ProcessInfo | null
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { ClaimedSocket, ProcessInfo, RelayPlatform } from './types'
|
||||
|
||||
const NOT_SUPPORTED = new Error('Platform not supported')
|
||||
|
||||
/**
|
||||
* Placeholder for platforms without a real implementation yet.
|
||||
*/
|
||||
export class UnsupportedPlatform implements RelayPlatform {
|
||||
readonly isSupported = false
|
||||
|
||||
fakeSocketPath(): string {
|
||||
throw NOT_SUPPORTED
|
||||
}
|
||||
|
||||
removeFakeSocket(): void {
|
||||
throw NOT_SUPPORTED
|
||||
}
|
||||
|
||||
finalizeFakeSocket(): void {
|
||||
throw NOT_SUPPORTED
|
||||
}
|
||||
|
||||
fakeSocketExists(): boolean {
|
||||
throw NOT_SUPPORTED
|
||||
}
|
||||
|
||||
async recoverLeftoverSockets(): Promise<void> {
|
||||
throw NOT_SUPPORTED
|
||||
}
|
||||
|
||||
discoverAndClaim(): ClaimedSocket[] {
|
||||
throw NOT_SUPPORTED
|
||||
}
|
||||
|
||||
discoverNewSocket(): ClaimedSocket | null {
|
||||
throw NOT_SUPPORTED
|
||||
}
|
||||
|
||||
restoreSocket(): void {
|
||||
throw NOT_SUPPORTED
|
||||
}
|
||||
|
||||
getInstanceProcess(): ProcessInfo | null {
|
||||
return null
|
||||
}
|
||||
|
||||
getPeerProcess(): ProcessInfo | null {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
import { EventEmitter } from 'events'
|
||||
import * as net from 'net'
|
||||
import { encodeFrame, Frame, FrameReader, OP_HANDSHAKE, parseFramePayload } from './ipc-protocol'
|
||||
import { MirrorConnection } from './mirror-connection'
|
||||
import { platform, type ClaimedSocket, type ProcessInfo } from './platform'
|
||||
|
||||
const MAX_SOCKETS = 10
|
||||
const DISCOVERY_INTERVAL_MS = 3000
|
||||
|
||||
interface AppAsset {
|
||||
id: string
|
||||
type: number
|
||||
name: string
|
||||
}
|
||||
|
||||
const appAssetCache = new Map<string, Map<string, string>>()
|
||||
|
||||
async function getAppAssetMap(appId: string): Promise<Map<string, string>> {
|
||||
const cached = appAssetCache.get(appId)
|
||||
if (cached) return cached
|
||||
|
||||
const map = new Map<string, string>()
|
||||
try {
|
||||
const res = await fetch(`https://discord.com/api/v9/oauth2/applications/${appId}/assets`)
|
||||
if (res.ok) {
|
||||
const assets = (await res.json()) as AppAsset[]
|
||||
for (const asset of assets) map.set(asset.name, asset.id)
|
||||
}
|
||||
} catch {
|
||||
// Network error: cache an empty map so we don't retry every frame.
|
||||
}
|
||||
|
||||
appAssetCache.set(appId, map)
|
||||
return map
|
||||
}
|
||||
|
||||
async function resolveAssetImage(
|
||||
key: string | undefined,
|
||||
appId: string | null
|
||||
): Promise<string | null> {
|
||||
if (!key) return null
|
||||
if (key.startsWith('mp:external/')) {
|
||||
return `https://media.discordapp.net/external/${key.slice('mp:external/'.length)}`
|
||||
}
|
||||
if (key.startsWith('mp:')) {
|
||||
return `https://media.discordapp.net/${key.slice('mp:'.length)}`
|
||||
}
|
||||
if (key.startsWith('http://') || key.startsWith('https://')) return key
|
||||
if (!appId) return null
|
||||
|
||||
// Numeric snowflake asset ids can be used directly.
|
||||
if (/^\d+$/.test(key)) return `https://cdn.discordapp.com/app-assets/${appId}/${key}.png`
|
||||
|
||||
// Named assets need resolving to their numeric id via the app's asset list.
|
||||
const assetMap = await getAppAssetMap(appId)
|
||||
const assetId = assetMap.get(key)
|
||||
if (!assetId) return null
|
||||
return `https://cdn.discordapp.com/app-assets/${appId}/${assetId}.png`
|
||||
}
|
||||
|
||||
export interface RelayInstance {
|
||||
index: number
|
||||
path: string
|
||||
isPrimary: boolean
|
||||
enabled: boolean
|
||||
process: ProcessInfo | null
|
||||
}
|
||||
|
||||
export interface ConnectedClient {
|
||||
id: number
|
||||
process: ProcessInfo | null
|
||||
}
|
||||
|
||||
export interface ActivityAssets {
|
||||
largeImage: string | null
|
||||
largeText: string | null
|
||||
smallImage: string | null
|
||||
smallText: string | null
|
||||
}
|
||||
|
||||
export interface ActivityTimestamps {
|
||||
start: number | null
|
||||
end: number | null
|
||||
}
|
||||
|
||||
export interface ActivityButton {
|
||||
label: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface LastActivity {
|
||||
app: string | null
|
||||
details: string | null
|
||||
state: string | null
|
||||
assets: ActivityAssets | null
|
||||
timestamps: ActivityTimestamps | null
|
||||
buttons: ActivityButton[]
|
||||
at: number
|
||||
}
|
||||
|
||||
export interface RelayStatus {
|
||||
running: boolean
|
||||
unsupported: boolean
|
||||
instances: RelayInstance[]
|
||||
connectedClients: ConnectedClient[]
|
||||
lastActivity: LastActivity | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes over Discord's primary IPC socket (discord-ipc-0), passing every
|
||||
* frame through to the real primary instance unchanged, while mirroring the
|
||||
* handshake and SET_ACTIVITY frames to any other running Discord instances.
|
||||
*/
|
||||
export class RpcRelay extends EventEmitter {
|
||||
private server: net.Server | null = null
|
||||
private claimed: ClaimedSocket[] = []
|
||||
private disabledMirrors = new Set<number>()
|
||||
private connectedClients = new Map<number, ConnectedClient>()
|
||||
private nextClientId = 1
|
||||
private discoveryTimer: NodeJS.Timeout | null = null
|
||||
private lastActivity: LastActivity | null = null
|
||||
private running = false
|
||||
private lastError: string | null = null
|
||||
private restarting = false
|
||||
|
||||
getStatus(): RelayStatus {
|
||||
const instances: RelayInstance[] = this.claimed.map(({ index, path: socketPath }, i) => ({
|
||||
index,
|
||||
path: socketPath,
|
||||
isPrimary: i === 0,
|
||||
enabled: i === 0 || !this.disabledMirrors.has(index),
|
||||
process: platform.getInstanceProcess(index)
|
||||
}))
|
||||
|
||||
return {
|
||||
running: this.running,
|
||||
unsupported: !platform.isSupported,
|
||||
instances,
|
||||
connectedClients: [...this.connectedClients.values()],
|
||||
lastActivity: this.lastActivity,
|
||||
error: this.lastError
|
||||
}
|
||||
}
|
||||
|
||||
setMirrorEnabled(index: number, enabled: boolean): RelayStatus {
|
||||
if (index === this.primaryIndex()) return this.getStatus()
|
||||
|
||||
if (enabled) {
|
||||
this.disabledMirrors.delete(index)
|
||||
} else {
|
||||
this.disabledMirrors.add(index)
|
||||
}
|
||||
this.emitStatus()
|
||||
return this.getStatus()
|
||||
}
|
||||
|
||||
getDisabledMirrors(): number[] {
|
||||
return [...this.disabledMirrors]
|
||||
}
|
||||
|
||||
setDisabledMirrors(indices: number[]): void {
|
||||
this.disabledMirrors = new Set(indices)
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.running) return
|
||||
this.lastError = null
|
||||
|
||||
if (!platform.isSupported) {
|
||||
this.lastError = 'This platform is not supported'
|
||||
this.emitStatus()
|
||||
throw new Error(this.lastError)
|
||||
}
|
||||
|
||||
await platform.recoverLeftoverSockets()
|
||||
this.claimed = platform.discoverAndClaim()
|
||||
|
||||
if (this.claimed.length === 0) {
|
||||
this.lastError = 'No running Discord clients found (no discord-ipc-N sockets)'
|
||||
this.emitStatus()
|
||||
throw new Error(this.lastError)
|
||||
}
|
||||
|
||||
const fake = platform.fakeSocketPath()
|
||||
platform.removeFakeSocket(fake)
|
||||
|
||||
this.server = net.createServer((sock) => this.handleClient(sock))
|
||||
this.server.on('error', (err) => {
|
||||
this.lastError = err.message
|
||||
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)
|
||||
|
||||
this.running = true
|
||||
this.startDiscoveryTimer()
|
||||
this.emitStatus()
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (!this.running) return
|
||||
this.running = false
|
||||
this.stopDiscoveryTimer()
|
||||
|
||||
if (this.server) {
|
||||
await new Promise<void>((resolve) => this.server!.close(() => resolve()))
|
||||
this.server = null
|
||||
}
|
||||
|
||||
platform.removeFakeSocket(platform.fakeSocketPath())
|
||||
for (const claimed of this.claimed) platform.restoreSocket(claimed)
|
||||
|
||||
this.claimed = []
|
||||
this.connectedClients.clear()
|
||||
this.emitStatus()
|
||||
}
|
||||
|
||||
private primaryIndex(): number | undefined {
|
||||
return this.claimed[0]?.index
|
||||
}
|
||||
|
||||
private startDiscoveryTimer(): void {
|
||||
this.stopDiscoveryTimer()
|
||||
this.discoveryTimer = setInterval(() => this.discoverNewInstances(), DISCOVERY_INTERVAL_MS)
|
||||
}
|
||||
|
||||
private stopDiscoveryTimer(): void {
|
||||
if (this.discoveryTimer) {
|
||||
clearInterval(this.discoveryTimer)
|
||||
this.discoveryTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Picks up Discord instances launched after the relay started, and recovers from a stolen primary socket. */
|
||||
private discoverNewInstances(): void {
|
||||
if (!this.running) return
|
||||
|
||||
if (!platform.fakeSocketExists(platform.fakeSocketPath())) {
|
||||
void this.restart()
|
||||
return
|
||||
}
|
||||
|
||||
const claimedIndices = new Set(this.claimed.map((c) => c.index))
|
||||
let changed = false
|
||||
|
||||
for (let i = 1; i < MAX_SOCKETS; i++) {
|
||||
if (claimedIndices.has(i)) continue
|
||||
const found = platform.discoverNewSocket(i)
|
||||
if (found) {
|
||||
this.claimed.push(found)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) this.emitStatus()
|
||||
}
|
||||
|
||||
private handleClient(client: net.Socket): void {
|
||||
const clientId = this.nextClientId++
|
||||
const fd = (client as unknown as { _handle?: { fd?: number } })._handle?.fd
|
||||
this.connectedClients.set(clientId, {
|
||||
id: clientId,
|
||||
process: fd !== undefined ? platform.getPeerProcess(fd) : null
|
||||
})
|
||||
this.emitStatus()
|
||||
|
||||
const primaryPath = this.claimed[0].path
|
||||
const primary = net.createConnection(primaryPath)
|
||||
|
||||
const clientReader = new FrameReader()
|
||||
const primaryReader = new FrameReader()
|
||||
|
||||
let handshakePayload: Buffer | null = null
|
||||
const mirrors = new Map<number, MirrorConnection>()
|
||||
|
||||
const cleanup = (): void => {
|
||||
client.destroy()
|
||||
primary.destroy()
|
||||
for (const mirror of mirrors.values()) mirror.destroy()
|
||||
mirrors.clear()
|
||||
this.connectedClients.delete(clientId)
|
||||
this.emitStatus()
|
||||
}
|
||||
|
||||
let clientAppId: string | null = null
|
||||
let primaryConnected = false
|
||||
const pendingToPrimary: Buffer[] = []
|
||||
|
||||
client.on('data', (chunk) => {
|
||||
for (const frame of clientReader.push(chunk)) {
|
||||
if (frame.op === OP_HANDSHAKE) {
|
||||
handshakePayload = frame.payload
|
||||
clientAppId = (parseFramePayload(frame)?.client_id as string) ?? null
|
||||
}
|
||||
|
||||
const encoded = encodeFrame(frame.op, frame.payload)
|
||||
if (primaryConnected && primary.writable) {
|
||||
primary.write(encoded)
|
||||
} else {
|
||||
pendingToPrimary.push(encoded)
|
||||
}
|
||||
|
||||
if (handshakePayload) this.mirrorFrame(frame, handshakePayload, mirrors)
|
||||
void this.recordActivity(frame, clientAppId)
|
||||
}
|
||||
})
|
||||
|
||||
primary.on('connect', () => {
|
||||
primaryConnected = true
|
||||
for (const encoded of pendingToPrimary.splice(0)) primary.write(encoded)
|
||||
|
||||
primary.on('data', (chunk) => {
|
||||
for (const frame of primaryReader.push(chunk)) {
|
||||
if (client.writable) client.write(encodeFrame(frame.op, frame.payload))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
client.on('error', cleanup)
|
||||
primary.on('error', (err) => {
|
||||
cleanup()
|
||||
if ((err as NodeJS.ErrnoException).code === 'ECONNREFUSED') {
|
||||
void this.restart()
|
||||
} else {
|
||||
this.lastError = `Primary connection error: ${err.message}`
|
||||
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 async restart(): Promise<void> {
|
||||
if (this.restarting) return
|
||||
this.restarting = true
|
||||
try {
|
||||
await this.stop()
|
||||
await this.start()
|
||||
} catch (err) {
|
||||
this.lastError = err instanceof Error ? err.message : String(err)
|
||||
this.emitStatus()
|
||||
} finally {
|
||||
this.restarting = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Forwards the handshake and SET_ACTIVITY frames to every enabled mirror instance. */
|
||||
private mirrorFrame(
|
||||
frame: Frame,
|
||||
handshakePayload: Buffer,
|
||||
mirrors: Map<number, MirrorConnection>
|
||||
): void {
|
||||
const isSetActivity =
|
||||
frame.op !== OP_HANDSHAKE && parseFramePayload(frame)?.cmd === 'SET_ACTIVITY'
|
||||
if (frame.op !== OP_HANDSHAKE && !isSetActivity) return
|
||||
|
||||
for (let i = 1; i < this.claimed.length; i++) {
|
||||
const { index, path: mirrorPath } = this.claimed[i]
|
||||
|
||||
if (this.disabledMirrors.has(index)) {
|
||||
mirrors.get(i)?.destroy()
|
||||
mirrors.delete(i)
|
||||
continue
|
||||
}
|
||||
|
||||
let mirror = mirrors.get(i)
|
||||
if (!mirror) {
|
||||
mirror = new MirrorConnection(mirrorPath, handshakePayload, () => mirrors.delete(i))
|
||||
mirrors.set(i, mirror)
|
||||
if (frame.op === OP_HANDSHAKE) continue // handshake already sent on connect
|
||||
}
|
||||
|
||||
if (frame.op !== OP_HANDSHAKE) mirror.sendActivity(frame.payload)
|
||||
}
|
||||
}
|
||||
|
||||
private async recordActivity(frame: Frame, appId: string | null): Promise<void> {
|
||||
if (frame.op === OP_HANDSHAKE) return
|
||||
|
||||
const data = parseFramePayload(frame)
|
||||
if (data?.cmd !== 'SET_ACTIVITY') return
|
||||
|
||||
const activity = (data.args as { activity?: Record<string, unknown> })?.activity ?? {}
|
||||
|
||||
const rawAssets = activity.assets as Record<string, string> | undefined
|
||||
const assets: ActivityAssets | null = rawAssets
|
||||
? {
|
||||
largeImage: await resolveAssetImage(rawAssets.large_image, appId),
|
||||
largeText: rawAssets.large_text ?? null,
|
||||
smallImage: await resolveAssetImage(rawAssets.small_image, appId),
|
||||
smallText: rawAssets.small_text ?? null
|
||||
}
|
||||
: null
|
||||
|
||||
const rawTimestamps = activity.timestamps as Record<string, number> | undefined
|
||||
const timestamps: ActivityTimestamps | null = rawTimestamps
|
||||
? {
|
||||
start: rawTimestamps.start ?? null,
|
||||
end: rawTimestamps.end ?? null
|
||||
}
|
||||
: null
|
||||
|
||||
const rawButtons = activity.buttons as Array<{ label: string; url: string }> | undefined
|
||||
const buttons: ActivityButton[] = Array.isArray(rawButtons)
|
||||
? rawButtons.map((b) => ({ label: b.label, url: b.url }))
|
||||
: []
|
||||
|
||||
this.lastActivity = {
|
||||
app: (activity.name as string) ?? null,
|
||||
details: (activity.details as string) ?? null,
|
||||
state: (activity.state as string) ?? null,
|
||||
assets,
|
||||
timestamps,
|
||||
buttons,
|
||||
at: Date.now()
|
||||
}
|
||||
this.emitStatus()
|
||||
}
|
||||
|
||||
private emitStatus(): void {
|
||||
this.emit('status', this.getStatus())
|
||||
}
|
||||
}
|
||||
|
||||
export const relay = new RpcRelay()
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
import { ElectronAPI } from '@electron-toolkit/preload'
|
||||
import type { RelayApi } from './index'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electron: ElectronAPI
|
||||
api: RelayApi
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import { electronAPI } from '@electron-toolkit/preload'
|
||||
import type { RelayStatus } from '../main/relay'
|
||||
|
||||
const api = {
|
||||
getStatus: (): Promise<RelayStatus> => ipcRenderer.invoke('relay:get-status'),
|
||||
start: (): Promise<void> => ipcRenderer.invoke('relay:start'),
|
||||
stop: (): Promise<void> => ipcRenderer.invoke('relay:stop'),
|
||||
getAutostart: (): Promise<boolean> => ipcRenderer.invoke('relay:get-autostart'),
|
||||
setAutostart: (enabled: boolean): Promise<boolean> =>
|
||||
ipcRenderer.invoke('relay:set-autostart', enabled),
|
||||
setMirrorEnabled: (index: number, enabled: boolean): Promise<RelayStatus> =>
|
||||
ipcRenderer.invoke('relay:set-mirror-enabled', index, enabled),
|
||||
onStatus: (callback: (status: RelayStatus) => void): (() => void) => {
|
||||
const listener = (_e: unknown, status: RelayStatus): void => callback(status)
|
||||
ipcRenderer.on('relay:status', listener)
|
||||
return () => ipcRenderer.removeListener('relay:status', listener)
|
||||
}
|
||||
}
|
||||
|
||||
if (process.contextIsolated) {
|
||||
try {
|
||||
contextBridge.exposeInMainWorld('electron', electronAPI)
|
||||
contextBridge.exposeInMainWorld('api', api)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
} else {
|
||||
// @ts-ignore (define in dts)
|
||||
window.electron = electronAPI
|
||||
// @ts-ignore (define in dts)
|
||||
window.api = api
|
||||
}
|
||||
|
||||
export type RelayApi = typeof api
|
||||
@@ -0,0 +1,5 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
body {
|
||||
user-select: none;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Discord RPC Relay</title>
|
||||
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src * data:"
|
||||
/>
|
||||
|
||||
<link href="./assets/main.css" type="text/css" rel="stylesheet" />
|
||||
</head>
|
||||
|
||||
<body class="bg-zinc-900 text-zinc-100 font-sans antialiased">
|
||||
<div id="app"></div>
|
||||
|
||||
<script type="module" src="./src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,255 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ChevronLeft, Settings } from 'lucide-react'
|
||||
import type { RelayStatus } from '../../main/relay'
|
||||
import { InstanceRow } from './components/InstanceRow'
|
||||
import { Toggle } from './components/Toggle'
|
||||
import { Button, LinkButton } from './components/Button'
|
||||
|
||||
const EMPTY_STATUS: RelayStatus = {
|
||||
running: false,
|
||||
unsupported: false,
|
||||
instances: [],
|
||||
connectedClients: [],
|
||||
lastActivity: null,
|
||||
error: null
|
||||
}
|
||||
|
||||
function formatElapsed(start: number | null, end: number | null): string | null {
|
||||
const now = Date.now()
|
||||
if (end && end > now) {
|
||||
const remaining = Math.max(0, end - now)
|
||||
return `${formatDuration(remaining)} left`
|
||||
}
|
||||
if (start && start <= now) {
|
||||
return `${formatDuration(now - start)} elapsed`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const totalSeconds = Math.floor(ms / 1000)
|
||||
const hours = Math.floor(totalSeconds / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
|
||||
if (hours > 0)
|
||||
return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
|
||||
return `${minutes}:${String(seconds).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function ActivityPreview({
|
||||
activity
|
||||
}: {
|
||||
activity: RelayStatus['lastActivity']
|
||||
}): React.JSX.Element | null {
|
||||
if (!activity) return null
|
||||
|
||||
const { app, details, state, assets, timestamps, buttons, at } = activity
|
||||
const elapsed = timestamps ? formatElapsed(timestamps.start, timestamps.end) : null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex gap-3">
|
||||
<div className="relative shrink-0 w-16 h-16">
|
||||
{assets?.largeImage ? (
|
||||
<img
|
||||
src={assets.largeImage}
|
||||
alt={assets.largeText ?? app ?? ''}
|
||||
title={assets.largeText ?? undefined}
|
||||
className="w-16 h-16 rounded-lg object-cover bg-zinc-700"
|
||||
/>
|
||||
) : (
|
||||
<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() : '—'}
|
||||
</div>
|
||||
)}
|
||||
{assets?.smallImage && (
|
||||
<img
|
||||
src={assets.smallImage}
|
||||
alt={assets.smallText ?? ''}
|
||||
title={assets.smallText ?? undefined}
|
||||
className="absolute -bottom-1 -right-1 w-6 h-6 rounded-full ring-2 ring-zinc-800 object-cover bg-zinc-700"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col justify-center min-w-0">
|
||||
{app && <div className="text-sm font-medium text-zinc-100 truncate">{app}</div>}
|
||||
{details && <div className="text-sm text-zinc-200 truncate">{details}</div>}
|
||||
{state && <div className="text-sm text-zinc-400 truncate">{state}</div>}
|
||||
{elapsed && <div className="text-xs text-zinc-500 mt-0.5">{elapsed}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{buttons.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
{buttons.map((button, i) => (
|
||||
<LinkButton key={i} href={button.url} className="flex-1 text-xs py-1.5 px-2">
|
||||
{button.label}
|
||||
</LinkButton>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-zinc-500">
|
||||
Last mirrored at {new Date(at).toLocaleTimeString()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function App(): React.JSX.Element {
|
||||
const [status, setStatus] = useState<RelayStatus>(EMPTY_STATUS)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [autostart, setAutostart] = useState(false)
|
||||
const [view, setView] = useState<'main' | 'settings'>('main')
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
setStatus(await window.api.getStatus())
|
||||
setAutostart(await window.api.getAutostart())
|
||||
})()
|
||||
|
||||
return window.api.onStatus(setStatus)
|
||||
}, [])
|
||||
|
||||
const toggleRelay = async (): Promise<void> => {
|
||||
setLoading(true)
|
||||
try {
|
||||
if (status.running) {
|
||||
await window.api.stop()
|
||||
} else {
|
||||
await window.api.start()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onToggleMirror = async (index: number, enabled: boolean): Promise<void> => {
|
||||
setStatus(await window.api.setMirrorEnabled(index, enabled))
|
||||
}
|
||||
|
||||
const onToggleAutostart = async (enabled: boolean): Promise<void> => {
|
||||
setAutostart(await window.api.setAutostart(enabled))
|
||||
}
|
||||
|
||||
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>
|
||||
<p className="text-sm text-zinc-400">Windows is not supported.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (view === 'settings') {
|
||||
return (
|
||||
<div className="flex flex-col h-screen p-5 gap-4">
|
||||
<header className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setView('main')}
|
||||
aria-label="Back"
|
||||
className="p-1.5"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</Button>
|
||||
<h1 className="text-lg font-semibold">Settings</h1>
|
||||
</header>
|
||||
|
||||
<section className="rounded-xl bg-zinc-800/60 border border-zinc-700 p-4 flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm">Start on login</span>
|
||||
<span className="text-xs text-zinc-500">Launch automatically when you sign in</span>
|
||||
</div>
|
||||
<Toggle checked={autostart} onChange={onToggleAutostart} />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setView('settings')}
|
||||
aria-label="Settings"
|
||||
className="p-1.5"
|
||||
>
|
||||
<Settings className="w-5 h-5" />
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{status.error && (
|
||||
<div className="rounded-lg bg-red-950 border border-red-800 text-red-200 text-sm px-3 py-2">
|
||||
{status.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="rounded-xl bg-zinc-800/60 border border-zinc-700 p-4 flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm">Relay</span>
|
||||
<span className={`text-xs ${status.running ? 'text-emerald-400' : 'text-zinc-500'}`}>
|
||||
{status.running ? 'Running' : 'Stopped'}
|
||||
</span>
|
||||
</div>
|
||||
<Toggle checked={status.running} onChange={() => toggleRelay()} disabled={loading} />
|
||||
</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">Discord Instances</h2>
|
||||
<div className="flex flex-col gap-1.5 text-sm">
|
||||
{status.instances.length > 0 ? (
|
||||
status.instances.map((instance) => (
|
||||
<InstanceRow
|
||||
key={instance.index}
|
||||
instance={instance}
|
||||
onToggleMirror={onToggleMirror}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="text-zinc-500">No Discord instances detected</div>
|
||||
)}
|
||||
</div>
|
||||
</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">Connected RPC Clients</h2>
|
||||
<div className="flex flex-col gap-1.5 text-sm">
|
||||
{status.connectedClients.length > 0 ? (
|
||||
status.connectedClients.map((client) => (
|
||||
<div key={client.id} className="flex items-center justify-between gap-2">
|
||||
<span className="text-zinc-300">
|
||||
{client.process
|
||||
? `${client.process.name} (pid ${client.process.pid})`
|
||||
: 'unknown process'}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-zinc-500">No clients connected</div>
|
||||
)}
|
||||
</div>
|
||||
</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">
|
||||
Apps using Discord Rich Presence need to be restarted after toggling the relay to pick up
|
||||
the change.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { ButtonHTMLAttributes, AnchorHTMLAttributes } from 'react'
|
||||
|
||||
type Variant = 'primary' | 'danger' | 'ghost' | 'subtle'
|
||||
|
||||
const VARIANT_CLASSES: Record<Variant, string> = {
|
||||
primary: 'bg-emerald-600 hover:bg-emerald-500 text-white',
|
||||
danger: 'bg-red-600 hover:bg-red-500 text-white',
|
||||
ghost: 'text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800',
|
||||
subtle: 'bg-zinc-700 hover:bg-zinc-600 text-zinc-100'
|
||||
}
|
||||
|
||||
const BASE_CLASSES =
|
||||
'rounded-md text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: Variant
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
export function Button({
|
||||
variant = 'primary',
|
||||
active = false,
|
||||
className = '',
|
||||
children,
|
||||
...rest
|
||||
}: ButtonProps): React.JSX.Element {
|
||||
const activeClasses = active ? 'bg-zinc-700 text-zinc-100' : VARIANT_CLASSES[variant]
|
||||
return (
|
||||
<button className={`${BASE_CLASSES} ${activeClasses} ${className}`} {...rest}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
interface LinkButtonProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
|
||||
variant?: Variant
|
||||
}
|
||||
|
||||
export function LinkButton({
|
||||
variant = 'subtle',
|
||||
className = '',
|
||||
children,
|
||||
...rest
|
||||
}: LinkButtonProps): React.JSX.Element {
|
||||
return (
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={`${BASE_CLASSES} ${VARIANT_CLASSES[variant]} text-center truncate ${className}`}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Info } from 'lucide-react'
|
||||
import type { RelayInstance } from '../../../main/relay'
|
||||
import { Toggle } from './Toggle'
|
||||
|
||||
function shortPath(p: string): string {
|
||||
return p.split('/').pop() ?? p
|
||||
}
|
||||
|
||||
interface Props {
|
||||
instance: RelayInstance
|
||||
onToggleMirror: (index: number, enabled: boolean) => Promise<void>
|
||||
}
|
||||
|
||||
export function InstanceRow({ instance, onToggleMirror }: Props): React.JSX.Element {
|
||||
const procLabel = instance.process
|
||||
? `${instance.process.name} (pid ${instance.process.pid})`
|
||||
: 'unknown process'
|
||||
|
||||
const info = (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={instance.isPrimary ? 'text-zinc-300' : ''}>
|
||||
{shortPath(instance.path)}
|
||||
</span>
|
||||
{instance.isPrimary ? (
|
||||
<span className="text-xs text-emerald-400">primary</span>
|
||||
) : (
|
||||
<span className={`text-xs ${instance.enabled ? 'text-sky-400' : 'text-zinc-500'}`}>
|
||||
mirror #{instance.index}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-zinc-500">{procLabel}</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (instance.isPrimary) {
|
||||
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't be turned off. It'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>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{info}
|
||||
<Toggle
|
||||
checked={instance.enabled}
|
||||
onChange={(checked) => onToggleMirror(instance.index, checked)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
interface Props {
|
||||
checked: boolean
|
||||
onChange: (checked: boolean) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function Toggle({ checked, onChange, disabled = false }: Props): React.JSX.Element {
|
||||
return (
|
||||
<label
|
||||
className={`relative inline-flex items-center ${disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
/>
|
||||
<div className="w-10 h-6 bg-zinc-600 rounded-full peer-checked:bg-emerald-600 transition-colors" />
|
||||
<div className="absolute left-1 top-1 w-4 h-4 bg-white rounded-full transition-transform peer-checked:translate-x-4" />
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { App } from './App'
|
||||
|
||||
createRoot(document.getElementById('app')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
)
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
|
||||
"include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"types": ["electron-vite/node"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
|
||||
"include": ["src/renderer/**/*.ts", "src/renderer/**/*.tsx", "src/preload/*.d.ts"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"jsx": "react-jsx"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user