Author SHA1 Message Date
dependabot[bot]andGitHub 3ab4fbc582 build(deps-dev): bump vite from 8.1.5 to 8.2.0
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.1.5 to 8.2.0.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/create-vite@8.2.0/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.2.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-03 15:46:15 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
debdb9869e build(deps-dev): bump @vitejs/plugin-react from 6.0.3 to 6.0.4 (#15)
Bumps [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) from 6.0.3 to 6.0.4.
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.0.4/packages/plugin-react)

---
updated-dependencies:
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 6.0.3
  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-27 13:32:33 +07:00
yuzu 1919211311 🐛 fix: Correct depends_on macos syntax, document brew trust 2026-07-25 15:00:42 +07:00
yuzu 6f2b9574a9 feat: Add Homebrew cask, self-tap this repo 2026-07-25 14:04:26 +07:00
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
20 changed files with 2098 additions and 1048 deletions
+1
View File
@@ -0,0 +1 @@
github: [YuzuZensai]
+41 -2
View File
@@ -5,9 +5,12 @@ on:
branches:
- main
tags:
- 'v*'
- '[0-9]*.[0-9]*.[0-9]*'
pull_request:
permissions:
contents: read
jobs:
check:
strategy:
@@ -99,7 +102,7 @@ jobs:
release:
needs: build
if: startsWith(github.ref, 'refs/tags/v')
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
permissions:
contents: write
@@ -120,3 +123,39 @@ jobs:
dist/*.AppImage
dist/*.deb
generate_release_notes: true
update-cask:
needs: release
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v5
with:
ref: main
- name: Compute DMG sha256
id: sha
run: |
version="${GITHUB_REF_NAME}"
url="https://github.com/${GITHUB_REPOSITORY}/releases/download/${version}/discord-presence-relay-${version}.dmg"
curl -sL -o dpr.dmg "$url"
echo "value=$(shasum -a 256 dpr.dmg | cut -d ' ' -f1)" >> "$GITHUB_OUTPUT"
- name: Update cask
run: |
version="${GITHUB_REF_NAME}"
sed -i "s/version \".*\"/version \"${version}\"/" Casks/discord-presence-relay.rb
sed -i "s/sha256 \".*\"/sha256 \"${{ steps.sha.outputs.value }}\"/" Casks/discord-presence-relay.rb
- name: Commit and push
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add Casks/discord-presence-relay.rb
git diff --cached --quiet && exit 0
git commit -m "Update discord-presence-relay to ${GITHUB_REF_NAME}"
git push origin HEAD:main
+35
View File
@@ -0,0 +1,35 @@
cask "discord-presence-relay" do
version "1.2.0"
sha256 "3a7d83639f5f5fb5f9689866c4f96405a36f26bbe92891f444d62ec93df2c134"
url "https://github.com/YuzuZensai/Discord-RPC-Relay/releases/download/#{version}/discord-presence-relay-#{version}.dmg"
name "Discord Presence Relay"
desc "Mirrors Discord Rich Presence to every running Discord instance"
homepage "https://github.com/YuzuZensai/Discord-RPC-Relay"
auto_updates false
depends_on macos: :big_sur
app "Discord Presence Relay.app"
postflight do
system_command "/usr/bin/xattr",
args: ["-cr", "#{appdir}/Discord Presence Relay.app"],
sudo: false
end
zap trash: [
"~/Library/Application Support/Discord Presence Relay",
"~/Library/Preferences/cafe.kirameki.discord-presence-relay.plist",
"~/Library/Saved Application State/cafe.kirameki.discord-presence-relay.savedState"
]
caveats do
<<~EOS
#{token} is not notarized. The quarantine attribute is cleared
automatically on install, if macOS still refuses to open it
("app is damaged and can't be opened"), run:
xattr -cr "#{appdir}/Discord Presence Relay.app"
EOS
end
end
+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.
+30 -16
View File
@@ -1,11 +1,11 @@
# Discord RPC Relay
# 🪩 Discord-Presence-Relay
![Screenshot](assets/screenshot.png)
Mirrors Discord Rich Presence (RPC) activity to every running Discord
Mirrors Discord Rich Presence activity to every running Discord
instance (stable, PTB, Canary, ...), so apps that only connect to the
primary `discord-ipc-0` socket show their presence everywhere.
![Screenshot](assets/screenshot.png)
It works by taking over `discord-ipc-0`, passing all traffic through to the
real primary instance unchanged, and forwarding the handshake and
`SET_ACTIVITY` frames to the other detected instances.
@@ -15,16 +15,16 @@ Linux and macOS only.
## Install
Download the latest build for your platform from the
[Releases page](https://github.com/YuzuZensai/Discord-RPC-Relay/releases).
[Releases page](https://github.com/YuzuZensai/Discord-Presence-Relay/releases).
### Linux (AppImage)
1. Download `discord-rpc-relay-<version>.AppImage`.
1. Download `discord-presence-relay-<version>.AppImage`.
2. Make it executable and run it:
```bash
chmod +x discord-rpc-relay-*.AppImage
./discord-rpc-relay-*.AppImage
chmod +x discord-presence-relay-*.AppImage
./discord-presence-relay-*.AppImage
```
The app runs from the tray. To launch it automatically and have it appear in
@@ -34,24 +34,38 @@ settings once it's running.
### Linux (.deb)
1. Download `discord-rpc-relay-<version>.deb`.
1. Download `discord-presence-relay-<version>.deb`.
2. Install it:
```bash
sudo apt install ./discord-rpc-relay-*.deb
sudo apt install ./discord-presence-relay-*.deb
```
3. Launch it from your application menu ("Discord RPC Relay"), or from a
3. Launch it from your application menu ("Discord Presence Relay"), or from a
terminal:
```bash
discord-rpc-relay
discord-presence-relay
```
### macOS
### macOS (Homebrew)
1. Download `discord-rpc-relay-<version>.dmg` and open it.
2. Drag **Discord RPC Relay** into the **Applications** folder.
```bash
brew tap yuzuzensai/discord-rpc-relay https://github.com/YuzuZensai/Discord-RPC-Relay
brew trust yuzuzensai/discord-rpc-relay
brew install --cask discord-presence-relay
```
`brew trust` is required once per tap, Homebrew blocks installing from
third-party taps until you explicitly trust them.
The cask clears the Gatekeeper quarantine attribute automatically after
install, so the app opens normally right away.
### macOS (manual)
1. Download `discord-presence-relay-<version>.dmg` and open it.
2. Drag **Discord Presence Relay** into the **Applications** folder.
3. Clear the Gatekeeper quarantine attribute before first launch (see below),
then open the app from Applications/Launchpad/Spotlight as usual.
@@ -66,7 +80,7 @@ developer"). After installing, clear the quarantine attribute from the
Terminal:
```bash
xattr -cr /Applications/Discord\ RPC\ Relay.app
xattr -cr /Applications/Discord\ Presence\ Relay.app
```
Then open the app normally.
+4 -3
View File
@@ -15,11 +15,12 @@ process.env.BUILD_VERSION = getBuildVersion()
* @type {import('electron-builder').Configuration}
*/
export default {
appId: 'cafe.kirameki.discord-rpc-relay',
productName: 'Discord RPC Relay',
appId: 'cafe.kirameki.discord-presence-relay',
productName: 'Discord Presence Relay',
files: [
'!**/.vscode/*',
'!src/*',
'!Casks/*',
'!electron.vite.config.{js,ts,mjs,cjs}',
'!electronBuilder.mjs',
'!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}',
@@ -28,7 +29,7 @@ export default {
],
asarUnpack: ['resources/**'],
win: {
executableName: 'discord-rpc-relay'
executableName: 'discord-presence-relay'
},
nsis: {
artifactName: '${name}-${env.BUILD_VERSION}-setup.${ext}',
+15 -15
View File
@@ -1,9 +1,9 @@
{
"name": "discord-rpc-relay",
"name": "discord-presence-relay",
"version": "1.0.0",
"description": "Mirrors Discord Rich Presence (RPC) to multiple running Discord instances",
"description": "Mirrors Discord Rich Presence to multiple running Discord instances",
"main": "./out/main/index.js",
"packageManager": "pnpm@11.7.0+sha512.19cc852c120c7125760f2443ee6be0ca5b40f9f50598de1a09a1f177503e010e57c23c77646e01e761de59bf874fb22a3398c33ab9691fc13eb946b6f0f4d620",
"packageManager": "pnpm@11.17.0+sha512.cca3cea332ad254bb84145f966d19f4879615210346fc92c79a047f23a0d7b3cca3c3792f0076ba1f1831d277efbcf0a9119b31a9a60eca7fb3d6231f331ef72",
"author": "",
"private": true,
"scripts": {
@@ -25,27 +25,27 @@
"dependencies": {
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"koffi": "^3.0.2",
"lucide-react": "^1.18.0",
"react": "^19.2.7",
"react-dom": "^19.2.7"
"koffi": "^3.1.2",
"lucide-react": "^1.25.0",
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
"devDependencies": {
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
"@electron-toolkit/eslint-config-ts": "^3.1.0",
"@electron-toolkit/tsconfig": "^2.0.0",
"@tailwindcss/vite": "^4.3.1",
"@types/node": "^25.9.3",
"@tailwindcss/vite": "^4.3.3",
"@types/node": "^26.1.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0",
"electron": "^42.4.0",
"@vitejs/plugin-react": "^6.0.4",
"electron": "^43.2.0",
"electron-builder": "^26.15.3",
"electron-vite": "^5.0.0",
"eslint": "^10.5.0",
"prettier": "^3.8.4",
"tailwindcss": "^4.3.1",
"eslint": "^10.7.0",
"prettier": "^3.9.6",
"tailwindcss": "^4.3.3",
"typescript": "^6.0.3",
"vite": "^7.3.5"
"vite": "^8.2.0"
}
}
+805 -747
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -7,6 +7,7 @@ onlyBuiltDependencies:
minimumReleaseAge: 4320
allowBuilds:
electron: true
electron-winstaller: true
esbuild: true
koffi: true
+72 -12
View File
@@ -3,7 +3,7 @@ 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'
import { relay, BlacklistedApp, RelayStatus } from './relay'
declare const __COMMIT_HASH__: string
@@ -13,7 +13,18 @@ function settingsPath(): string {
interface Settings {
disabledMirrors: number[]
blacklistedApps: BlacklistedApp[]
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 {
@@ -22,22 +33,27 @@ function loadSettings(): Settings {
const data = JSON.parse(raw)
return {
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 {
return { disabledMirrors: [], startMinimized: false }
return { disabledMirrors: [], blacklistedApps: [], startMinimized: false, lockedPrimary: null }
}
}
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(
app.getPath('home'),
'.config',
'autostart',
'cafe.kirameki.discord-rpc-relay.desktop'
'cafe.kirameki.discord-presence-relay.desktop'
)
function getLinuxAutostart(): boolean {
@@ -55,7 +71,7 @@ function setLinuxAutostart(enabled: boolean, startMinimized: boolean): boolean {
const desktopEntry = [
'[Desktop Entry]',
'Type=Application',
'Name=Discord RPC Relay',
'Name=Discord Presence Relay',
`Exec=${exec}`,
'Terminal=false',
'X-GNOME-Autostart-enabled=true'
@@ -136,7 +152,7 @@ function createWindow(): void {
function createTray(): void {
const trayIcon = nativeImage.createFromPath(icon).resize({ width: 24, height: 24 })
tray = new Tray(trayIcon)
tray.setToolTip('Discord RPC Relay')
tray.setToolTip('Discord Presence Relay')
updateTrayMenu(relay.getStatus())
@@ -194,7 +210,7 @@ app.on('second-instance', () => {
})
app.whenReady().then(() => {
electronApp.setAppUserModelId('cafe.kirameki.discord-rpc-relay')
electronApp.setAppUserModelId('cafe.kirameki.discord-presence-relay')
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
@@ -202,6 +218,8 @@ app.whenReady().then(() => {
const settings = loadSettings()
relay.setDisabledMirrors(settings.disabledMirrors)
relay.setBlacklistedApps(settings.blacklistedApps)
relay.setLockedPrimary(settings.lockedPrimary)
ipcMain.handle('relay:get-version', () => ({
version: app.getVersion(),
@@ -244,6 +262,24 @@ app.whenReady().then(() => {
saveSettings({ ...current, disabledMirrors: relay.getDisabledMirrors() })
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) => {
updateTrayMenu(status)
@@ -260,16 +296,40 @@ app.whenReady().then(() => {
})
})
app.on('window-all-closed', () => {
// Keep running in the tray
})
app.on('window-all-closed', () => {})
let stopping = false
const QUIT_TIMEOUT_MS = 5000
app.on('before-quit', (e) => {
quitting = true
if (stopping) return
stopping = true
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
}
/** Buffers stream chunks and yields complete IPC frames as they arrive. */
export class FrameReader {
private buf = Buffer.alloc(0)
@@ -39,7 +38,6 @@ export function encodeFrame(op: number, payload: Buffer): Buffer {
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'))
+10 -9
View File
@@ -1,10 +1,7 @@
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.
*/
const READY_TIMEOUT_MS = 10_000
export class MirrorConnection {
private readonly sock: net.Socket
@@ -16,23 +13,28 @@ export class MirrorConnection {
constructor(socketPath: string, handshakePayload: Buffer, onClose: () => void) {
this.sock = net.createConnection(socketPath)
const readyTimeout = setTimeout(() => this.sock.destroy(), READY_TIMEOUT_MS)
readyTimeout.unref()
this.sock.on('connect', () => {
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) => {
const hadFrames = this.reader.push(chunk).length > 0
if (hadFrames && !this.ready) {
this.ready = true
clearTimeout(readyTimeout)
for (const frame of this.pending.splice(0)) this.sock.write(frame)
if (this.closeAfterPending) this.sock.end()
}
})
this.sock.on('error', onClose)
this.sock.on('close', onClose)
this.sock.on('error', () => {})
this.sock.on('close', () => {
clearTimeout(readyTimeout)
onClose()
})
}
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 {
const frame = encodeFrame(OP_FRAME, payload)
if (this.ready) {
+1
View File
@@ -2,6 +2,7 @@ import { PosixPlatform } from './posix'
import { UnsupportedPlatform } from './unsupported'
import type { RelayPlatform } from './types'
export { MAX_SOCKETS } from './types'
export type { ClaimedSocket, ProcessInfo, RelayPlatform } from './types'
export const platform: RelayPlatform =
+213 -30
View File
@@ -1,16 +1,35 @@
import * as fs from 'fs'
import * as net from 'net'
import * as path from 'path'
import { execFileSync } from 'child_process'
import type { ClaimedSocket, ProcessInfo, RelayPlatform } from './types'
import { execFileSync, spawn } from 'child_process'
import { MAX_SOCKETS, type ClaimedSocket, type ProcessInfo, type RelayPlatform } from './types'
const REAL_PREFIX = 'discord-ipc-real-'
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 {
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}`
}
@@ -29,6 +48,10 @@ export class PosixPlatform implements RelayPlatform {
return ipcPath(FAKE_INDEX)
}
ipcSocketPath(index: number): string {
return ipcPath(index)
}
removeFakeSocket(fakePath: string): void {
if (fs.existsSync(fakePath)) fs.unlinkSync(fakePath)
}
@@ -43,65 +66,218 @@ export class PosixPlatform implements RelayPlatform {
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
try {
const leftover = claimedPath(i)
const original = ipcPath(i)
if (!fs.existsSync(leftover)) continue
if (!fs.existsSync(original)) {
fs.renameSync(leftover, original)
continue
}
if (!fs.existsSync(original)) {
fs.renameSync(leftover, original)
continue
}
if (!(await isSocketAlive(original))) {
fs.unlinkSync(original)
fs.renameSync(leftover, original)
if (!(await isSocketAlive(original))) {
fs.unlinkSync(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[] = []
for (let i = 0; i < MAX_SOCKETS; i++) {
const claimed = this.discoverNewSocket(i)
const claimed = await this.discoverNewSocket(i)
if (claimed) found.push(claimed)
}
return found
}
discoverNewSocket(index: number): ClaimedSocket | null {
async discoverNewSocket(index: number): Promise<ClaimedSocket | null> {
const src = ipcPath(index)
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)
fs.renameSync(src, dst)
try {
fs.renameSync(src, dst)
} catch {
return null // socket replaced between the check and the rename
}
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)
try {
const original = ipcPath(claimed.index)
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 {
// /proc/net/unix reports the original bind path even after the file is renamed on disk
// lsof/procfs still report the original bind path even after we rename to -real-N.
return findSocketOwner(ipcPath(index))
}
getPeerProcess(fd: number): ProcessInfo | null {
return findPeerProcess(fd)
}
killProcess(pid: number): boolean {
try {
process.kill(pid, 'SIGTERM')
return true
} catch {
return false
}
}
isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0)
return true
} catch (err) {
// EPERM means it's alive, we just aren't allowed to touch it.
return (err as NodeJS.ErrnoException).code === 'EPERM'
}
}
launchExecutable(executable: string): boolean {
try {
// Point Discord at the temp dir we watch, whatever TMPDIR we inherited.
const env = { ...process.env }
if (process.platform === 'darwin') {
// Spawning the .app binary directly just dies, so let `open` launch it.
const canonical = resolveDarwinTempDir()
if (canonical) env.TMPDIR = canonical
const appPath = executable.includes('.app/')
? executable.slice(0, executable.indexOf('.app/') + '.app'.length)
: executable
const child = spawn('open', ['-a', appPath], { detached: true, stdio: 'ignore', env })
child.unref()
return true
}
const child = spawn(executable, [], { detached: true, stdio: 'ignore', env })
child.unref()
return true
} catch {
return false
}
}
}
// The pid holding the socket is usually a helper; walk up to the real app process.
function resolveMainAppProcess(pid: number): ProcessInfo | null {
if (process.platform !== 'darwin') return null
const exe = executablePath(pid)
const bundle = bundleRoot(exe)
if (!bundle) return null
let mainPid = pid
let mainExe = exe
for (let depth = 0; depth < 20; depth++) {
const ppid = parentPid(mainPid)
if (ppid === null || ppid <= 1) break
const parentExe = executablePath(ppid)
if (bundleRoot(parentExe) !== bundle) break
mainPid = ppid
mainExe = parentExe
}
return { pid: mainPid, name: path.basename(mainExe ?? String(mainPid)), executable: mainExe }
}
function bundleRoot(exe: string | null): string | null {
if (!exe) return null
const idx = exe.indexOf('.app/Contents/')
return idx >= 0 ? exe.slice(0, idx + '.app'.length) : null
}
function parentPid(pid: number): number | null {
try {
const out = execFileSync('ps', ['-p', String(pid), '-o', 'ppid='], {
encoding: 'utf8',
timeout: 1000
}).trim()
const ppid = parseInt(out, 10)
return Number.isNaN(ppid) ? null : ppid
} catch {
return null
}
}
function executablePath(pid: number): string | null {
try {
if (process.platform === 'linux') {
return fs.readlinkSync(`/proc/${pid}/exe`)
}
if (process.platform === 'darwin') {
const out = execFileSync('ps', ['-p', String(pid), '-o', 'comm='], {
encoding: 'utf8',
timeout: 1000
}).trim()
return out || null
}
} catch {
return null
}
return null
}
function isSocketAlive(socketPath: string): Promise<boolean> {
return new Promise((resolve) => {
const sock = net.createConnection(socketPath)
sock.once('connect', () => {
let settled = false
const done = (alive: boolean): void => {
if (settled) return
settled = true
sock.destroy()
resolve(true)
})
sock.once('error', () => resolve(false))
resolve(alive)
}
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)
} else if (line.startsWith('n') && line.slice(1) === socketPath) {
if (currentPid > 0 && currentPid !== process.pid) {
return { pid: currentPid, name: currentName }
return (
resolveMainAppProcess(currentPid) ?? {
pid: currentPid,
name: currentName,
executable: executablePath(currentPid)
}
)
}
}
}
@@ -197,7 +379,8 @@ function findProcessForInodes(inodes: Set<string>): ProcessInfo | null {
}
const match = /^socket:\[(\d+)\]$/.exec(link)
if (match && inodes.has(match[1])) {
return { pid: parseInt(pidStr, 10), name: processName(pidStr) }
const pid = parseInt(pidStr, 10)
return { pid, name: processName(pidStr), executable: executablePath(pid) }
}
}
}
@@ -260,7 +443,7 @@ function findPeerProcess(localFd: number): ProcessInfo | null {
if (fn(localFd, SOL_LOCAL, LOCAL_PEERPID, optval, optlen) !== 0) return null
const pid = optval.readInt32LE(0)
if (pid <= 0) return null
return { pid, name: macProcessName(pid) }
return { pid, name: macProcessName(pid), executable: executablePath(pid) }
}
const optval = Buffer.alloc(UCRED_SIZE)
@@ -272,7 +455,7 @@ function findPeerProcess(localFd: number): ProcessInfo | null {
const pid = optval.readInt32LE(0)
if (pid <= 0) return null
return { pid, name: processName(String(pid)) }
return { pid, name: processName(String(pid)), executable: executablePath(pid) }
}
function macProcessName(pid: number): string {
+20 -14
View File
@@ -1,6 +1,9 @@
export const MAX_SOCKETS = 10
export interface ProcessInfo {
pid: number
name: string
executable: string | null
}
export interface ClaimedSocket {
@@ -8,38 +11,41 @@ export interface ClaimedSocket {
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
ipcSocketPath(index: number): 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). */
/** Checks whether Discord has unlinked our fake socket. */
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[]
discoverAndClaim(): Promise<ClaimedSocket[]>
discoverNewSocket(index: number): ClaimedSocket | null
discoverNewSocket(index: number): Promise<ClaimedSocket | null>
restoreSocket(claimed: ClaimedSocket): void
discardClaimedSocket(claimed: ClaimedSocket): void
isSocketAlive(socketPath: string): Promise<boolean>
removeStaleIpcSocket(index: number): Promise<void>
getInstanceProcess(index: number): ProcessInfo | null
getPeerProcess(fd: number): ProcessInfo | null
killProcess(pid: number): boolean
isProcessAlive(pid: number): boolean
launchExecutable(executable: string): boolean
}
+30 -5
View File
@@ -2,9 +2,6 @@ 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
@@ -12,6 +9,10 @@ export class UnsupportedPlatform implements RelayPlatform {
throw NOT_SUPPORTED
}
ipcSocketPath(): string {
throw NOT_SUPPORTED
}
removeFakeSocket(): void {
throw NOT_SUPPORTED
}
@@ -28,11 +29,11 @@ export class UnsupportedPlatform implements RelayPlatform {
throw NOT_SUPPORTED
}
discoverAndClaim(): ClaimedSocket[] {
async discoverAndClaim(): Promise<ClaimedSocket[]> {
throw NOT_SUPPORTED
}
discoverNewSocket(): ClaimedSocket | null {
async discoverNewSocket(): Promise<ClaimedSocket | null> {
throw NOT_SUPPORTED
}
@@ -40,6 +41,18 @@ export class UnsupportedPlatform implements RelayPlatform {
throw NOT_SUPPORTED
}
discardClaimedSocket(): void {
throw NOT_SUPPORTED
}
async isSocketAlive(): Promise<boolean> {
return false
}
async removeStaleIpcSocket(): Promise<void> {
throw NOT_SUPPORTED
}
getInstanceProcess(): ProcessInfo | null {
return null
}
@@ -47,4 +60,16 @@ export class UnsupportedPlatform implements RelayPlatform {
getPeerProcess(): ProcessInfo | null {
return null
}
killProcess(): boolean {
return false
}
isProcessAlive(): boolean {
return false
}
launchExecutable(): boolean {
return false
}
}
+520 -145
View File
@@ -3,11 +3,18 @@ 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'
import { platform, MAX_SOCKETS, type ClaimedSocket, type ProcessInfo } from './platform'
const MAX_SOCKETS = 10
const DISCOVERY_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 {
id: string
@@ -15,27 +22,46 @@ interface AppAsset {
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>> {
const cached = appAssetCache.get(appId)
if (cached) return cached
if (cached && Date.now() < cached.expires) return cached.map
const map = new Map<string, string>()
let expires = Number.POSITIVE_INFINITY
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)
} else {
expires = Date.now() + ASSET_FETCH_RETRY_MS
}
} 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
}
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(
key: string | undefined,
appId: string | null
@@ -65,12 +91,21 @@ export interface RelayInstance {
path: string
isPrimary: boolean
enabled: boolean
locked: boolean
process: ProcessInfo | null
}
export interface BlacklistedApp {
id: string
name: string | null
}
export interface ConnectedClient {
id: number
process: ProcessInfo | null
appId: string | null
blacklisted: boolean
activity: LastActivity | null
}
export interface ActivityAssets {
@@ -106,51 +141,94 @@ export interface RelayStatus {
unsupported: boolean
instances: RelayInstance[]
connectedClients: ConnectedClient[]
lastActivity: LastActivity | null
blacklistedApps: BlacklistedApp[]
lockedPrimary: string | null
reordering: boolean
primaryOutOfOrder: boolean
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.
*/
interface ClientSession {
id: number
client: net.Socket
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 {
private server: net.Server | null = null
private claimed: ClaimedSocket[] = []
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 discoveryTimer: NodeJS.Timeout | null = null
private discovering = false
private waitingTimer: NodeJS.Timeout | null = null
private lastActivity: LastActivity | null = null
private running = false
private waiting = false
private fakeOwned = false
private lastError: string | null = null
private restarting = false
private mirrors = new Map<number, MirrorConnection>()
private lastActivityPid = new Map<number, number>()
private lockedPrimary: string | null = null
private reordering = 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)
}))
const instances: RelayInstance[] = this.claimed.map(({ index, path: socketPath }, i) => {
const process = platform.getInstanceProcess(index)
return {
index,
path: socketPath,
isPrimary: i === 0,
enabled: i === 0 || !this.disabledMirrors.has(index),
locked: this.isLockedInstance(process),
process
}
})
return {
running: this.running,
waiting: this.waiting,
unsupported: !platform.isSupported,
instances,
connectedClients: [...this.connectedClients.values()],
lastActivity: this.lastActivity,
connectedClients: [...this.sessions.values()].map((s) => ({
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
}
}
private isLockedInstance(process: ProcessInfo | null): boolean {
return (
this.lockedPrimary !== null &&
process?.executable != null &&
process.executable === this.lockedPrimary
)
}
private primaryOutOfOrder(instances: RelayInstance[]): boolean {
if (this.lockedPrimary === null) return false
const locked = instances.find((inst) => inst.locked)
return locked !== undefined && !locked.isPrimary
}
setMirrorEnabled(index: number, enabled: boolean): RelayStatus {
if (index === this.primaryIndex()) return this.getStatus()
@@ -172,6 +250,87 @@ export class RpcRelay extends EventEmitter {
this.disabledMirrors = new Set(indices)
}
getLockedPrimary(): string | null {
return this.lockedPrimary
}
setLockedPrimary(executable: string | null): void {
this.lockedPrimary = executable
this.emitStatus()
}
unlockPrimary(): RelayStatus {
this.lockedPrimary = null
this.emitStatus()
return this.getStatus()
}
async promoteToPrimary(index: number): Promise<RelayStatus> {
const process = platform.getInstanceProcess(index)
if (!process?.executable) {
this.lastError = 'Cannot resolve this Discords executable path'
this.emitStatus()
return this.getStatus()
}
this.lockedPrimary = process.executable
this.emitStatus()
return this.reorderForPrimary()
}
getBlacklistedApps(): BlacklistedApp[] {
return [...this.blacklistedApps].map(([id, name]) => ({ id, name }))
}
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> {
if (this.running) return
this.lastError = null
@@ -183,34 +342,46 @@ export class RpcRelay extends EventEmitter {
}
await platform.recoverLeftoverSockets()
this.claimed = platform.discoverAndClaim()
const claimed = await platform.discoverAndClaim()
if (this.claimed.length === 0) {
if (claimed.length === 0) {
this.waitForDiscord()
return
}
this.claimed = claimed
this.waiting = false
this.stopWaitingTimer()
const fake = platform.fakeSocketPath()
platform.removeFakeSocket(fake)
try {
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()
this.server = net.createServer((sock) => this.handleClient(sock))
this.server.on('error', (err) => {
this.lastError = err.message
this.emitStatus()
})
})
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.startDiscoveryTimer()
@@ -228,23 +399,41 @@ export class RpcRelay extends EventEmitter {
this.running = false
this.stopDiscoveryTimer()
for (const session of [...this.sessions.values()]) this.destroySession(session)
if (this.server) {
await new Promise<void>((resolve) => this.server!.close(() => resolve()))
const server = this.server
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())
this.fakeOwned = false
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.connectedClients.clear()
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 {
return this.claimed[0]?.index
}
@@ -273,7 +462,7 @@ export class RpcRelay extends EventEmitter {
private startDiscoveryTimer(): void {
this.stopDiscoveryTimer()
this.discoveryTimer = setInterval(() => this.discoverNewInstances(), DISCOVERY_INTERVAL_MS)
this.discoveryTimer = setInterval(() => void this.discoverInstances(), DISCOVERY_INTERVAL_MS)
}
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. */
private discoverNewInstances(): void {
if (!this.running) return
/** Periodic: recover stolen primary, prune dead instances, discover new ones. */
private async discoverInstances(): Promise<void> {
if (!this.running || this.discovering) return
this.discovering = true
try {
if (!platform.fakeSocketExists(platform.fakeSocketPath())) {
void this.restart()
return
}
if (!platform.fakeSocketExists(platform.fakeSocketPath())) {
void this.restart()
return
}
let changed = false
const claimedIndices = new Set(this.claimed.map((c) => c.index))
let changed = false
for (const claimed of [...this.claimed]) {
if (await platform.isSocketAlive(claimed.path)) continue
if (!this.running) return
for (let i = 1; i < MAX_SOCKETS; i++) {
if (claimedIndices.has(i)) continue
const found = platform.discoverNewSocket(i)
if (found) {
this.claimed.push(found)
if (claimed === this.claimed[0]) {
void this.restart()
return
}
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
}
}
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 {
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
this.connectedClients.set(clientId, {
id: clientId,
process: fd !== undefined ? platform.getPeerProcess(fd) : null
})
const session: ClientSession = {
id: this.nextClientId++,
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()
const primaryPath = this.claimed[0].path
const primary = net.createConnection(primaryPath)
const { primary } = session
const clientReader = 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
const pendingToPrimary: Buffer[] = []
client.on('data', (chunk: Buffer) => {
for (const frame of clientReader.push(chunk)) {
if (frame.op === OP_HANDSHAKE) {
handshakePayload = frame.payload
clientAppId = (parseFramePayload(frame)?.client_id as string) ?? null
session.handshakePayload = frame.payload
session.appId = (parseFramePayload(frame)?.client_id as string) ?? null
}
const encoded = encodeFrame(frame.op, frame.payload)
@@ -352,8 +590,8 @@ export class RpcRelay extends EventEmitter {
pendingToPrimary.push(encoded)
}
if (handshakePayload) this.mirrorFrame(frame, handshakePayload)
void this.recordActivity(frame, clientAppId)
if (session.handshakePayload) this.mirrorFrame(session, frame)
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('close', cleanup)
primary.on('close', cleanup)
primary.on('error', (err) => {
cleanup()
if ((err as NodeJS.ErrnoException).code === 'ECONNREFUSED') {
const code = (err as NodeJS.ErrnoException).code
if (code === 'ECONNREFUSED' || code === 'ENOENT') {
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 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> {
if (this.restarting) return
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 mirrorFrame(frame: Frame, handshakePayload: Buffer): void {
private async reorderForPrimary(): Promise<RelayStatus> {
if (this.reordering) return this.getStatus()
if (this.lockedPrimary === null) return this.getStatus()
const lockedSlot = this.claimed.findIndex(
(c) => platform.getInstanceProcess(c.index)?.executable === this.lockedPrimary
)
if (lockedSlot < 0) {
this.lastError = 'Locked Discord is not currently running'
this.emitStatus()
return this.getStatus()
}
if (lockedSlot === 0) return this.getStatus()
this.reordering = true
this.lastError = null
this.emitStatus()
try {
// Grab what we need now before stop() wipes claimed below.
const others: string[] = []
const toKill: number[] = []
const freeIndices: number[] = []
for (let i = 0; i <= lockedSlot; i++) {
freeIndices.push(this.claimed[i].index)
const proc = platform.getInstanceProcess(this.claimed[i].index)
if (!proc) continue
toKill.push(proc.pid)
if (proc.executable && proc.executable !== this.lockedPrimary) {
others.push(proc.executable)
}
}
const lockedExecutable = this.lockedPrimary
await this.stop()
for (const pid of toKill) platform.killProcess(pid)
await this.waitForProcessesExit(toKill)
for (const index of freeIndices) {
await platform.removeStaleIpcSocket(index)
}
if (!platform.launchExecutable(lockedExecutable)) {
throw new Error('Failed to relaunch the locked Discord')
}
await this.waitForSlotBound(0)
for (const executable of others) platform.launchExecutable(executable)
} catch (err) {
this.lastError = err instanceof Error ? err.message : String(err)
} finally {
this.reordering = false
await this.start().catch((err) => {
this.lastError = err instanceof Error ? err.message : String(err)
})
this.emitStatus()
}
return this.getStatus()
}
private async waitForProcessesExit(pids: number[]): Promise<void> {
const deadline = Date.now() + PROCESS_EXIT_TIMEOUT_MS
while (Date.now() < deadline) {
if (pids.every((pid) => !platform.isProcessAlive(pid))) return
await delay(PROCESS_EXIT_POLL_MS)
}
}
private async waitForSlotBound(index: number): Promise<void> {
const deadline = Date.now() + SLOT_BIND_TIMEOUT_MS
while (Date.now() < deadline) {
if (await platform.isSocketAlive(platform.ipcSocketPath(index))) return
await delay(SLOT_BIND_POLL_MS)
}
throw new Error('Timed out waiting for the locked Discord to start')
}
private mirrorFrame(session: ClientSession, frame: Frame): void {
const payload = frame.op !== OP_HANDSHAKE ? parseFramePayload(frame) : null
const isSetActivity = payload?.cmd === 'SET_ACTIVITY'
if (frame.op !== OP_HANDSHAKE && !isSetActivity) return
if (isSetActivity) {
session.lastActivityPayload = frame.payload
const pid = (payload!.args as { pid?: number } | undefined)?.pid
if (typeof pid === 'number') {
for (let i = 1; i < this.claimed.length; i++) {
this.lastActivityPid.set(this.claimed[i].index, pid)
}
}
if (typeof pid === 'number') session.lastActivityPid = pid
}
if (this.isBlacklisted(session)) return
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)) {
this.mirrors.get(index)?.destroy()
this.mirrors.delete(index)
this.dropSessionMirror(session, index)
continue
}
let mirror = this.mirrors.get(index)
if (!mirror) {
const newMirror: MirrorConnection = new MirrorConnection(
mirrorPath,
handshakePayload,
() => {
if (this.mirrors.get(index) === newMirror) this.mirrors.delete(index)
}
)
mirror = newMirror
this.mirrors.set(index, mirror)
if (frame.op === OP_HANDSHAKE) continue // handshake already sent on connect
this.ensureMirror(session, this.claimed[i])
if (frame.op !== OP_HANDSHAKE) session.mirrors.get(index)?.sendActivity(frame.payload)
}
}
private ensureMirror(session: ClientSession, target: ClaimedSocket): boolean {
if (session.closed || !session.handshakePayload) return false
if (session.mirrors.has(target.index)) return false
const mirror: MirrorConnection = new MirrorConnection(
target.path,
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 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> {
private async recordActivity(session: ClientSession, frame: Frame): 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 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 assets: ActivityAssets | null = rawAssets
@@ -494,14 +865,18 @@ export class RpcRelay extends EventEmitter {
? 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,
details: (activity.details as string) ?? null,
state: (activity.state as string) ?? null,
assets,
timestamps,
buttons,
at: Date.now()
at
}
this.emitStatus()
}
+5
View File
@@ -16,6 +16,11 @@ const api = {
ipcRenderer.invoke('relay:set-start-minimized', enabled),
setMirrorEnabled: (index: number, enabled: boolean): Promise<RelayStatus> =>
ipcRenderer.invoke('relay:set-mirror-enabled', index, enabled),
setAppBlacklisted: (appId: string, blacklisted: boolean): Promise<RelayStatus> =>
ipcRenderer.invoke('relay:set-app-blacklisted', appId, blacklisted),
unlockPrimary: (): Promise<RelayStatus> => ipcRenderer.invoke('relay:unlock-primary'),
promoteToPrimary: (index: number): Promise<RelayStatus> =>
ipcRenderer.invoke('relay:promote-to-primary', index),
onStatus: (callback: (status: RelayStatus) => void): (() => void) => {
const listener = (_e: unknown, status: RelayStatus): void => callback(status)
ipcRenderer.on('relay:status', listener)
+203 -34
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { ChevronLeft, Info, Settings } from 'lucide-react'
import type { RelayStatus } from '../../main/relay'
import { useEffect, useRef, useState } from 'react'
import { ChevronLeft, ChevronRight, Eye, EyeOff, Info, Settings } from 'lucide-react'
import type { ConnectedClient, LastActivity, RelayStatus } from '../../main/relay'
import { InstanceRow } from './components/InstanceRow'
import { Toggle } from './components/Toggle'
import { Button, LinkButton } from './components/Button'
@@ -11,7 +11,10 @@ const EMPTY_STATUS: RelayStatus = {
unsupported: false,
instances: [],
connectedClients: [],
lastActivity: null,
blacklistedApps: [],
lockedPrimary: null,
reordering: false,
primaryOutOfOrder: false,
error: null
}
@@ -38,13 +41,7 @@ function formatDuration(ms: number): string {
return `${minutes}:${String(seconds).padStart(2, '0')}`
}
function ActivityPreview({
activity
}: {
activity: RelayStatus['lastActivity']
}): React.JSX.Element | null {
if (!activity) return null
function ActivityPreview({ activity }: { activity: LastActivity }): React.JSX.Element | null {
const { app, details, state, assets, timestamps, buttons, at } = activity
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">
{app ? app.slice(0, 2).toUpperCase() : ''}
{app ? app.slice(0, 2).toUpperCase() : '-'}
</div>
)}
{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 {
const [status, setStatus] = useState<RelayStatus>(EMPTY_STATUS)
const [loading, setLoading] = useState(false)
@@ -137,6 +265,18 @@ export function App(): React.JSX.Element {
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> => {
setAutostart(await window.api.setAutostart(enabled))
}
@@ -148,7 +288,7 @@ export function App(): React.JSX.Element {
if (status.unsupported) {
return (
<div className="flex flex-col h-screen items-center justify-center p-5 gap-3 text-center">
<h1 className="text-lg font-semibold">Discord RPC Relay</h1>
<h1 className="text-lg font-semibold">Discord Presence Relay</h1>
<p className="text-sm text-zinc-400">Windows is not supported.</p>
</div>
)
@@ -196,6 +336,33 @@ export function App(): React.JSX.Element {
</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">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 && (
<p className="text-xs text-zinc-500 mt-auto">
Version {appVersion.version} ({appVersion.commit})
@@ -208,7 +375,7 @@ export function App(): React.JSX.Element {
return (
<div className="flex flex-col h-screen p-5 gap-4">
<header className="flex items-center justify-between gap-3">
<h1 className="text-lg font-semibold">Discord RPC Relay</h1>
<h1 className="text-lg font-semibold">Discord Presence Relay</h1>
<Button
variant="ghost"
onClick={() => setView('settings')}
@@ -255,43 +422,45 @@ export function App(): React.JSX.Element {
<InstanceRow
key={instance.index}
instance={instance}
reordering={status.reordering}
onToggleMirror={onToggleMirror}
onUnlock={onUnlockPrimary}
onPromote={onPromote}
/>
))
) : (
<div className="text-zinc-500">No Discord instances detected</div>
)}
</div>
{status.reordering && (
<div className="mt-1 text-xs text-amber-300/90">
Restarting Discord clients to promote the new primary
</div>
)}
</section>
<section className="rounded-xl bg-zinc-800/60 border border-zinc-700 p-4 flex flex-col gap-2">
<h2 className="text-sm text-zinc-400 mb-1">Connected RPC Clients</h2>
<div className="flex flex-col gap-1.5 text-sm">
<div className="flex items-center justify-between mb-1">
<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.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>
))
<ClientCarousel
clients={status.connectedClients}
onToggleBlacklist={onToggleBlacklist}
/>
) : (
<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.
+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 { Toggle } from './Toggle'
import { Button } from './Button'
function shortPath(p: string): string {
return p.split('/').pop() ?? p
@@ -8,14 +9,71 @@ function shortPath(p: string): string {
interface Props {
instance: RelayInstance
reordering: boolean
onToggleMirror: (index: number, enabled: boolean) => Promise<void>
onUnlock: () => Promise<void>
onPromote: (index: number) => Promise<void>
}
export function InstanceRow({ instance, onToggleMirror }: Props): React.JSX.Element {
export function InstanceRow({
instance,
reordering,
onToggleMirror,
onUnlock,
onPromote
}: Props): React.JSX.Element {
const procLabel = instance.process
? `${instance.process.name} (pid ${instance.process.pid})`
: 'unknown process'
const canPromote = instance.process?.executable != null
const promoteButton = (
<Button
variant="ghost"
className="p-1 shrink-0"
disabled={!canPromote || reordering}
onClick={() => onPromote(instance.index)}
aria-label="Promote to primary"
title={
!canPromote
? 'Cannot resolve this Discords executable path'
: 'Make this Discord primary: restarts Discord clients so it starts first.'
}
>
<ArrowUpToLine className="w-4 h-4 text-emerald-400" />
</Button>
)
const unlockButton = (
<Button
variant="ghost"
className="p-1 shrink-0"
onClick={() => onUnlock()}
aria-label="Unlock primary"
title="Locked as primary. Click to unlock."
>
<Lock className="w-4 h-4 text-amber-400" />
</Button>
)
const lockButton = (
<Button
variant="ghost"
className="p-1 shrink-0"
disabled={!canPromote || reordering}
onClick={() => onPromote(instance.index)}
aria-label="Lock as primary"
title={
!canPromote
? 'Cannot resolve this Discords executable path'
: 'Lock this Discord as primary so it stays first even after restarts.'
}
>
<LockOpen className="w-4 h-4 text-zinc-400 hover:text-zinc-100 transition-colors" />
</Button>
)
const info = (
<div className="flex flex-col">
<div className="flex items-center gap-2">
@@ -29,6 +87,7 @@ export function InstanceRow({ instance, onToggleMirror }: Props): React.JSX.Elem
mirror #{instance.index}
</span>
)}
{instance.locked && <span className="text-xs text-amber-400">locked</span>}
</div>
<span className="text-xs text-zinc-500">{procLabel}</span>
</div>
@@ -38,14 +97,9 @@ export function InstanceRow({ instance, onToggleMirror }: Props): React.JSX.Elem
return (
<div className="flex items-center justify-between gap-2">
{info}
<Info className="w-4 h-4 text-zinc-400 hover:text-zinc-100 cursor-help transition-colors">
<title>
The primary instance gets full passthrough and can&apos;t be turned off. It&apos;s
always the Discord instance that started first (discord-ipc-0). To make a different
instance primary, close this one first so the other claims that slot, then restart the
relay.
</title>
</Info>
<div className="flex items-center gap-1 shrink-0">
{instance.locked ? unlockButton : lockButton}
</div>
</div>
)
}
@@ -53,10 +107,13 @@ export function InstanceRow({ instance, onToggleMirror }: Props): React.JSX.Elem
return (
<div className="flex items-center justify-between gap-2">
{info}
<Toggle
checked={instance.enabled}
onChange={(checked) => onToggleMirror(instance.index, checked)}
/>
<div className="flex items-center gap-1 shrink-0">
{promoteButton}
<Toggle
checked={instance.enabled}
onChange={(checked) => onToggleMirror(instance.index, checked)}
/>
</div>
</div>
)
}