mirror of
https://github.com/YuzuZensai/Discord-Presence-Relay.git
synced 2026-09-13 10:49:02 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
181a8f4325
|
||
|
|
d046257205
|
||
|
|
587fddb01d
|
||
|
|
6f9221698a
|
||
|
|
a961132e2c
|
||
|
|
fcf1e5e4c3 | ||
|
|
ba31771a0e | ||
|
|
55dfb9ad67 | ||
|
|
0ba8041a5a | ||
|
|
209da80f69 | ||
|
|
7ea3354bf6
|
||
|
|
4674142f52 | ||
|
|
998e7d6fa8 |
@@ -0,0 +1 @@
|
|||||||
|
github: [YuzuZensai]
|
||||||
@@ -5,9 +5,12 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- '[0-9]*.[0-9]*.[0-9]*'
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check:
|
check:
|
||||||
strategy:
|
strategy:
|
||||||
@@ -99,7 +102,7 @@ jobs:
|
|||||||
|
|
||||||
release:
|
release:
|
||||||
needs: build
|
needs: build
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
# Discord RPC Relay
|
# 🪩 Discord-Presence-Relay
|
||||||
|
|
||||||

|
Mirrors Discord Rich Presence activity to every running Discord
|
||||||
|
|
||||||
Mirrors Discord Rich Presence (RPC) activity to every running Discord
|
|
||||||
instance (stable, PTB, Canary, ...), so apps that only connect to the
|
instance (stable, PTB, Canary, ...), so apps that only connect to the
|
||||||
primary `discord-ipc-0` socket show their presence everywhere.
|
primary `discord-ipc-0` socket show their presence everywhere.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
It works by taking over `discord-ipc-0`, passing all traffic through to the
|
It works by taking over `discord-ipc-0`, passing all traffic through to the
|
||||||
real primary instance unchanged, and forwarding the handshake and
|
real primary instance unchanged, and forwarding the handshake and
|
||||||
`SET_ACTIVITY` frames to the other detected instances.
|
`SET_ACTIVITY` frames to the other detected instances.
|
||||||
@@ -15,16 +15,16 @@ Linux and macOS only.
|
|||||||
## Install
|
## Install
|
||||||
|
|
||||||
Download the latest build for your platform from the
|
Download the latest build for your platform from the
|
||||||
[Releases page](https://github.com/YuzuZensai/Discord-RPC-Relay/releases).
|
[Releases page](https://github.com/YuzuZensai/Discord-Presence-Relay/releases).
|
||||||
|
|
||||||
### Linux (AppImage)
|
### Linux (AppImage)
|
||||||
|
|
||||||
1. Download `discord-rpc-relay-<version>.AppImage`.
|
1. Download `discord-presence-relay-<version>.AppImage`.
|
||||||
2. Make it executable and run it:
|
2. Make it executable and run it:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
chmod +x discord-rpc-relay-*.AppImage
|
chmod +x discord-presence-relay-*.AppImage
|
||||||
./discord-rpc-relay-*.AppImage
|
./discord-presence-relay-*.AppImage
|
||||||
```
|
```
|
||||||
|
|
||||||
The app runs from the tray. To launch it automatically and have it appear in
|
The app runs from the tray. To launch it automatically and have it appear in
|
||||||
@@ -34,24 +34,24 @@ settings once it's running.
|
|||||||
|
|
||||||
### Linux (.deb)
|
### Linux (.deb)
|
||||||
|
|
||||||
1. Download `discord-rpc-relay-<version>.deb`.
|
1. Download `discord-presence-relay-<version>.deb`.
|
||||||
2. Install it:
|
2. Install it:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo apt install ./discord-rpc-relay-*.deb
|
sudo apt install ./discord-presence-relay-*.deb
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Launch it from your application menu ("Discord RPC Relay"), or from a
|
3. Launch it from your application menu ("Discord Presence Relay"), or from a
|
||||||
terminal:
|
terminal:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
discord-rpc-relay
|
discord-presence-relay
|
||||||
```
|
```
|
||||||
|
|
||||||
### macOS
|
### macOS
|
||||||
|
|
||||||
1. Download `discord-rpc-relay-<version>.dmg` and open it.
|
1. Download `discord-presence-relay-<version>.dmg` and open it.
|
||||||
2. Drag **Discord RPC Relay** into the **Applications** folder.
|
2. Drag **Discord Presence Relay** into the **Applications** folder.
|
||||||
3. Clear the Gatekeeper quarantine attribute before first launch (see below),
|
3. Clear the Gatekeeper quarantine attribute before first launch (see below),
|
||||||
then open the app from Applications/Launchpad/Spotlight as usual.
|
then open the app from Applications/Launchpad/Spotlight as usual.
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ developer"). After installing, clear the quarantine attribute from the
|
|||||||
Terminal:
|
Terminal:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
xattr -cr /Applications/Discord\ RPC\ Relay.app
|
xattr -cr /Applications/Discord\ Presence\ Relay.app
|
||||||
```
|
```
|
||||||
|
|
||||||
Then open the app normally.
|
Then open the app normally.
|
||||||
|
|||||||
+3
-3
@@ -15,8 +15,8 @@ process.env.BUILD_VERSION = getBuildVersion()
|
|||||||
* @type {import('electron-builder').Configuration}
|
* @type {import('electron-builder').Configuration}
|
||||||
*/
|
*/
|
||||||
export default {
|
export default {
|
||||||
appId: 'cafe.kirameki.discord-rpc-relay',
|
appId: 'cafe.kirameki.discord-presence-relay',
|
||||||
productName: 'Discord RPC Relay',
|
productName: 'Discord Presence Relay',
|
||||||
files: [
|
files: [
|
||||||
'!**/.vscode/*',
|
'!**/.vscode/*',
|
||||||
'!src/*',
|
'!src/*',
|
||||||
@@ -28,7 +28,7 @@ export default {
|
|||||||
],
|
],
|
||||||
asarUnpack: ['resources/**'],
|
asarUnpack: ['resources/**'],
|
||||||
win: {
|
win: {
|
||||||
executableName: 'discord-rpc-relay'
|
executableName: 'discord-presence-relay'
|
||||||
},
|
},
|
||||||
nsis: {
|
nsis: {
|
||||||
artifactName: '${name}-${env.BUILD_VERSION}-setup.${ext}',
|
artifactName: '${name}-${env.BUILD_VERSION}-setup.${ext}',
|
||||||
|
|||||||
+15
-15
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "discord-rpc-relay",
|
"name": "discord-presence-relay",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Mirrors Discord Rich Presence (RPC) to multiple running Discord instances",
|
"description": "Mirrors Discord Rich Presence to multiple running Discord instances",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"packageManager": "pnpm@11.7.0+sha512.19cc852c120c7125760f2443ee6be0ca5b40f9f50598de1a09a1f177503e010e57c23c77646e01e761de59bf874fb22a3398c33ab9691fc13eb946b6f0f4d620",
|
"packageManager": "pnpm@11.17.0+sha512.cca3cea332ad254bb84145f966d19f4879615210346fc92c79a047f23a0d7b3cca3c3792f0076ba1f1831d277efbcf0a9119b31a9a60eca7fb3d6231f331ef72",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -25,27 +25,27 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@electron-toolkit/preload": "^3.0.2",
|
"@electron-toolkit/preload": "^3.0.2",
|
||||||
"@electron-toolkit/utils": "^4.0.0",
|
"@electron-toolkit/utils": "^4.0.0",
|
||||||
"koffi": "^3.0.2",
|
"koffi": "^3.1.2",
|
||||||
"lucide-react": "^1.18.0",
|
"lucide-react": "^1.25.0",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.7"
|
"react-dom": "^19.2.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
|
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
|
||||||
"@electron-toolkit/eslint-config-ts": "^3.1.0",
|
"@electron-toolkit/eslint-config-ts": "^3.1.0",
|
||||||
"@electron-toolkit/tsconfig": "^2.0.0",
|
"@electron-toolkit/tsconfig": "^2.0.0",
|
||||||
"@tailwindcss/vite": "^4.3.1",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"@types/node": "^25.9.3",
|
"@types/node": "^26.1.1",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.2.0",
|
"@vitejs/plugin-react": "^6.0.3",
|
||||||
"electron": "^42.4.0",
|
"electron": "^43.2.0",
|
||||||
"electron-builder": "^26.15.3",
|
"electron-builder": "^26.15.3",
|
||||||
"electron-vite": "^5.0.0",
|
"electron-vite": "^5.0.0",
|
||||||
"eslint": "^10.5.0",
|
"eslint": "^10.7.0",
|
||||||
"prettier": "^3.8.4",
|
"prettier": "^3.9.6",
|
||||||
"tailwindcss": "^4.3.1",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"vite": "^7.3.5"
|
"vite": "^8.1.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+713
-657
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ onlyBuiltDependencies:
|
|||||||
minimumReleaseAge: 4320
|
minimumReleaseAge: 4320
|
||||||
|
|
||||||
allowBuilds:
|
allowBuilds:
|
||||||
|
electron: true
|
||||||
electron-winstaller: true
|
electron-winstaller: true
|
||||||
esbuild: true
|
esbuild: true
|
||||||
koffi: true
|
koffi: true
|
||||||
|
|||||||
+72
-12
@@ -3,7 +3,7 @@ import { join } from 'path'
|
|||||||
import * as fs from 'fs'
|
import * as fs from 'fs'
|
||||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||||
import icon from '../../resources/icon.png?asset'
|
import icon from '../../resources/icon.png?asset'
|
||||||
import { relay, RelayStatus } from './relay'
|
import { relay, BlacklistedApp, RelayStatus } from './relay'
|
||||||
|
|
||||||
declare const __COMMIT_HASH__: string
|
declare const __COMMIT_HASH__: string
|
||||||
|
|
||||||
@@ -13,7 +13,18 @@ function settingsPath(): string {
|
|||||||
|
|
||||||
interface Settings {
|
interface Settings {
|
||||||
disabledMirrors: number[]
|
disabledMirrors: number[]
|
||||||
|
blacklistedApps: BlacklistedApp[]
|
||||||
startMinimized: boolean
|
startMinimized: boolean
|
||||||
|
lockedPrimary: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBlacklistedApps(raw: unknown): BlacklistedApp[] {
|
||||||
|
if (!Array.isArray(raw)) return []
|
||||||
|
return raw.flatMap((entry): BlacklistedApp[] =>
|
||||||
|
entry && typeof entry.id === 'string'
|
||||||
|
? [{ id: entry.id, name: typeof entry.name === 'string' ? entry.name : null }]
|
||||||
|
: []
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadSettings(): Settings {
|
function loadSettings(): Settings {
|
||||||
@@ -22,22 +33,27 @@ function loadSettings(): Settings {
|
|||||||
const data = JSON.parse(raw)
|
const data = JSON.parse(raw)
|
||||||
return {
|
return {
|
||||||
disabledMirrors: Array.isArray(data?.disabledMirrors) ? data.disabledMirrors : [],
|
disabledMirrors: Array.isArray(data?.disabledMirrors) ? data.disabledMirrors : [],
|
||||||
startMinimized: data?.startMinimized === true
|
blacklistedApps: parseBlacklistedApps(data?.blacklistedApps),
|
||||||
|
startMinimized: data?.startMinimized === true,
|
||||||
|
lockedPrimary: typeof data?.lockedPrimary === 'string' ? data.lockedPrimary : null
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return { disabledMirrors: [], startMinimized: false }
|
return { disabledMirrors: [], blacklistedApps: [], startMinimized: false, lockedPrimary: null }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveSettings(settings: Settings): void {
|
function saveSettings(settings: Settings): void {
|
||||||
fs.writeFileSync(settingsPath(), JSON.stringify(settings), 'utf8')
|
const target = settingsPath()
|
||||||
|
const tmp = `${target}.tmp`
|
||||||
|
fs.writeFileSync(tmp, JSON.stringify(settings), 'utf8')
|
||||||
|
fs.renameSync(tmp, target)
|
||||||
}
|
}
|
||||||
|
|
||||||
const LINUX_AUTOSTART_DESKTOP_FILE = join(
|
const LINUX_AUTOSTART_DESKTOP_FILE = join(
|
||||||
app.getPath('home'),
|
app.getPath('home'),
|
||||||
'.config',
|
'.config',
|
||||||
'autostart',
|
'autostart',
|
||||||
'cafe.kirameki.discord-rpc-relay.desktop'
|
'cafe.kirameki.discord-presence-relay.desktop'
|
||||||
)
|
)
|
||||||
|
|
||||||
function getLinuxAutostart(): boolean {
|
function getLinuxAutostart(): boolean {
|
||||||
@@ -55,7 +71,7 @@ function setLinuxAutostart(enabled: boolean, startMinimized: boolean): boolean {
|
|||||||
const desktopEntry = [
|
const desktopEntry = [
|
||||||
'[Desktop Entry]',
|
'[Desktop Entry]',
|
||||||
'Type=Application',
|
'Type=Application',
|
||||||
'Name=Discord RPC Relay',
|
'Name=Discord Presence Relay',
|
||||||
`Exec=${exec}`,
|
`Exec=${exec}`,
|
||||||
'Terminal=false',
|
'Terminal=false',
|
||||||
'X-GNOME-Autostart-enabled=true'
|
'X-GNOME-Autostart-enabled=true'
|
||||||
@@ -136,7 +152,7 @@ function createWindow(): void {
|
|||||||
function createTray(): void {
|
function createTray(): void {
|
||||||
const trayIcon = nativeImage.createFromPath(icon).resize({ width: 24, height: 24 })
|
const trayIcon = nativeImage.createFromPath(icon).resize({ width: 24, height: 24 })
|
||||||
tray = new Tray(trayIcon)
|
tray = new Tray(trayIcon)
|
||||||
tray.setToolTip('Discord RPC Relay')
|
tray.setToolTip('Discord Presence Relay')
|
||||||
|
|
||||||
updateTrayMenu(relay.getStatus())
|
updateTrayMenu(relay.getStatus())
|
||||||
|
|
||||||
@@ -194,7 +210,7 @@ app.on('second-instance', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
electronApp.setAppUserModelId('cafe.kirameki.discord-rpc-relay')
|
electronApp.setAppUserModelId('cafe.kirameki.discord-presence-relay')
|
||||||
|
|
||||||
app.on('browser-window-created', (_, window) => {
|
app.on('browser-window-created', (_, window) => {
|
||||||
optimizer.watchWindowShortcuts(window)
|
optimizer.watchWindowShortcuts(window)
|
||||||
@@ -202,6 +218,8 @@ app.whenReady().then(() => {
|
|||||||
|
|
||||||
const settings = loadSettings()
|
const settings = loadSettings()
|
||||||
relay.setDisabledMirrors(settings.disabledMirrors)
|
relay.setDisabledMirrors(settings.disabledMirrors)
|
||||||
|
relay.setBlacklistedApps(settings.blacklistedApps)
|
||||||
|
relay.setLockedPrimary(settings.lockedPrimary)
|
||||||
|
|
||||||
ipcMain.handle('relay:get-version', () => ({
|
ipcMain.handle('relay:get-version', () => ({
|
||||||
version: app.getVersion(),
|
version: app.getVersion(),
|
||||||
@@ -244,6 +262,24 @@ app.whenReady().then(() => {
|
|||||||
saveSettings({ ...current, disabledMirrors: relay.getDisabledMirrors() })
|
saveSettings({ ...current, disabledMirrors: relay.getDisabledMirrors() })
|
||||||
return status
|
return status
|
||||||
})
|
})
|
||||||
|
ipcMain.handle('relay:set-app-blacklisted', (_e, appId: string, blacklisted: boolean) => {
|
||||||
|
const status = relay.setAppBlacklisted(appId, blacklisted)
|
||||||
|
const current = loadSettings()
|
||||||
|
saveSettings({ ...current, blacklistedApps: relay.getBlacklistedApps() })
|
||||||
|
return status
|
||||||
|
})
|
||||||
|
ipcMain.handle('relay:unlock-primary', () => {
|
||||||
|
const status = relay.unlockPrimary()
|
||||||
|
const current = loadSettings()
|
||||||
|
saveSettings({ ...current, lockedPrimary: relay.getLockedPrimary() })
|
||||||
|
return status
|
||||||
|
})
|
||||||
|
ipcMain.handle('relay:promote-to-primary', async (_e, index: number) => {
|
||||||
|
const status = await relay.promoteToPrimary(index)
|
||||||
|
const current = loadSettings()
|
||||||
|
saveSettings({ ...current, lockedPrimary: relay.getLockedPrimary() })
|
||||||
|
return status
|
||||||
|
})
|
||||||
|
|
||||||
relay.on('status', (status: RelayStatus) => {
|
relay.on('status', (status: RelayStatus) => {
|
||||||
updateTrayMenu(status)
|
updateTrayMenu(status)
|
||||||
@@ -260,16 +296,40 @@ app.whenReady().then(() => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
app.on('window-all-closed', () => {
|
app.on('window-all-closed', () => {})
|
||||||
// Keep running in the tray
|
|
||||||
})
|
|
||||||
|
|
||||||
let stopping = false
|
let stopping = false
|
||||||
|
|
||||||
|
const QUIT_TIMEOUT_MS = 5000
|
||||||
|
|
||||||
app.on('before-quit', (e) => {
|
app.on('before-quit', (e) => {
|
||||||
quitting = true
|
quitting = true
|
||||||
if (stopping) return
|
if (stopping) return
|
||||||
stopping = true
|
stopping = true
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
relay.stop().finally(() => app.exit(0))
|
const timeout = new Promise<void>((resolve) => setTimeout(resolve, QUIT_TIMEOUT_MS))
|
||||||
|
Promise.race([relay.stop(), timeout])
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => {
|
||||||
|
relay.emergencyRestoreSync()
|
||||||
|
app.exit(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Electron doesn't reliably turn SIGTERM/SIGHUP into a quit on Linux.
|
||||||
|
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const) {
|
||||||
|
process.on(signal, () => {
|
||||||
|
quitting = true
|
||||||
|
app.quit()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('uncaughtException', (err) => {
|
||||||
|
console.error('Uncaught exception:', err)
|
||||||
|
relay.emergencyRestoreSync()
|
||||||
|
app.exit(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
process.on('unhandledRejection', (reason) => {
|
||||||
|
console.error('Unhandled rejection:', reason)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ export interface Frame {
|
|||||||
payload: Buffer
|
payload: Buffer
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Buffers stream chunks and yields complete IPC frames as they arrive. */
|
|
||||||
export class FrameReader {
|
export class FrameReader {
|
||||||
private buf = Buffer.alloc(0)
|
private buf = Buffer.alloc(0)
|
||||||
|
|
||||||
@@ -39,7 +38,6 @@ export function encodeFrame(op: number, payload: Buffer): Buffer {
|
|||||||
return Buffer.concat([header, payload])
|
return Buffer.concat([header, payload])
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parses a non-handshake frame's JSON payload, returning null if it isn't JSON. */
|
|
||||||
export function parseFramePayload(frame: Frame): Record<string, unknown> | null {
|
export function parseFramePayload(frame: Frame): Record<string, unknown> | null {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(frame.payload.toString('utf8'))
|
return JSON.parse(frame.payload.toString('utf8'))
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import * as net from 'net'
|
import * as net from 'net'
|
||||||
import { encodeFrame, FrameReader, OP_FRAME, OP_HANDSHAKE } from './ipc-protocol'
|
import { encodeFrame, FrameReader, OP_FRAME, OP_HANDSHAKE } from './ipc-protocol'
|
||||||
|
|
||||||
/**
|
const READY_TIMEOUT_MS = 10_000
|
||||||
* A lazily-established connection to a secondary Discord instance that
|
|
||||||
* mirrors the primary connection's handshake and SET_ACTIVITY frames.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export class MirrorConnection {
|
export class MirrorConnection {
|
||||||
private readonly sock: net.Socket
|
private readonly sock: net.Socket
|
||||||
@@ -16,23 +13,28 @@ export class MirrorConnection {
|
|||||||
constructor(socketPath: string, handshakePayload: Buffer, onClose: () => void) {
|
constructor(socketPath: string, handshakePayload: Buffer, onClose: () => void) {
|
||||||
this.sock = net.createConnection(socketPath)
|
this.sock = net.createConnection(socketPath)
|
||||||
|
|
||||||
|
const readyTimeout = setTimeout(() => this.sock.destroy(), READY_TIMEOUT_MS)
|
||||||
|
readyTimeout.unref()
|
||||||
|
|
||||||
this.sock.on('connect', () => {
|
this.sock.on('connect', () => {
|
||||||
this.sock.write(encodeFrame(OP_HANDSHAKE, handshakePayload))
|
this.sock.write(encodeFrame(OP_HANDSHAKE, handshakePayload))
|
||||||
})
|
})
|
||||||
|
|
||||||
// Discord sends a READY dispatch after the handshake; only once that
|
|
||||||
// arrives will it accept further commands like SET_ACTIVITY.
|
|
||||||
this.sock.on('data', (chunk: Buffer) => {
|
this.sock.on('data', (chunk: Buffer) => {
|
||||||
const hadFrames = this.reader.push(chunk).length > 0
|
const hadFrames = this.reader.push(chunk).length > 0
|
||||||
if (hadFrames && !this.ready) {
|
if (hadFrames && !this.ready) {
|
||||||
this.ready = true
|
this.ready = true
|
||||||
|
clearTimeout(readyTimeout)
|
||||||
for (const frame of this.pending.splice(0)) this.sock.write(frame)
|
for (const frame of this.pending.splice(0)) this.sock.write(frame)
|
||||||
if (this.closeAfterPending) this.sock.end()
|
if (this.closeAfterPending) this.sock.end()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
this.sock.on('error', onClose)
|
this.sock.on('error', () => {})
|
||||||
this.sock.on('close', onClose)
|
this.sock.on('close', () => {
|
||||||
|
clearTimeout(readyTimeout)
|
||||||
|
onClose()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
sendActivity(payload: Buffer): void {
|
sendActivity(payload: Buffer): void {
|
||||||
@@ -44,7 +46,6 @@ export class MirrorConnection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sends a final frame (after the handshake) and closes the connection once it has been flushed. */
|
|
||||||
sendActivityAndClose(payload: Buffer): void {
|
sendActivityAndClose(payload: Buffer): void {
|
||||||
const frame = encodeFrame(OP_FRAME, payload)
|
const frame = encodeFrame(OP_FRAME, payload)
|
||||||
if (this.ready) {
|
if (this.ready) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { PosixPlatform } from './posix'
|
|||||||
import { UnsupportedPlatform } from './unsupported'
|
import { UnsupportedPlatform } from './unsupported'
|
||||||
import type { RelayPlatform } from './types'
|
import type { RelayPlatform } from './types'
|
||||||
|
|
||||||
|
export { MAX_SOCKETS } from './types'
|
||||||
export type { ClaimedSocket, ProcessInfo, RelayPlatform } from './types'
|
export type { ClaimedSocket, ProcessInfo, RelayPlatform } from './types'
|
||||||
|
|
||||||
export const platform: RelayPlatform =
|
export const platform: RelayPlatform =
|
||||||
|
|||||||
+213
-30
@@ -1,16 +1,35 @@
|
|||||||
import * as fs from 'fs'
|
import * as fs from 'fs'
|
||||||
import * as net from 'net'
|
import * as net from 'net'
|
||||||
import * as path from 'path'
|
import * as path from 'path'
|
||||||
import { execFileSync } from 'child_process'
|
import { execFileSync, spawn } from 'child_process'
|
||||||
import type { ClaimedSocket, ProcessInfo, RelayPlatform } from './types'
|
import { MAX_SOCKETS, type ClaimedSocket, type ProcessInfo, type RelayPlatform } from './types'
|
||||||
|
|
||||||
const REAL_PREFIX = 'discord-ipc-real-'
|
const REAL_PREFIX = 'discord-ipc-real-'
|
||||||
const FAKE_INDEX = 0
|
const FAKE_INDEX = 0
|
||||||
const MAX_SOCKETS = 10
|
const ALIVE_CHECK_TIMEOUT_MS = 1000
|
||||||
|
const STALE_SOCKET_MIN_AGE_MS = 10_000
|
||||||
|
|
||||||
|
let darwinTempDir: string | null | undefined
|
||||||
|
|
||||||
|
// $TMPDIR can be overridden by a shell, so ask the OS where Discord really binds.
|
||||||
|
function resolveDarwinTempDir(): string | null {
|
||||||
|
if (darwinTempDir !== undefined) return darwinTempDir
|
||||||
|
try {
|
||||||
|
darwinTempDir = execFileSync('getconf', ['DARWIN_USER_TEMP_DIR'], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: 1000
|
||||||
|
}).trim()
|
||||||
|
} catch {
|
||||||
|
darwinTempDir = null
|
||||||
|
}
|
||||||
|
return darwinTempDir
|
||||||
|
}
|
||||||
|
|
||||||
function runtimeDir(): string {
|
function runtimeDir(): string {
|
||||||
if (process.env.XDG_RUNTIME_DIR) return process.env.XDG_RUNTIME_DIR
|
if (process.env.XDG_RUNTIME_DIR) return process.env.XDG_RUNTIME_DIR
|
||||||
if (process.platform === 'darwin') return process.env.TMPDIR ?? '/tmp'
|
if (process.platform === 'darwin') {
|
||||||
|
return resolveDarwinTempDir() ?? process.env.TMPDIR ?? '/tmp'
|
||||||
|
}
|
||||||
return `/run/user/${process.getuid?.() ?? 0}`
|
return `/run/user/${process.getuid?.() ?? 0}`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,6 +48,10 @@ export class PosixPlatform implements RelayPlatform {
|
|||||||
return ipcPath(FAKE_INDEX)
|
return ipcPath(FAKE_INDEX)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ipcSocketPath(index: number): string {
|
||||||
|
return ipcPath(index)
|
||||||
|
}
|
||||||
|
|
||||||
removeFakeSocket(fakePath: string): void {
|
removeFakeSocket(fakePath: string): void {
|
||||||
if (fs.existsSync(fakePath)) fs.unlinkSync(fakePath)
|
if (fs.existsSync(fakePath)) fs.unlinkSync(fakePath)
|
||||||
}
|
}
|
||||||
@@ -43,65 +66,218 @@ export class PosixPlatform implements RelayPlatform {
|
|||||||
|
|
||||||
async recoverLeftoverSockets(): Promise<void> {
|
async recoverLeftoverSockets(): Promise<void> {
|
||||||
for (let i = 0; i < MAX_SOCKETS; i++) {
|
for (let i = 0; i < MAX_SOCKETS; i++) {
|
||||||
const leftover = claimedPath(i)
|
try {
|
||||||
const original = ipcPath(i)
|
const leftover = claimedPath(i)
|
||||||
if (!fs.existsSync(leftover)) continue
|
const original = ipcPath(i)
|
||||||
|
if (!fs.existsSync(leftover)) continue
|
||||||
|
|
||||||
if (!fs.existsSync(original)) {
|
if (!fs.existsSync(original)) {
|
||||||
fs.renameSync(leftover, original)
|
fs.renameSync(leftover, original)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(await isSocketAlive(original))) {
|
if (!(await isSocketAlive(original))) {
|
||||||
fs.unlinkSync(original)
|
fs.unlinkSync(original)
|
||||||
fs.renameSync(leftover, original)
|
fs.renameSync(leftover, original)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Another process may be touching the same files; keep recovering the rest.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
discoverAndClaim(): ClaimedSocket[] {
|
async discoverAndClaim(): Promise<ClaimedSocket[]> {
|
||||||
const found: ClaimedSocket[] = []
|
const found: ClaimedSocket[] = []
|
||||||
for (let i = 0; i < MAX_SOCKETS; i++) {
|
for (let i = 0; i < MAX_SOCKETS; i++) {
|
||||||
const claimed = this.discoverNewSocket(i)
|
const claimed = await this.discoverNewSocket(i)
|
||||||
if (claimed) found.push(claimed)
|
if (claimed) found.push(claimed)
|
||||||
}
|
}
|
||||||
return found
|
return found
|
||||||
}
|
}
|
||||||
|
|
||||||
discoverNewSocket(index: number): ClaimedSocket | null {
|
async discoverNewSocket(index: number): Promise<ClaimedSocket | null> {
|
||||||
const src = ipcPath(index)
|
const src = ipcPath(index)
|
||||||
if (!fs.existsSync(src)) return null
|
if (!fs.existsSync(src)) return null
|
||||||
|
|
||||||
|
if (!(await isSocketAlive(src))) {
|
||||||
|
try {
|
||||||
|
if (Date.now() - fs.statSync(src).mtimeMs > STALE_SOCKET_MIN_AGE_MS) fs.unlinkSync(src)
|
||||||
|
} catch {
|
||||||
|
// Already gone.
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const dst = claimedPath(index)
|
const dst = claimedPath(index)
|
||||||
fs.renameSync(src, dst)
|
try {
|
||||||
|
fs.renameSync(src, dst)
|
||||||
|
} catch {
|
||||||
|
return null // socket replaced between the check and the rename
|
||||||
|
}
|
||||||
return { index, path: dst }
|
return { index, path: dst }
|
||||||
}
|
}
|
||||||
|
|
||||||
restoreSocket(claimed: ClaimedSocket): void {
|
restoreSocket(claimed: ClaimedSocket): void {
|
||||||
const original = ipcPath(claimed.index)
|
try {
|
||||||
if (fs.existsSync(claimed.path) && !fs.existsSync(original)) {
|
const original = ipcPath(claimed.index)
|
||||||
fs.renameSync(claimed.path, original)
|
if (fs.existsSync(claimed.path) && !fs.existsSync(original)) {
|
||||||
|
fs.renameSync(claimed.path, original)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Best effort.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
discardClaimedSocket(claimed: ClaimedSocket): void {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(claimed.path)
|
||||||
|
} catch {
|
||||||
|
// Already gone.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isSocketAlive(socketPath: string): Promise<boolean> {
|
||||||
|
return isSocketAlive(socketPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeStaleIpcSocket(index: number): Promise<void> {
|
||||||
|
const p = ipcPath(index)
|
||||||
|
if (!fs.existsSync(p)) return
|
||||||
|
if (await isSocketAlive(p)) return
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(p)
|
||||||
|
} catch {
|
||||||
|
// Already gone or replaced.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getInstanceProcess(index: number): ProcessInfo | null {
|
getInstanceProcess(index: number): ProcessInfo | null {
|
||||||
// /proc/net/unix reports the original bind path even after the file is renamed on disk
|
// lsof/procfs still report the original bind path even after we rename to -real-N.
|
||||||
return findSocketOwner(ipcPath(index))
|
return findSocketOwner(ipcPath(index))
|
||||||
}
|
}
|
||||||
|
|
||||||
getPeerProcess(fd: number): ProcessInfo | null {
|
getPeerProcess(fd: number): ProcessInfo | null {
|
||||||
return findPeerProcess(fd)
|
return findPeerProcess(fd)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
killProcess(pid: number): boolean {
|
||||||
|
try {
|
||||||
|
process.kill(pid, 'SIGTERM')
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isProcessAlive(pid: number): boolean {
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0)
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
// EPERM means it's alive, we just aren't allowed to touch it.
|
||||||
|
return (err as NodeJS.ErrnoException).code === 'EPERM'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
launchExecutable(executable: string): boolean {
|
||||||
|
try {
|
||||||
|
// Point Discord at the temp dir we watch, whatever TMPDIR we inherited.
|
||||||
|
const env = { ...process.env }
|
||||||
|
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
// Spawning the .app binary directly just dies, so let `open` launch it.
|
||||||
|
const canonical = resolveDarwinTempDir()
|
||||||
|
if (canonical) env.TMPDIR = canonical
|
||||||
|
|
||||||
|
const appPath = executable.includes('.app/')
|
||||||
|
? executable.slice(0, executable.indexOf('.app/') + '.app'.length)
|
||||||
|
: executable
|
||||||
|
const child = spawn('open', ['-a', appPath], { detached: true, stdio: 'ignore', env })
|
||||||
|
child.unref()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const child = spawn(executable, [], { detached: true, stdio: 'ignore', env })
|
||||||
|
child.unref()
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The pid holding the socket is usually a helper; walk up to the real app process.
|
||||||
|
function resolveMainAppProcess(pid: number): ProcessInfo | null {
|
||||||
|
if (process.platform !== 'darwin') return null
|
||||||
|
|
||||||
|
const exe = executablePath(pid)
|
||||||
|
const bundle = bundleRoot(exe)
|
||||||
|
if (!bundle) return null
|
||||||
|
|
||||||
|
let mainPid = pid
|
||||||
|
let mainExe = exe
|
||||||
|
for (let depth = 0; depth < 20; depth++) {
|
||||||
|
const ppid = parentPid(mainPid)
|
||||||
|
if (ppid === null || ppid <= 1) break
|
||||||
|
const parentExe = executablePath(ppid)
|
||||||
|
if (bundleRoot(parentExe) !== bundle) break
|
||||||
|
mainPid = ppid
|
||||||
|
mainExe = parentExe
|
||||||
|
}
|
||||||
|
|
||||||
|
return { pid: mainPid, name: path.basename(mainExe ?? String(mainPid)), executable: mainExe }
|
||||||
|
}
|
||||||
|
|
||||||
|
function bundleRoot(exe: string | null): string | null {
|
||||||
|
if (!exe) return null
|
||||||
|
const idx = exe.indexOf('.app/Contents/')
|
||||||
|
return idx >= 0 ? exe.slice(0, idx + '.app'.length) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function parentPid(pid: number): number | null {
|
||||||
|
try {
|
||||||
|
const out = execFileSync('ps', ['-p', String(pid), '-o', 'ppid='], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: 1000
|
||||||
|
}).trim()
|
||||||
|
const ppid = parseInt(out, 10)
|
||||||
|
return Number.isNaN(ppid) ? null : ppid
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function executablePath(pid: number): string | null {
|
||||||
|
try {
|
||||||
|
if (process.platform === 'linux') {
|
||||||
|
return fs.readlinkSync(`/proc/${pid}/exe`)
|
||||||
|
}
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
const out = execFileSync('ps', ['-p', String(pid), '-o', 'comm='], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: 1000
|
||||||
|
}).trim()
|
||||||
|
return out || null
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSocketAlive(socketPath: string): Promise<boolean> {
|
function isSocketAlive(socketPath: string): Promise<boolean> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const sock = net.createConnection(socketPath)
|
const sock = net.createConnection(socketPath)
|
||||||
sock.once('connect', () => {
|
let settled = false
|
||||||
|
const done = (alive: boolean): void => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
sock.destroy()
|
sock.destroy()
|
||||||
resolve(true)
|
resolve(alive)
|
||||||
})
|
}
|
||||||
sock.once('error', () => resolve(false))
|
sock.setTimeout(ALIVE_CHECK_TIMEOUT_MS, () => done(false))
|
||||||
|
sock.once('connect', () => done(true))
|
||||||
|
sock.once('error', () => done(false))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,7 +313,13 @@ function locateSocketOwnerMacOS(socketPath: string): ProcessInfo | null {
|
|||||||
currentName = line.slice(1)
|
currentName = line.slice(1)
|
||||||
} else if (line.startsWith('n') && line.slice(1) === socketPath) {
|
} else if (line.startsWith('n') && line.slice(1) === socketPath) {
|
||||||
if (currentPid > 0 && currentPid !== process.pid) {
|
if (currentPid > 0 && currentPid !== process.pid) {
|
||||||
return { pid: currentPid, name: currentName }
|
return (
|
||||||
|
resolveMainAppProcess(currentPid) ?? {
|
||||||
|
pid: currentPid,
|
||||||
|
name: currentName,
|
||||||
|
executable: executablePath(currentPid)
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -197,7 +379,8 @@ function findProcessForInodes(inodes: Set<string>): ProcessInfo | null {
|
|||||||
}
|
}
|
||||||
const match = /^socket:\[(\d+)\]$/.exec(link)
|
const match = /^socket:\[(\d+)\]$/.exec(link)
|
||||||
if (match && inodes.has(match[1])) {
|
if (match && inodes.has(match[1])) {
|
||||||
return { pid: parseInt(pidStr, 10), name: processName(pidStr) }
|
const pid = parseInt(pidStr, 10)
|
||||||
|
return { pid, name: processName(pidStr), executable: executablePath(pid) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -260,7 +443,7 @@ function findPeerProcess(localFd: number): ProcessInfo | null {
|
|||||||
if (fn(localFd, SOL_LOCAL, LOCAL_PEERPID, optval, optlen) !== 0) return null
|
if (fn(localFd, SOL_LOCAL, LOCAL_PEERPID, optval, optlen) !== 0) return null
|
||||||
const pid = optval.readInt32LE(0)
|
const pid = optval.readInt32LE(0)
|
||||||
if (pid <= 0) return null
|
if (pid <= 0) return null
|
||||||
return { pid, name: macProcessName(pid) }
|
return { pid, name: macProcessName(pid), executable: executablePath(pid) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const optval = Buffer.alloc(UCRED_SIZE)
|
const optval = Buffer.alloc(UCRED_SIZE)
|
||||||
@@ -272,7 +455,7 @@ function findPeerProcess(localFd: number): ProcessInfo | null {
|
|||||||
const pid = optval.readInt32LE(0)
|
const pid = optval.readInt32LE(0)
|
||||||
if (pid <= 0) return null
|
if (pid <= 0) return null
|
||||||
|
|
||||||
return { pid, name: processName(String(pid)) }
|
return { pid, name: processName(String(pid)), executable: executablePath(pid) }
|
||||||
}
|
}
|
||||||
|
|
||||||
function macProcessName(pid: number): string {
|
function macProcessName(pid: number): string {
|
||||||
|
|||||||
+20
-14
@@ -1,6 +1,9 @@
|
|||||||
|
export const MAX_SOCKETS = 10
|
||||||
|
|
||||||
export interface ProcessInfo {
|
export interface ProcessInfo {
|
||||||
pid: number
|
pid: number
|
||||||
name: string
|
name: string
|
||||||
|
executable: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClaimedSocket {
|
export interface ClaimedSocket {
|
||||||
@@ -8,38 +11,41 @@ export interface ClaimedSocket {
|
|||||||
path: string
|
path: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Platform-specific primitives the relay needs to take over Discord's IPC
|
|
||||||
* sockets and identify the processes on either end of a connection.
|
|
||||||
*
|
|
||||||
* Implement this interface to add support for a new OS; `RpcRelay` itself
|
|
||||||
* contains no platform-specific code.
|
|
||||||
*/
|
|
||||||
export interface RelayPlatform {
|
export interface RelayPlatform {
|
||||||
readonly isSupported: boolean
|
readonly isSupported: boolean
|
||||||
|
|
||||||
fakeSocketPath(): string
|
fakeSocketPath(): string
|
||||||
|
|
||||||
|
ipcSocketPath(index: number): string
|
||||||
|
|
||||||
removeFakeSocket(fakePath: string): void
|
removeFakeSocket(fakePath: string): void
|
||||||
|
|
||||||
finalizeFakeSocket(fakePath: string): void
|
finalizeFakeSocket(fakePath: string): void
|
||||||
|
|
||||||
/** Checks whether the fake socket file still exists on disk (a restarted Discord client can unlink and rebind it out from under us). */
|
/** Checks whether Discord has unlinked our fake socket. */
|
||||||
fakeSocketExists(fakePath: string): boolean
|
fakeSocketExists(fakePath: string): boolean
|
||||||
|
|
||||||
/**
|
|
||||||
* Restores sockets left claimed from a previous run that crashed before
|
|
||||||
* it could call restoreSocket(), so a fresh start() can discover them.
|
|
||||||
*/
|
|
||||||
recoverLeftoverSockets(): Promise<void>
|
recoverLeftoverSockets(): Promise<void>
|
||||||
|
|
||||||
discoverAndClaim(): ClaimedSocket[]
|
discoverAndClaim(): Promise<ClaimedSocket[]>
|
||||||
|
|
||||||
discoverNewSocket(index: number): ClaimedSocket | null
|
discoverNewSocket(index: number): Promise<ClaimedSocket | null>
|
||||||
|
|
||||||
restoreSocket(claimed: ClaimedSocket): void
|
restoreSocket(claimed: ClaimedSocket): void
|
||||||
|
|
||||||
|
discardClaimedSocket(claimed: ClaimedSocket): void
|
||||||
|
|
||||||
|
isSocketAlive(socketPath: string): Promise<boolean>
|
||||||
|
|
||||||
|
removeStaleIpcSocket(index: number): Promise<void>
|
||||||
|
|
||||||
getInstanceProcess(index: number): ProcessInfo | null
|
getInstanceProcess(index: number): ProcessInfo | null
|
||||||
|
|
||||||
getPeerProcess(fd: number): ProcessInfo | null
|
getPeerProcess(fd: number): ProcessInfo | null
|
||||||
|
|
||||||
|
killProcess(pid: number): boolean
|
||||||
|
|
||||||
|
isProcessAlive(pid: number): boolean
|
||||||
|
|
||||||
|
launchExecutable(executable: string): boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,6 @@ import type { ClaimedSocket, ProcessInfo, RelayPlatform } from './types'
|
|||||||
|
|
||||||
const NOT_SUPPORTED = new Error('Platform not supported')
|
const NOT_SUPPORTED = new Error('Platform not supported')
|
||||||
|
|
||||||
/**
|
|
||||||
* Placeholder for platforms without a real implementation yet.
|
|
||||||
*/
|
|
||||||
export class UnsupportedPlatform implements RelayPlatform {
|
export class UnsupportedPlatform implements RelayPlatform {
|
||||||
readonly isSupported = false
|
readonly isSupported = false
|
||||||
|
|
||||||
@@ -12,6 +9,10 @@ export class UnsupportedPlatform implements RelayPlatform {
|
|||||||
throw NOT_SUPPORTED
|
throw NOT_SUPPORTED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ipcSocketPath(): string {
|
||||||
|
throw NOT_SUPPORTED
|
||||||
|
}
|
||||||
|
|
||||||
removeFakeSocket(): void {
|
removeFakeSocket(): void {
|
||||||
throw NOT_SUPPORTED
|
throw NOT_SUPPORTED
|
||||||
}
|
}
|
||||||
@@ -28,11 +29,11 @@ export class UnsupportedPlatform implements RelayPlatform {
|
|||||||
throw NOT_SUPPORTED
|
throw NOT_SUPPORTED
|
||||||
}
|
}
|
||||||
|
|
||||||
discoverAndClaim(): ClaimedSocket[] {
|
async discoverAndClaim(): Promise<ClaimedSocket[]> {
|
||||||
throw NOT_SUPPORTED
|
throw NOT_SUPPORTED
|
||||||
}
|
}
|
||||||
|
|
||||||
discoverNewSocket(): ClaimedSocket | null {
|
async discoverNewSocket(): Promise<ClaimedSocket | null> {
|
||||||
throw NOT_SUPPORTED
|
throw NOT_SUPPORTED
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,6 +41,18 @@ export class UnsupportedPlatform implements RelayPlatform {
|
|||||||
throw NOT_SUPPORTED
|
throw NOT_SUPPORTED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
discardClaimedSocket(): void {
|
||||||
|
throw NOT_SUPPORTED
|
||||||
|
}
|
||||||
|
|
||||||
|
async isSocketAlive(): Promise<boolean> {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeStaleIpcSocket(): Promise<void> {
|
||||||
|
throw NOT_SUPPORTED
|
||||||
|
}
|
||||||
|
|
||||||
getInstanceProcess(): ProcessInfo | null {
|
getInstanceProcess(): ProcessInfo | null {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -47,4 +60,16 @@ export class UnsupportedPlatform implements RelayPlatform {
|
|||||||
getPeerProcess(): ProcessInfo | null {
|
getPeerProcess(): ProcessInfo | null {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
killProcess(): boolean {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
isProcessAlive(): boolean {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
launchExecutable(): boolean {
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+520
-145
@@ -3,11 +3,18 @@ import { EventEmitter } from 'events'
|
|||||||
import * as net from 'net'
|
import * as net from 'net'
|
||||||
import { encodeFrame, Frame, FrameReader, OP_HANDSHAKE, parseFramePayload } from './ipc-protocol'
|
import { encodeFrame, Frame, FrameReader, OP_HANDSHAKE, parseFramePayload } from './ipc-protocol'
|
||||||
import { MirrorConnection } from './mirror-connection'
|
import { MirrorConnection } from './mirror-connection'
|
||||||
import { platform, type ClaimedSocket, type ProcessInfo } from './platform'
|
import { platform, MAX_SOCKETS, type ClaimedSocket, type ProcessInfo } from './platform'
|
||||||
|
|
||||||
const MAX_SOCKETS = 10
|
|
||||||
const DISCOVERY_INTERVAL_MS = 3000
|
const DISCOVERY_INTERVAL_MS = 3000
|
||||||
const WAITING_RETRY_INTERVAL_MS = 3000
|
const WAITING_RETRY_INTERVAL_MS = 3000
|
||||||
|
const MIRROR_RECONNECT_DELAY_MS = 2000
|
||||||
|
const SERVER_CLOSE_TIMEOUT_MS = 2000
|
||||||
|
const ASSET_FETCH_RETRY_MS = 60_000
|
||||||
|
|
||||||
|
const PROCESS_EXIT_TIMEOUT_MS = 8000
|
||||||
|
const PROCESS_EXIT_POLL_MS = 200
|
||||||
|
const SLOT_BIND_TIMEOUT_MS = 20_000
|
||||||
|
const SLOT_BIND_POLL_MS = 300
|
||||||
|
|
||||||
interface AppAsset {
|
interface AppAsset {
|
||||||
id: string
|
id: string
|
||||||
@@ -15,27 +22,46 @@ interface AppAsset {
|
|||||||
name: string
|
name: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const appAssetCache = new Map<string, Map<string, string>>()
|
interface AssetCacheEntry {
|
||||||
|
map: Map<string, string>
|
||||||
|
expires: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const appAssetCache = new Map<string, AssetCacheEntry>()
|
||||||
|
|
||||||
async function getAppAssetMap(appId: string): Promise<Map<string, string>> {
|
async function getAppAssetMap(appId: string): Promise<Map<string, string>> {
|
||||||
const cached = appAssetCache.get(appId)
|
const cached = appAssetCache.get(appId)
|
||||||
if (cached) return cached
|
if (cached && Date.now() < cached.expires) return cached.map
|
||||||
|
|
||||||
const map = new Map<string, string>()
|
const map = new Map<string, string>()
|
||||||
|
let expires = Number.POSITIVE_INFINITY
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`https://discord.com/api/v9/oauth2/applications/${appId}/assets`)
|
const res = await fetch(`https://discord.com/api/v9/oauth2/applications/${appId}/assets`)
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const assets = (await res.json()) as AppAsset[]
|
const assets = (await res.json()) as AppAsset[]
|
||||||
for (const asset of assets) map.set(asset.name, asset.id)
|
for (const asset of assets) map.set(asset.name, asset.id)
|
||||||
|
} else {
|
||||||
|
expires = Date.now() + ASSET_FETCH_RETRY_MS
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Network error: cache an empty map so we don't retry every frame.
|
expires = Date.now() + ASSET_FETCH_RETRY_MS
|
||||||
}
|
}
|
||||||
|
|
||||||
appAssetCache.set(appId, map)
|
appAssetCache.set(appId, { map, expires })
|
||||||
return map
|
return map
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearActivityPayload(pid: number): Buffer {
|
||||||
|
return Buffer.from(
|
||||||
|
JSON.stringify({ cmd: 'SET_ACTIVITY', args: { pid, activity: null }, nonce: randomUUID() }),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveAssetImage(
|
async function resolveAssetImage(
|
||||||
key: string | undefined,
|
key: string | undefined,
|
||||||
appId: string | null
|
appId: string | null
|
||||||
@@ -65,12 +91,21 @@ export interface RelayInstance {
|
|||||||
path: string
|
path: string
|
||||||
isPrimary: boolean
|
isPrimary: boolean
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
|
locked: boolean
|
||||||
process: ProcessInfo | null
|
process: ProcessInfo | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BlacklistedApp {
|
||||||
|
id: string
|
||||||
|
name: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface ConnectedClient {
|
export interface ConnectedClient {
|
||||||
id: number
|
id: number
|
||||||
process: ProcessInfo | null
|
process: ProcessInfo | null
|
||||||
|
appId: string | null
|
||||||
|
blacklisted: boolean
|
||||||
|
activity: LastActivity | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActivityAssets {
|
export interface ActivityAssets {
|
||||||
@@ -106,51 +141,94 @@ export interface RelayStatus {
|
|||||||
unsupported: boolean
|
unsupported: boolean
|
||||||
instances: RelayInstance[]
|
instances: RelayInstance[]
|
||||||
connectedClients: ConnectedClient[]
|
connectedClients: ConnectedClient[]
|
||||||
lastActivity: LastActivity | null
|
blacklistedApps: BlacklistedApp[]
|
||||||
|
lockedPrimary: string | null
|
||||||
|
reordering: boolean
|
||||||
|
primaryOutOfOrder: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
interface ClientSession {
|
||||||
* Takes over Discord's primary IPC socket (discord-ipc-0), passing every
|
id: number
|
||||||
* frame through to the real primary instance unchanged, while mirroring the
|
client: net.Socket
|
||||||
* handshake and SET_ACTIVITY frames to any other running Discord instances.
|
primary: net.Socket
|
||||||
*/
|
process: ProcessInfo | null
|
||||||
|
handshakePayload: Buffer | null
|
||||||
|
appId: string | null
|
||||||
|
lastActivityPayload: Buffer | null
|
||||||
|
lastActivityPid: number | null
|
||||||
|
activity: LastActivity | null
|
||||||
|
mirrors: Map<number, MirrorConnection>
|
||||||
|
reconnectTimers: Map<number, NodeJS.Timeout>
|
||||||
|
closed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hijacks discord-ipc-0, passthrough to primary, mirroring frames to other instances. */
|
||||||
export class RpcRelay extends EventEmitter {
|
export class RpcRelay extends EventEmitter {
|
||||||
private server: net.Server | null = null
|
private server: net.Server | null = null
|
||||||
private claimed: ClaimedSocket[] = []
|
private claimed: ClaimedSocket[] = []
|
||||||
private disabledMirrors = new Set<number>()
|
private disabledMirrors = new Set<number>()
|
||||||
private connectedClients = new Map<number, ConnectedClient>()
|
private blacklistedApps = new Map<string, string | null>()
|
||||||
|
private sessions = new Map<number, ClientSession>()
|
||||||
private nextClientId = 1
|
private nextClientId = 1
|
||||||
private discoveryTimer: NodeJS.Timeout | null = null
|
private discoveryTimer: NodeJS.Timeout | null = null
|
||||||
|
private discovering = false
|
||||||
private waitingTimer: NodeJS.Timeout | null = null
|
private waitingTimer: NodeJS.Timeout | null = null
|
||||||
private lastActivity: LastActivity | null = null
|
|
||||||
private running = false
|
private running = false
|
||||||
private waiting = false
|
private waiting = false
|
||||||
|
private fakeOwned = false
|
||||||
private lastError: string | null = null
|
private lastError: string | null = null
|
||||||
private restarting = false
|
private restarting = false
|
||||||
private mirrors = new Map<number, MirrorConnection>()
|
private lockedPrimary: string | null = null
|
||||||
private lastActivityPid = new Map<number, number>()
|
private reordering = false
|
||||||
|
|
||||||
getStatus(): RelayStatus {
|
getStatus(): RelayStatus {
|
||||||
const instances: RelayInstance[] = this.claimed.map(({ index, path: socketPath }, i) => ({
|
const instances: RelayInstance[] = this.claimed.map(({ index, path: socketPath }, i) => {
|
||||||
index,
|
const process = platform.getInstanceProcess(index)
|
||||||
path: socketPath,
|
return {
|
||||||
isPrimary: i === 0,
|
index,
|
||||||
enabled: i === 0 || !this.disabledMirrors.has(index),
|
path: socketPath,
|
||||||
process: platform.getInstanceProcess(index)
|
isPrimary: i === 0,
|
||||||
}))
|
enabled: i === 0 || !this.disabledMirrors.has(index),
|
||||||
|
locked: this.isLockedInstance(process),
|
||||||
|
process
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
running: this.running,
|
running: this.running,
|
||||||
waiting: this.waiting,
|
waiting: this.waiting,
|
||||||
unsupported: !platform.isSupported,
|
unsupported: !platform.isSupported,
|
||||||
instances,
|
instances,
|
||||||
connectedClients: [...this.connectedClients.values()],
|
connectedClients: [...this.sessions.values()].map((s) => ({
|
||||||
lastActivity: this.lastActivity,
|
id: s.id,
|
||||||
|
process: s.process,
|
||||||
|
appId: s.appId,
|
||||||
|
blacklisted: this.isBlacklisted(s),
|
||||||
|
activity: s.activity
|
||||||
|
})),
|
||||||
|
blacklistedApps: this.getBlacklistedApps(),
|
||||||
|
lockedPrimary: this.lockedPrimary,
|
||||||
|
reordering: this.reordering,
|
||||||
|
primaryOutOfOrder: this.primaryOutOfOrder(instances),
|
||||||
error: this.lastError
|
error: this.lastError
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isLockedInstance(process: ProcessInfo | null): boolean {
|
||||||
|
return (
|
||||||
|
this.lockedPrimary !== null &&
|
||||||
|
process?.executable != null &&
|
||||||
|
process.executable === this.lockedPrimary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private primaryOutOfOrder(instances: RelayInstance[]): boolean {
|
||||||
|
if (this.lockedPrimary === null) return false
|
||||||
|
const locked = instances.find((inst) => inst.locked)
|
||||||
|
return locked !== undefined && !locked.isPrimary
|
||||||
|
}
|
||||||
|
|
||||||
setMirrorEnabled(index: number, enabled: boolean): RelayStatus {
|
setMirrorEnabled(index: number, enabled: boolean): RelayStatus {
|
||||||
if (index === this.primaryIndex()) return this.getStatus()
|
if (index === this.primaryIndex()) return this.getStatus()
|
||||||
|
|
||||||
@@ -172,6 +250,87 @@ export class RpcRelay extends EventEmitter {
|
|||||||
this.disabledMirrors = new Set(indices)
|
this.disabledMirrors = new Set(indices)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getLockedPrimary(): string | null {
|
||||||
|
return this.lockedPrimary
|
||||||
|
}
|
||||||
|
|
||||||
|
setLockedPrimary(executable: string | null): void {
|
||||||
|
this.lockedPrimary = executable
|
||||||
|
this.emitStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
unlockPrimary(): RelayStatus {
|
||||||
|
this.lockedPrimary = null
|
||||||
|
this.emitStatus()
|
||||||
|
return this.getStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
async promoteToPrimary(index: number): Promise<RelayStatus> {
|
||||||
|
const process = platform.getInstanceProcess(index)
|
||||||
|
if (!process?.executable) {
|
||||||
|
this.lastError = 'Cannot resolve this Discord’s executable path'
|
||||||
|
this.emitStatus()
|
||||||
|
return this.getStatus()
|
||||||
|
}
|
||||||
|
this.lockedPrimary = process.executable
|
||||||
|
this.emitStatus()
|
||||||
|
return this.reorderForPrimary()
|
||||||
|
}
|
||||||
|
|
||||||
|
getBlacklistedApps(): BlacklistedApp[] {
|
||||||
|
return [...this.blacklistedApps].map(([id, name]) => ({ id, name }))
|
||||||
|
}
|
||||||
|
|
||||||
|
setBlacklistedApps(apps: BlacklistedApp[]): void {
|
||||||
|
this.blacklistedApps = new Map(apps.map((a) => [a.id, a.name]))
|
||||||
|
}
|
||||||
|
|
||||||
|
setAppBlacklisted(appId: string, blacklisted: boolean): RelayStatus {
|
||||||
|
if (blacklisted) {
|
||||||
|
const session = [...this.sessions.values()].find((s) => s.appId === appId)
|
||||||
|
const name = session?.activity?.app ?? session?.process?.name ?? null
|
||||||
|
this.blacklistedApps.set(appId, name ?? this.blacklistedApps.get(appId) ?? null)
|
||||||
|
} else {
|
||||||
|
this.blacklistedApps.delete(appId)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const session of this.sessions.values()) {
|
||||||
|
if (session.appId !== appId) continue
|
||||||
|
if (blacklisted) {
|
||||||
|
this.clearSessionMirrors(session)
|
||||||
|
} else if (session.lastActivityPayload) {
|
||||||
|
for (let i = 1; i < this.claimed.length; i++) {
|
||||||
|
const target = this.claimed[i]
|
||||||
|
if (this.disabledMirrors.has(target.index)) continue
|
||||||
|
if (this.ensureMirror(session, target)) {
|
||||||
|
session.mirrors.get(target.index)?.sendActivity(session.lastActivityPayload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emitStatus()
|
||||||
|
return this.getStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
private isBlacklisted(session: ClientSession): boolean {
|
||||||
|
return session.appId !== null && this.blacklistedApps.has(session.appId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearSessionMirrors(session: ClientSession): void {
|
||||||
|
for (const timer of session.reconnectTimers.values()) clearTimeout(timer)
|
||||||
|
session.reconnectTimers.clear()
|
||||||
|
|
||||||
|
for (const mirror of session.mirrors.values()) {
|
||||||
|
if (session.lastActivityPid !== null) {
|
||||||
|
mirror.sendActivityAndClose(clearActivityPayload(session.lastActivityPid))
|
||||||
|
} else {
|
||||||
|
mirror.destroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
session.mirrors.clear()
|
||||||
|
}
|
||||||
|
|
||||||
async start(): Promise<void> {
|
async start(): Promise<void> {
|
||||||
if (this.running) return
|
if (this.running) return
|
||||||
this.lastError = null
|
this.lastError = null
|
||||||
@@ -183,34 +342,46 @@ export class RpcRelay extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await platform.recoverLeftoverSockets()
|
await platform.recoverLeftoverSockets()
|
||||||
this.claimed = platform.discoverAndClaim()
|
const claimed = await platform.discoverAndClaim()
|
||||||
|
|
||||||
if (this.claimed.length === 0) {
|
if (claimed.length === 0) {
|
||||||
this.waitForDiscord()
|
this.waitForDiscord()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.claimed = claimed
|
||||||
this.waiting = false
|
this.waiting = false
|
||||||
this.stopWaitingTimer()
|
this.stopWaitingTimer()
|
||||||
|
|
||||||
const fake = platform.fakeSocketPath()
|
try {
|
||||||
platform.removeFakeSocket(fake)
|
const fake = platform.fakeSocketPath()
|
||||||
|
platform.removeFakeSocket(fake)
|
||||||
|
|
||||||
this.server = net.createServer((sock) => this.handleClient(sock))
|
this.server = net.createServer((sock) => this.handleClient(sock))
|
||||||
this.server.on('error', (err) => {
|
this.server.on('error', (err) => {
|
||||||
this.lastError = err.message
|
this.lastError = err.message
|
||||||
this.emitStatus()
|
this.emitStatus()
|
||||||
})
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
this.server!.once('error', reject)
|
|
||||||
this.server!.listen(fake, () => {
|
|
||||||
this.server!.removeListener('error', reject)
|
|
||||||
resolve()
|
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
platform.finalizeFakeSocket(fake)
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
this.server!.once('error', reject)
|
||||||
|
this.server!.listen(fake, () => {
|
||||||
|
this.server!.removeListener('error', reject)
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
platform.finalizeFakeSocket(fake)
|
||||||
|
this.fakeOwned = true
|
||||||
|
} catch (err) {
|
||||||
|
this.server?.close()
|
||||||
|
this.server = null
|
||||||
|
for (const c of this.claimed) platform.restoreSocket(c)
|
||||||
|
this.claimed = []
|
||||||
|
this.lastError = err instanceof Error ? err.message : String(err)
|
||||||
|
this.emitStatus()
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
this.running = true
|
this.running = true
|
||||||
this.startDiscoveryTimer()
|
this.startDiscoveryTimer()
|
||||||
@@ -228,23 +399,41 @@ export class RpcRelay extends EventEmitter {
|
|||||||
this.running = false
|
this.running = false
|
||||||
this.stopDiscoveryTimer()
|
this.stopDiscoveryTimer()
|
||||||
|
|
||||||
|
for (const session of [...this.sessions.values()]) this.destroySession(session)
|
||||||
|
|
||||||
if (this.server) {
|
if (this.server) {
|
||||||
await new Promise<void>((resolve) => this.server!.close(() => resolve()))
|
const server = this.server
|
||||||
this.server = null
|
this.server = null
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const timeout = setTimeout(resolve, SERVER_CLOSE_TIMEOUT_MS)
|
||||||
|
server.close(() => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
platform.removeFakeSocket(platform.fakeSocketPath())
|
platform.removeFakeSocket(platform.fakeSocketPath())
|
||||||
|
this.fakeOwned = false
|
||||||
for (const claimed of this.claimed) platform.restoreSocket(claimed)
|
for (const claimed of this.claimed) platform.restoreSocket(claimed)
|
||||||
|
|
||||||
for (const mirror of this.mirrors.values()) mirror.destroy()
|
|
||||||
this.mirrors.clear()
|
|
||||||
this.lastActivityPid.clear()
|
|
||||||
|
|
||||||
this.claimed = []
|
this.claimed = []
|
||||||
this.connectedClients.clear()
|
|
||||||
this.emitStatus()
|
this.emitStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
emergencyRestoreSync(): void {
|
||||||
|
try {
|
||||||
|
if (this.fakeOwned) {
|
||||||
|
platform.removeFakeSocket(platform.fakeSocketPath())
|
||||||
|
this.fakeOwned = false
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Best effort.
|
||||||
|
}
|
||||||
|
for (const claimed of this.claimed) platform.restoreSocket(claimed)
|
||||||
|
this.claimed = []
|
||||||
|
}
|
||||||
|
|
||||||
private primaryIndex(): number | undefined {
|
private primaryIndex(): number | undefined {
|
||||||
return this.claimed[0]?.index
|
return this.claimed[0]?.index
|
||||||
}
|
}
|
||||||
@@ -273,7 +462,7 @@ export class RpcRelay extends EventEmitter {
|
|||||||
|
|
||||||
private startDiscoveryTimer(): void {
|
private startDiscoveryTimer(): void {
|
||||||
this.stopDiscoveryTimer()
|
this.stopDiscoveryTimer()
|
||||||
this.discoveryTimer = setInterval(() => this.discoverNewInstances(), DISCOVERY_INTERVAL_MS)
|
this.discoveryTimer = setInterval(() => void this.discoverInstances(), DISCOVERY_INTERVAL_MS)
|
||||||
}
|
}
|
||||||
|
|
||||||
private stopDiscoveryTimer(): void {
|
private stopDiscoveryTimer(): void {
|
||||||
@@ -283,66 +472,115 @@ export class RpcRelay extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Picks up Discord instances launched after the relay started, and recovers from a stolen primary socket. */
|
/** Periodic: recover stolen primary, prune dead instances, discover new ones. */
|
||||||
private discoverNewInstances(): void {
|
private async discoverInstances(): Promise<void> {
|
||||||
if (!this.running) return
|
if (!this.running || this.discovering) return
|
||||||
|
this.discovering = true
|
||||||
|
try {
|
||||||
|
if (!platform.fakeSocketExists(platform.fakeSocketPath())) {
|
||||||
|
void this.restart()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (!platform.fakeSocketExists(platform.fakeSocketPath())) {
|
let changed = false
|
||||||
void this.restart()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const claimedIndices = new Set(this.claimed.map((c) => c.index))
|
for (const claimed of [...this.claimed]) {
|
||||||
let changed = false
|
if (await platform.isSocketAlive(claimed.path)) continue
|
||||||
|
if (!this.running) return
|
||||||
|
|
||||||
for (let i = 1; i < MAX_SOCKETS; i++) {
|
if (claimed === this.claimed[0]) {
|
||||||
if (claimedIndices.has(i)) continue
|
void this.restart()
|
||||||
const found = platform.discoverNewSocket(i)
|
return
|
||||||
if (found) {
|
}
|
||||||
this.claimed.push(found)
|
|
||||||
|
this.claimed = this.claimed.filter((c) => c !== claimed)
|
||||||
|
platform.discardClaimedSocket(claimed)
|
||||||
|
for (const session of this.sessions.values()) {
|
||||||
|
this.dropSessionMirror(session, claimed.index)
|
||||||
|
}
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (changed) this.emitStatus()
|
const claimedIndices = new Set(this.claimed.map((c) => c.index))
|
||||||
|
for (let i = 1; i < MAX_SOCKETS; i++) {
|
||||||
|
if (claimedIndices.has(i)) continue
|
||||||
|
const found = await platform.discoverNewSocket(i)
|
||||||
|
if (!found) continue
|
||||||
|
if (!this.running) {
|
||||||
|
platform.restoreSocket(found)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.claimed.push(found)
|
||||||
|
changed = true
|
||||||
|
|
||||||
|
if (!this.disabledMirrors.has(found.index)) {
|
||||||
|
for (const session of this.sessions.values()) {
|
||||||
|
if (this.isBlacklisted(session)) continue
|
||||||
|
if (this.ensureMirror(session, found) && session.lastActivityPayload) {
|
||||||
|
session.mirrors.get(found.index)?.sendActivity(session.lastActivityPayload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) this.emitStatus()
|
||||||
|
|
||||||
|
if (this.lockedPrimary !== null) {
|
||||||
|
const lockedSlot = this.claimed.findIndex(
|
||||||
|
(c) => platform.getInstanceProcess(c.index)?.executable === this.lockedPrimary
|
||||||
|
)
|
||||||
|
if (lockedSlot > 0) {
|
||||||
|
this.discovering = false
|
||||||
|
await this.reorderForPrimary()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.lastError = err instanceof Error ? err.message : String(err)
|
||||||
|
this.emitStatus()
|
||||||
|
} finally {
|
||||||
|
this.discovering = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleClient(client: net.Socket): void {
|
private handleClient(client: net.Socket): void {
|
||||||
const clientId = this.nextClientId++
|
const primaryClaimed = this.claimed[0]
|
||||||
|
if (!primaryClaimed) {
|
||||||
|
client.destroy()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const fd = (client as unknown as { _handle?: { fd?: number } })._handle?.fd
|
const fd = (client as unknown as { _handle?: { fd?: number } })._handle?.fd
|
||||||
this.connectedClients.set(clientId, {
|
const session: ClientSession = {
|
||||||
id: clientId,
|
id: this.nextClientId++,
|
||||||
process: fd !== undefined ? platform.getPeerProcess(fd) : null
|
client,
|
||||||
})
|
primary: net.createConnection(primaryClaimed.path),
|
||||||
|
process: fd !== undefined ? platform.getPeerProcess(fd) : null,
|
||||||
|
handshakePayload: null,
|
||||||
|
appId: null,
|
||||||
|
lastActivityPayload: null,
|
||||||
|
lastActivityPid: null,
|
||||||
|
activity: null,
|
||||||
|
mirrors: new Map(),
|
||||||
|
reconnectTimers: new Map(),
|
||||||
|
closed: false
|
||||||
|
}
|
||||||
|
this.sessions.set(session.id, session)
|
||||||
this.emitStatus()
|
this.emitStatus()
|
||||||
|
|
||||||
const primaryPath = this.claimed[0].path
|
const { primary } = session
|
||||||
const primary = net.createConnection(primaryPath)
|
|
||||||
|
|
||||||
const clientReader = new FrameReader()
|
const clientReader = new FrameReader()
|
||||||
const primaryReader = new FrameReader()
|
const primaryReader = new FrameReader()
|
||||||
|
|
||||||
let handshakePayload: Buffer | null = null
|
|
||||||
|
|
||||||
const cleanup = (): void => {
|
|
||||||
client.destroy()
|
|
||||||
primary.destroy()
|
|
||||||
for (const mirror of this.mirrors.values()) mirror.destroy()
|
|
||||||
this.mirrors.clear()
|
|
||||||
this.lastActivityPid.clear()
|
|
||||||
this.connectedClients.delete(clientId)
|
|
||||||
this.emitStatus()
|
|
||||||
}
|
|
||||||
|
|
||||||
let clientAppId: string | null = null
|
|
||||||
let primaryConnected = false
|
let primaryConnected = false
|
||||||
const pendingToPrimary: Buffer[] = []
|
const pendingToPrimary: Buffer[] = []
|
||||||
|
|
||||||
client.on('data', (chunk: Buffer) => {
|
client.on('data', (chunk: Buffer) => {
|
||||||
for (const frame of clientReader.push(chunk)) {
|
for (const frame of clientReader.push(chunk)) {
|
||||||
if (frame.op === OP_HANDSHAKE) {
|
if (frame.op === OP_HANDSHAKE) {
|
||||||
handshakePayload = frame.payload
|
session.handshakePayload = frame.payload
|
||||||
clientAppId = (parseFramePayload(frame)?.client_id as string) ?? null
|
session.appId = (parseFramePayload(frame)?.client_id as string) ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
const encoded = encodeFrame(frame.op, frame.payload)
|
const encoded = encodeFrame(frame.op, frame.payload)
|
||||||
@@ -352,8 +590,8 @@ export class RpcRelay extends EventEmitter {
|
|||||||
pendingToPrimary.push(encoded)
|
pendingToPrimary.push(encoded)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (handshakePayload) this.mirrorFrame(frame, handshakePayload)
|
if (session.handshakePayload) this.mirrorFrame(session, frame)
|
||||||
void this.recordActivity(frame, clientAppId)
|
void this.recordActivity(session, frame)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -368,21 +606,48 @@ export class RpcRelay extends EventEmitter {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const cleanup = (): void => this.destroySession(session)
|
||||||
|
|
||||||
client.on('error', cleanup)
|
client.on('error', cleanup)
|
||||||
|
client.on('close', cleanup)
|
||||||
|
primary.on('close', cleanup)
|
||||||
primary.on('error', (err) => {
|
primary.on('error', (err) => {
|
||||||
cleanup()
|
cleanup()
|
||||||
if ((err as NodeJS.ErrnoException).code === 'ECONNREFUSED') {
|
const code = (err as NodeJS.ErrnoException).code
|
||||||
|
if (code === 'ECONNREFUSED' || code === 'ENOENT') {
|
||||||
void this.restart()
|
void this.restart()
|
||||||
} else {
|
} else {
|
||||||
this.lastError = `Primary connection error: ${err.message}`
|
this.lastError = `Primary connection error: ${err.message}`
|
||||||
this.emitStatus()
|
this.emitStatus()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
client.on('close', cleanup)
|
|
||||||
primary.on('close', cleanup)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Restarts the relay, picking up any Discord instance that has replaced its IPC socket. */
|
private destroySession(session: ClientSession): void {
|
||||||
|
if (session.closed) return
|
||||||
|
session.closed = true
|
||||||
|
|
||||||
|
session.client.destroy()
|
||||||
|
session.primary.destroy()
|
||||||
|
for (const timer of session.reconnectTimers.values()) clearTimeout(timer)
|
||||||
|
session.reconnectTimers.clear()
|
||||||
|
for (const mirror of session.mirrors.values()) mirror.destroy()
|
||||||
|
session.mirrors.clear()
|
||||||
|
|
||||||
|
this.sessions.delete(session.id)
|
||||||
|
this.emitStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
private dropSessionMirror(session: ClientSession, index: number): void {
|
||||||
|
const timer = session.reconnectTimers.get(index)
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
session.reconnectTimers.delete(index)
|
||||||
|
}
|
||||||
|
session.mirrors.get(index)?.destroy()
|
||||||
|
session.mirrors.delete(index)
|
||||||
|
}
|
||||||
|
|
||||||
private async restart(): Promise<void> {
|
private async restart(): Promise<void> {
|
||||||
if (this.restarting) return
|
if (this.restarting) return
|
||||||
this.restarting = true
|
this.restarting = true
|
||||||
@@ -397,79 +662,185 @@ export class RpcRelay extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Forwards the handshake and SET_ACTIVITY frames to every enabled mirror instance. */
|
private async reorderForPrimary(): Promise<RelayStatus> {
|
||||||
private mirrorFrame(frame: Frame, handshakePayload: Buffer): void {
|
if (this.reordering) return this.getStatus()
|
||||||
|
if (this.lockedPrimary === null) return this.getStatus()
|
||||||
|
|
||||||
|
const lockedSlot = this.claimed.findIndex(
|
||||||
|
(c) => platform.getInstanceProcess(c.index)?.executable === this.lockedPrimary
|
||||||
|
)
|
||||||
|
if (lockedSlot < 0) {
|
||||||
|
this.lastError = 'Locked Discord is not currently running'
|
||||||
|
this.emitStatus()
|
||||||
|
return this.getStatus()
|
||||||
|
}
|
||||||
|
if (lockedSlot === 0) return this.getStatus()
|
||||||
|
|
||||||
|
this.reordering = true
|
||||||
|
this.lastError = null
|
||||||
|
this.emitStatus()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Grab what we need now before stop() wipes claimed below.
|
||||||
|
const others: string[] = []
|
||||||
|
const toKill: number[] = []
|
||||||
|
const freeIndices: number[] = []
|
||||||
|
for (let i = 0; i <= lockedSlot; i++) {
|
||||||
|
freeIndices.push(this.claimed[i].index)
|
||||||
|
const proc = platform.getInstanceProcess(this.claimed[i].index)
|
||||||
|
if (!proc) continue
|
||||||
|
toKill.push(proc.pid)
|
||||||
|
if (proc.executable && proc.executable !== this.lockedPrimary) {
|
||||||
|
others.push(proc.executable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const lockedExecutable = this.lockedPrimary
|
||||||
|
|
||||||
|
await this.stop()
|
||||||
|
|
||||||
|
for (const pid of toKill) platform.killProcess(pid)
|
||||||
|
await this.waitForProcessesExit(toKill)
|
||||||
|
|
||||||
|
for (const index of freeIndices) {
|
||||||
|
await platform.removeStaleIpcSocket(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!platform.launchExecutable(lockedExecutable)) {
|
||||||
|
throw new Error('Failed to relaunch the locked Discord')
|
||||||
|
}
|
||||||
|
await this.waitForSlotBound(0)
|
||||||
|
|
||||||
|
for (const executable of others) platform.launchExecutable(executable)
|
||||||
|
} catch (err) {
|
||||||
|
this.lastError = err instanceof Error ? err.message : String(err)
|
||||||
|
} finally {
|
||||||
|
this.reordering = false
|
||||||
|
await this.start().catch((err) => {
|
||||||
|
this.lastError = err instanceof Error ? err.message : String(err)
|
||||||
|
})
|
||||||
|
this.emitStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
private async waitForProcessesExit(pids: number[]): Promise<void> {
|
||||||
|
const deadline = Date.now() + PROCESS_EXIT_TIMEOUT_MS
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (pids.every((pid) => !platform.isProcessAlive(pid))) return
|
||||||
|
await delay(PROCESS_EXIT_POLL_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async waitForSlotBound(index: number): Promise<void> {
|
||||||
|
const deadline = Date.now() + SLOT_BIND_TIMEOUT_MS
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (await platform.isSocketAlive(platform.ipcSocketPath(index))) return
|
||||||
|
await delay(SLOT_BIND_POLL_MS)
|
||||||
|
}
|
||||||
|
throw new Error('Timed out waiting for the locked Discord to start')
|
||||||
|
}
|
||||||
|
|
||||||
|
private mirrorFrame(session: ClientSession, frame: Frame): void {
|
||||||
const payload = frame.op !== OP_HANDSHAKE ? parseFramePayload(frame) : null
|
const payload = frame.op !== OP_HANDSHAKE ? parseFramePayload(frame) : null
|
||||||
const isSetActivity = payload?.cmd === 'SET_ACTIVITY'
|
const isSetActivity = payload?.cmd === 'SET_ACTIVITY'
|
||||||
if (frame.op !== OP_HANDSHAKE && !isSetActivity) return
|
if (frame.op !== OP_HANDSHAKE && !isSetActivity) return
|
||||||
|
|
||||||
if (isSetActivity) {
|
if (isSetActivity) {
|
||||||
|
session.lastActivityPayload = frame.payload
|
||||||
const pid = (payload!.args as { pid?: number } | undefined)?.pid
|
const pid = (payload!.args as { pid?: number } | undefined)?.pid
|
||||||
if (typeof pid === 'number') {
|
if (typeof pid === 'number') session.lastActivityPid = pid
|
||||||
for (let i = 1; i < this.claimed.length; i++) {
|
|
||||||
this.lastActivityPid.set(this.claimed[i].index, pid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.isBlacklisted(session)) return
|
||||||
|
|
||||||
for (let i = 1; i < this.claimed.length; i++) {
|
for (let i = 1; i < this.claimed.length; i++) {
|
||||||
const { index, path: mirrorPath } = this.claimed[i]
|
const { index } = this.claimed[i]
|
||||||
|
|
||||||
if (this.disabledMirrors.has(index)) {
|
if (this.disabledMirrors.has(index)) {
|
||||||
this.mirrors.get(index)?.destroy()
|
this.dropSessionMirror(session, index)
|
||||||
this.mirrors.delete(index)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
let mirror = this.mirrors.get(index)
|
this.ensureMirror(session, this.claimed[i])
|
||||||
if (!mirror) {
|
if (frame.op !== OP_HANDSHAKE) session.mirrors.get(index)?.sendActivity(frame.payload)
|
||||||
const newMirror: MirrorConnection = new MirrorConnection(
|
}
|
||||||
mirrorPath,
|
}
|
||||||
handshakePayload,
|
|
||||||
() => {
|
private ensureMirror(session: ClientSession, target: ClaimedSocket): boolean {
|
||||||
if (this.mirrors.get(index) === newMirror) this.mirrors.delete(index)
|
if (session.closed || !session.handshakePayload) return false
|
||||||
}
|
if (session.mirrors.has(target.index)) return false
|
||||||
)
|
|
||||||
mirror = newMirror
|
const mirror: MirrorConnection = new MirrorConnection(
|
||||||
this.mirrors.set(index, mirror)
|
target.path,
|
||||||
if (frame.op === OP_HANDSHAKE) continue // handshake already sent on connect
|
session.handshakePayload,
|
||||||
|
() => {
|
||||||
|
if (session.mirrors.get(target.index) === mirror) {
|
||||||
|
session.mirrors.delete(target.index)
|
||||||
|
this.scheduleMirrorReconnect(session, target.index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
session.mirrors.set(target.index, mirror)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleMirrorReconnect(session: ClientSession, index: number): void {
|
||||||
|
if (session.closed || !this.running) return
|
||||||
|
if (session.reconnectTimers.has(index)) return
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
session.reconnectTimers.delete(index)
|
||||||
|
if (session.closed || !this.running) return
|
||||||
|
if (this.disabledMirrors.has(index) || this.isBlacklisted(session)) return
|
||||||
|
if (!session.lastActivityPayload) return
|
||||||
|
|
||||||
|
const target = this.claimed.find((c, i) => i > 0 && c.index === index)
|
||||||
|
if (!target) return
|
||||||
|
|
||||||
|
if (this.ensureMirror(session, target)) {
|
||||||
|
session.mirrors.get(index)?.sendActivity(session.lastActivityPayload)
|
||||||
|
}
|
||||||
|
}, MIRROR_RECONNECT_DELAY_MS)
|
||||||
|
session.reconnectTimers.set(index, timer)
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearMirrorActivity(index: number): void {
|
||||||
|
for (const session of this.sessions.values()) {
|
||||||
|
const timer = session.reconnectTimers.get(index)
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
session.reconnectTimers.delete(index)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (frame.op !== OP_HANDSHAKE) mirror.sendActivity(frame.payload)
|
const mirror = session.mirrors.get(index)
|
||||||
|
if (!mirror) continue
|
||||||
|
|
||||||
|
if (session.lastActivityPid !== null) {
|
||||||
|
mirror.sendActivityAndClose(clearActivityPayload(session.lastActivityPid))
|
||||||
|
} else {
|
||||||
|
mirror.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
session.mirrors.delete(index)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sends a SET_ACTIVITY frame clearing the presence on a mirror, then disconnects it. */
|
private async recordActivity(session: ClientSession, frame: Frame): Promise<void> {
|
||||||
private clearMirrorActivity(index: number): void {
|
|
||||||
const mirror = this.mirrors.get(index)
|
|
||||||
if (!mirror) return
|
|
||||||
|
|
||||||
const pid = this.lastActivityPid.get(index)
|
|
||||||
if (pid !== undefined) {
|
|
||||||
const clearPayload = Buffer.from(
|
|
||||||
JSON.stringify({
|
|
||||||
cmd: 'SET_ACTIVITY',
|
|
||||||
args: { pid, activity: null },
|
|
||||||
nonce: randomUUID()
|
|
||||||
}),
|
|
||||||
'utf8'
|
|
||||||
)
|
|
||||||
mirror.sendActivityAndClose(clearPayload)
|
|
||||||
} else {
|
|
||||||
mirror.destroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
this.mirrors.delete(index)
|
|
||||||
this.lastActivityPid.delete(index)
|
|
||||||
}
|
|
||||||
|
|
||||||
private async recordActivity(frame: Frame, appId: string | null): Promise<void> {
|
|
||||||
if (frame.op === OP_HANDSHAKE) return
|
if (frame.op === OP_HANDSHAKE) return
|
||||||
|
|
||||||
const data = parseFramePayload(frame)
|
const data = parseFramePayload(frame)
|
||||||
if (data?.cmd !== 'SET_ACTIVITY') return
|
if (data?.cmd !== 'SET_ACTIVITY') return
|
||||||
|
|
||||||
const activity = (data.args as { activity?: Record<string, unknown> })?.activity ?? {}
|
const at = Date.now()
|
||||||
|
const appId = session.appId
|
||||||
|
const activity = (data.args as { activity?: Record<string, unknown> | null })?.activity
|
||||||
|
|
||||||
|
if (!activity) {
|
||||||
|
session.activity = null
|
||||||
|
this.emitStatus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const rawAssets = activity.assets as Record<string, string> | undefined
|
const rawAssets = activity.assets as Record<string, string> | undefined
|
||||||
const assets: ActivityAssets | null = rawAssets
|
const assets: ActivityAssets | null = rawAssets
|
||||||
@@ -494,14 +865,18 @@ export class RpcRelay extends EventEmitter {
|
|||||||
? rawButtons.map((b) => ({ label: b.label, url: b.url }))
|
? rawButtons.map((b) => ({ label: b.label, url: b.url }))
|
||||||
: []
|
: []
|
||||||
|
|
||||||
this.lastActivity = {
|
// Prevent stale network-delayed frames from overwriting newer ones.
|
||||||
|
if (session.closed) return
|
||||||
|
if (session.activity && session.activity.at > at) return
|
||||||
|
|
||||||
|
session.activity = {
|
||||||
app: (activity.name as string) ?? null,
|
app: (activity.name as string) ?? null,
|
||||||
details: (activity.details as string) ?? null,
|
details: (activity.details as string) ?? null,
|
||||||
state: (activity.state as string) ?? null,
|
state: (activity.state as string) ?? null,
|
||||||
assets,
|
assets,
|
||||||
timestamps,
|
timestamps,
|
||||||
buttons,
|
buttons,
|
||||||
at: Date.now()
|
at
|
||||||
}
|
}
|
||||||
this.emitStatus()
|
this.emitStatus()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ const api = {
|
|||||||
ipcRenderer.invoke('relay:set-start-minimized', enabled),
|
ipcRenderer.invoke('relay:set-start-minimized', enabled),
|
||||||
setMirrorEnabled: (index: number, enabled: boolean): Promise<RelayStatus> =>
|
setMirrorEnabled: (index: number, enabled: boolean): Promise<RelayStatus> =>
|
||||||
ipcRenderer.invoke('relay:set-mirror-enabled', index, enabled),
|
ipcRenderer.invoke('relay:set-mirror-enabled', index, enabled),
|
||||||
|
setAppBlacklisted: (appId: string, blacklisted: boolean): Promise<RelayStatus> =>
|
||||||
|
ipcRenderer.invoke('relay:set-app-blacklisted', appId, blacklisted),
|
||||||
|
unlockPrimary: (): Promise<RelayStatus> => ipcRenderer.invoke('relay:unlock-primary'),
|
||||||
|
promoteToPrimary: (index: number): Promise<RelayStatus> =>
|
||||||
|
ipcRenderer.invoke('relay:promote-to-primary', index),
|
||||||
onStatus: (callback: (status: RelayStatus) => void): (() => void) => {
|
onStatus: (callback: (status: RelayStatus) => void): (() => void) => {
|
||||||
const listener = (_e: unknown, status: RelayStatus): void => callback(status)
|
const listener = (_e: unknown, status: RelayStatus): void => callback(status)
|
||||||
ipcRenderer.on('relay:status', listener)
|
ipcRenderer.on('relay:status', listener)
|
||||||
|
|||||||
+203
-34
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { ChevronLeft, Info, Settings } from 'lucide-react'
|
import { ChevronLeft, ChevronRight, Eye, EyeOff, Info, Settings } from 'lucide-react'
|
||||||
import type { RelayStatus } from '../../main/relay'
|
import type { ConnectedClient, LastActivity, RelayStatus } from '../../main/relay'
|
||||||
import { InstanceRow } from './components/InstanceRow'
|
import { InstanceRow } from './components/InstanceRow'
|
||||||
import { Toggle } from './components/Toggle'
|
import { Toggle } from './components/Toggle'
|
||||||
import { Button, LinkButton } from './components/Button'
|
import { Button, LinkButton } from './components/Button'
|
||||||
@@ -11,7 +11,10 @@ const EMPTY_STATUS: RelayStatus = {
|
|||||||
unsupported: false,
|
unsupported: false,
|
||||||
instances: [],
|
instances: [],
|
||||||
connectedClients: [],
|
connectedClients: [],
|
||||||
lastActivity: null,
|
blacklistedApps: [],
|
||||||
|
lockedPrimary: null,
|
||||||
|
reordering: false,
|
||||||
|
primaryOutOfOrder: false,
|
||||||
error: null
|
error: null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,13 +41,7 @@ function formatDuration(ms: number): string {
|
|||||||
return `${minutes}:${String(seconds).padStart(2, '0')}`
|
return `${minutes}:${String(seconds).padStart(2, '0')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function ActivityPreview({
|
function ActivityPreview({ activity }: { activity: LastActivity }): React.JSX.Element | null {
|
||||||
activity
|
|
||||||
}: {
|
|
||||||
activity: RelayStatus['lastActivity']
|
|
||||||
}): React.JSX.Element | null {
|
|
||||||
if (!activity) return null
|
|
||||||
|
|
||||||
const { app, details, state, assets, timestamps, buttons, at } = activity
|
const { app, details, state, assets, timestamps, buttons, at } = activity
|
||||||
const elapsed = timestamps ? formatElapsed(timestamps.start, timestamps.end) : null
|
const elapsed = timestamps ? formatElapsed(timestamps.start, timestamps.end) : null
|
||||||
|
|
||||||
@@ -61,7 +58,7 @@ function ActivityPreview({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-16 h-16 rounded-lg bg-zinc-700 flex items-center justify-center text-zinc-500 text-xs">
|
<div className="w-16 h-16 rounded-lg bg-zinc-700 flex items-center justify-center text-zinc-500 text-xs">
|
||||||
{app ? app.slice(0, 2).toUpperCase() : '—'}
|
{app ? app.slice(0, 2).toUpperCase() : '-'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{assets?.smallImage && (
|
{assets?.smallImage && (
|
||||||
@@ -99,6 +96,137 @@ function ActivityPreview({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ToggleBlacklist = (appId: string, blacklisted: boolean) => void
|
||||||
|
|
||||||
|
function ClientRow({
|
||||||
|
client,
|
||||||
|
onToggleBlacklist
|
||||||
|
}: {
|
||||||
|
client: ConnectedClient
|
||||||
|
onToggleBlacklist: ToggleBlacklist
|
||||||
|
}): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-zinc-300 truncate">
|
||||||
|
{client.process
|
||||||
|
? `${client.process.name} (pid ${client.process.pid})`
|
||||||
|
: 'unknown process'}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="p-1 shrink-0"
|
||||||
|
disabled={!client.appId}
|
||||||
|
onClick={() => client.appId && onToggleBlacklist(client.appId, !client.blacklisted)}
|
||||||
|
aria-label={client.blacklisted ? 'Enable mirroring' : 'Disable mirroring'}
|
||||||
|
title={
|
||||||
|
!client.appId
|
||||||
|
? 'Waiting for handshake'
|
||||||
|
: client.blacklisted
|
||||||
|
? 'Blacklisted - not mirrored. Click to mirror again.'
|
||||||
|
: 'Mirrored. Click to blacklist this app.'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{client.blacklisted ? (
|
||||||
|
<EyeOff className="w-4 h-4 text-red-400" />
|
||||||
|
) : (
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{client.blacklisted && <div className="text-xs text-red-400/90">Blacklisted</div>}
|
||||||
|
{client.activity && (
|
||||||
|
<div
|
||||||
|
className={`rounded-lg bg-zinc-900/60 border p-3 ${
|
||||||
|
client.blacklisted ? 'border-red-900/60 opacity-50' : 'border-zinc-700/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ActivityPreview activity={client.activity} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ClientCarousel({
|
||||||
|
clients,
|
||||||
|
onToggleBlacklist
|
||||||
|
}: {
|
||||||
|
clients: ConnectedClient[]
|
||||||
|
onToggleBlacklist: ToggleBlacklist
|
||||||
|
}): React.JSX.Element {
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [index, setIndex] = useState(0)
|
||||||
|
|
||||||
|
const current = Math.min(index, clients.length - 1)
|
||||||
|
|
||||||
|
const scrollTo = (i: number): void => {
|
||||||
|
scrollRef.current?.scrollTo({ left: i * scrollRef.current.clientWidth, behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const onScroll = (): void => {
|
||||||
|
const el = scrollRef.current
|
||||||
|
if (el) setIndex(Math.round(el.scrollLeft / el.clientWidth))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
onScroll={onScroll}
|
||||||
|
className="flex overflow-x-auto snap-x snap-mandatory [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||||
|
>
|
||||||
|
{clients.map((client) => (
|
||||||
|
<div key={client.id} className="w-full shrink-0 snap-center">
|
||||||
|
<ClientRow client={client} onToggleBlacklist={onToggleBlacklist} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{clients.length > 1 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="p-1"
|
||||||
|
onClick={() => scrollTo(current - 1)}
|
||||||
|
disabled={current === 0}
|
||||||
|
aria-label="Previous client"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{clients.map((client, i) => (
|
||||||
|
<button
|
||||||
|
key={client.id}
|
||||||
|
onClick={() => scrollTo(i)}
|
||||||
|
aria-label={`Show client ${i + 1}`}
|
||||||
|
className={`w-1.5 h-1.5 rounded-full transition-colors ${
|
||||||
|
client.blacklisted
|
||||||
|
? i === current
|
||||||
|
? 'bg-red-400'
|
||||||
|
: 'bg-red-900 hover:bg-red-700'
|
||||||
|
: i === current
|
||||||
|
? 'bg-zinc-300'
|
||||||
|
: 'bg-zinc-600 hover:bg-zinc-500'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="p-1"
|
||||||
|
onClick={() => scrollTo(current + 1)}
|
||||||
|
disabled={current === clients.length - 1}
|
||||||
|
aria-label="Next client"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function App(): React.JSX.Element {
|
export function App(): React.JSX.Element {
|
||||||
const [status, setStatus] = useState<RelayStatus>(EMPTY_STATUS)
|
const [status, setStatus] = useState<RelayStatus>(EMPTY_STATUS)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@@ -137,6 +265,18 @@ export function App(): React.JSX.Element {
|
|||||||
setStatus(await window.api.setMirrorEnabled(index, enabled))
|
setStatus(await window.api.setMirrorEnabled(index, enabled))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onToggleBlacklist = async (appId: string, blacklisted: boolean): Promise<void> => {
|
||||||
|
setStatus(await window.api.setAppBlacklisted(appId, blacklisted))
|
||||||
|
}
|
||||||
|
|
||||||
|
const onUnlockPrimary = async (): Promise<void> => {
|
||||||
|
setStatus(await window.api.unlockPrimary())
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPromote = async (index: number): Promise<void> => {
|
||||||
|
setStatus(await window.api.promoteToPrimary(index))
|
||||||
|
}
|
||||||
|
|
||||||
const onToggleAutostart = async (enabled: boolean): Promise<void> => {
|
const onToggleAutostart = async (enabled: boolean): Promise<void> => {
|
||||||
setAutostart(await window.api.setAutostart(enabled))
|
setAutostart(await window.api.setAutostart(enabled))
|
||||||
}
|
}
|
||||||
@@ -148,7 +288,7 @@ export function App(): React.JSX.Element {
|
|||||||
if (status.unsupported) {
|
if (status.unsupported) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-screen items-center justify-center p-5 gap-3 text-center">
|
<div className="flex flex-col h-screen items-center justify-center p-5 gap-3 text-center">
|
||||||
<h1 className="text-lg font-semibold">Discord RPC Relay</h1>
|
<h1 className="text-lg font-semibold">Discord Presence Relay</h1>
|
||||||
<p className="text-sm text-zinc-400">Windows is not supported.</p>
|
<p className="text-sm text-zinc-400">Windows is not supported.</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -196,6 +336,33 @@ export function App(): React.JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-xl bg-zinc-800/60 border border-zinc-700 p-4 flex flex-col gap-2">
|
||||||
|
<h2 className="text-sm text-zinc-400 mb-1">Blacklisted apps</h2>
|
||||||
|
<div className="flex flex-col gap-1.5 text-sm">
|
||||||
|
{status.blacklistedApps.length > 0 ? (
|
||||||
|
status.blacklistedApps.map((app) => (
|
||||||
|
<div key={app.id} className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex flex-col min-w-0">
|
||||||
|
<span className="text-zinc-300 truncate">{app.name ?? 'Unknown app'}</span>
|
||||||
|
<span className="text-xs text-zinc-500 truncate">{app.id}</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="p-1 shrink-0"
|
||||||
|
onClick={() => onToggleBlacklist(app.id, false)}
|
||||||
|
aria-label={`Remove ${app.name ?? app.id} from blacklist`}
|
||||||
|
title="Remove from blacklist and mirror again"
|
||||||
|
>
|
||||||
|
<EyeOff className="w-4 h-4 text-red-400" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="text-zinc-500">No blacklisted apps</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{appVersion && (
|
{appVersion && (
|
||||||
<p className="text-xs text-zinc-500 mt-auto">
|
<p className="text-xs text-zinc-500 mt-auto">
|
||||||
Version {appVersion.version} ({appVersion.commit})
|
Version {appVersion.version} ({appVersion.commit})
|
||||||
@@ -208,7 +375,7 @@ export function App(): React.JSX.Element {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-screen p-5 gap-4">
|
<div className="flex flex-col h-screen p-5 gap-4">
|
||||||
<header className="flex items-center justify-between gap-3">
|
<header className="flex items-center justify-between gap-3">
|
||||||
<h1 className="text-lg font-semibold">Discord RPC Relay</h1>
|
<h1 className="text-lg font-semibold">Discord Presence Relay</h1>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => setView('settings')}
|
onClick={() => setView('settings')}
|
||||||
@@ -255,43 +422,45 @@ export function App(): React.JSX.Element {
|
|||||||
<InstanceRow
|
<InstanceRow
|
||||||
key={instance.index}
|
key={instance.index}
|
||||||
instance={instance}
|
instance={instance}
|
||||||
|
reordering={status.reordering}
|
||||||
onToggleMirror={onToggleMirror}
|
onToggleMirror={onToggleMirror}
|
||||||
|
onUnlock={onUnlockPrimary}
|
||||||
|
onPromote={onPromote}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="text-zinc-500">No Discord instances detected</div>
|
<div className="text-zinc-500">No Discord instances detected</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{status.reordering && (
|
||||||
|
<div className="mt-1 text-xs text-amber-300/90">
|
||||||
|
Restarting Discord clients to promote the new primary…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-xl bg-zinc-800/60 border border-zinc-700 p-4 flex flex-col gap-2">
|
<section className="rounded-xl bg-zinc-800/60 border border-zinc-700 p-4 flex flex-col gap-2">
|
||||||
<h2 className="text-sm text-zinc-400 mb-1">Connected RPC Clients</h2>
|
<div className="flex items-center justify-between mb-1">
|
||||||
<div className="flex flex-col gap-1.5 text-sm">
|
<h2 className="text-sm text-zinc-400">Connected RPC Clients</h2>
|
||||||
|
{status.connectedClients.length > 0 && (
|
||||||
|
<span className="text-xs text-zinc-500 bg-zinc-700/60 rounded-full px-2 py-0.5">
|
||||||
|
{status.connectedClients.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">
|
||||||
{status.connectedClients.length > 0 ? (
|
{status.connectedClients.length > 0 ? (
|
||||||
status.connectedClients.map((client) => (
|
<ClientCarousel
|
||||||
<div key={client.id} className="flex items-center justify-between gap-2">
|
clients={status.connectedClients}
|
||||||
<span className="text-zinc-300">
|
onToggleBlacklist={onToggleBlacklist}
|
||||||
{client.process
|
/>
|
||||||
? `${client.process.name} (pid ${client.process.pid})`
|
|
||||||
: 'unknown process'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
) : (
|
||||||
<div className="text-zinc-500">No clients connected</div>
|
<div className="text-zinc-500">No clients connected</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-xl bg-zinc-800/60 border border-zinc-700 p-4 flex flex-col gap-2">
|
|
||||||
<h2 className="text-sm text-zinc-400 mb-1">Last Mirrored Activity</h2>
|
|
||||||
{status.lastActivity ? (
|
|
||||||
<ActivityPreview activity={status.lastActivity} />
|
|
||||||
) : (
|
|
||||||
<div className="text-sm text-zinc-300">No activity yet</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<p className="text-xs text-zinc-500 mt-auto">
|
<p className="text-xs text-zinc-500 mt-auto">
|
||||||
Apps using Discord Rich Presence need to be restarted after toggling the relay to pick up
|
Apps using Discord Rich Presence need to be restarted after toggling the relay to pick up
|
||||||
the change.
|
the change.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Info } from 'lucide-react'
|
import { ArrowUpToLine, Lock, LockOpen } from 'lucide-react'
|
||||||
import type { RelayInstance } from '../../../main/relay'
|
import type { RelayInstance } from '../../../main/relay'
|
||||||
import { Toggle } from './Toggle'
|
import { Toggle } from './Toggle'
|
||||||
|
import { Button } from './Button'
|
||||||
|
|
||||||
function shortPath(p: string): string {
|
function shortPath(p: string): string {
|
||||||
return p.split('/').pop() ?? p
|
return p.split('/').pop() ?? p
|
||||||
@@ -8,14 +9,71 @@ function shortPath(p: string): string {
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
instance: RelayInstance
|
instance: RelayInstance
|
||||||
|
reordering: boolean
|
||||||
onToggleMirror: (index: number, enabled: boolean) => Promise<void>
|
onToggleMirror: (index: number, enabled: boolean) => Promise<void>
|
||||||
|
onUnlock: () => Promise<void>
|
||||||
|
onPromote: (index: number) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InstanceRow({ instance, onToggleMirror }: Props): React.JSX.Element {
|
export function InstanceRow({
|
||||||
|
instance,
|
||||||
|
reordering,
|
||||||
|
onToggleMirror,
|
||||||
|
onUnlock,
|
||||||
|
onPromote
|
||||||
|
}: Props): React.JSX.Element {
|
||||||
const procLabel = instance.process
|
const procLabel = instance.process
|
||||||
? `${instance.process.name} (pid ${instance.process.pid})`
|
? `${instance.process.name} (pid ${instance.process.pid})`
|
||||||
: 'unknown process'
|
: 'unknown process'
|
||||||
|
|
||||||
|
const canPromote = instance.process?.executable != null
|
||||||
|
|
||||||
|
const promoteButton = (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="p-1 shrink-0"
|
||||||
|
disabled={!canPromote || reordering}
|
||||||
|
onClick={() => onPromote(instance.index)}
|
||||||
|
aria-label="Promote to primary"
|
||||||
|
title={
|
||||||
|
!canPromote
|
||||||
|
? 'Cannot resolve this Discord’s 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 Discord’s executable path'
|
||||||
|
: 'Lock this Discord as primary so it stays first even after restarts.'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LockOpen className="w-4 h-4 text-zinc-400 hover:text-zinc-100 transition-colors" />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
|
||||||
const info = (
|
const info = (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -29,6 +87,7 @@ export function InstanceRow({ instance, onToggleMirror }: Props): React.JSX.Elem
|
|||||||
mirror #{instance.index}
|
mirror #{instance.index}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{instance.locked && <span className="text-xs text-amber-400">locked</span>}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-zinc-500">{procLabel}</span>
|
<span className="text-xs text-zinc-500">{procLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -38,14 +97,9 @@ export function InstanceRow({ instance, onToggleMirror }: Props): React.JSX.Elem
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
{info}
|
{info}
|
||||||
<Info className="w-4 h-4 text-zinc-400 hover:text-zinc-100 cursor-help transition-colors">
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
<title>
|
{instance.locked ? unlockButton : lockButton}
|
||||||
The primary instance gets full passthrough and can't be turned off. It's
|
</div>
|
||||||
always the Discord instance that started first (discord-ipc-0). To make a different
|
|
||||||
instance primary, close this one first so the other claims that slot, then restart the
|
|
||||||
relay.
|
|
||||||
</title>
|
|
||||||
</Info>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -53,10 +107,13 @@ export function InstanceRow({ instance, onToggleMirror }: Props): React.JSX.Elem
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
{info}
|
{info}
|
||||||
<Toggle
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
checked={instance.enabled}
|
{promoteButton}
|
||||||
onChange={(checked) => onToggleMirror(instance.index, checked)}
|
<Toggle
|
||||||
/>
|
checked={instance.enabled}
|
||||||
|
onChange={(checked) => onToggleMirror(instance.index, checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user