feat: Per-client sessions, app blacklist, robustness rework

This commit is contained in:
2026-07-12 14:28:46 +07:00
parent 4674142f52
commit 7ea3354bf6
11 changed files with 688 additions and 230 deletions
+1
View File
@@ -7,6 +7,7 @@ onlyBuiltDependencies:
minimumReleaseAge: 4320
allowBuilds:
electron: true
electron-winstaller: true
esbuild: true
koffi: true
+52 -7
View File
@@ -3,7 +3,7 @@ import { join } from 'path'
import * as fs from 'fs'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset'
import { relay, RelayStatus } from './relay'
import { relay, BlacklistedApp, RelayStatus } from './relay'
declare const __COMMIT_HASH__: string
@@ -13,24 +13,38 @@ function settingsPath(): string {
interface Settings {
disabledMirrors: number[]
blacklistedApps: BlacklistedApp[]
startMinimized: boolean
}
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 {
try {
const raw = fs.readFileSync(settingsPath(), 'utf8')
const data = JSON.parse(raw)
return {
disabledMirrors: Array.isArray(data?.disabledMirrors) ? data.disabledMirrors : [],
blacklistedApps: parseBlacklistedApps(data?.blacklistedApps),
startMinimized: data?.startMinimized === true
}
} catch {
return { disabledMirrors: [], startMinimized: false }
return { disabledMirrors: [], blacklistedApps: [], startMinimized: false }
}
}
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(
@@ -202,6 +216,7 @@ app.whenReady().then(() => {
const settings = loadSettings()
relay.setDisabledMirrors(settings.disabledMirrors)
relay.setBlacklistedApps(settings.blacklistedApps)
ipcMain.handle('relay:get-version', () => ({
version: app.getVersion(),
@@ -244,6 +259,12 @@ app.whenReady().then(() => {
saveSettings({ ...current, disabledMirrors: relay.getDisabledMirrors() })
return status
})
ipcMain.handle('relay:set-app-blacklisted', (_e, appId: string, blacklisted: boolean) => {
const status = relay.setAppBlacklisted(appId, blacklisted)
const current = loadSettings()
saveSettings({ ...current, blacklistedApps: relay.getBlacklistedApps() })
return status
})
relay.on('status', (status: RelayStatus) => {
updateTrayMenu(status)
@@ -260,16 +281,40 @@ app.whenReady().then(() => {
})
})
app.on('window-all-closed', () => {
// Keep running in the tray
})
app.on('window-all-closed', () => {})
let stopping = false
const QUIT_TIMEOUT_MS = 5000
app.on('before-quit', (e) => {
quitting = true
if (stopping) return
stopping = true
e.preventDefault()
relay.stop().finally(() => app.exit(0))
const timeout = new Promise<void>((resolve) => setTimeout(resolve, QUIT_TIMEOUT_MS))
Promise.race([relay.stop(), timeout])
.catch(() => {})
.finally(() => {
relay.emergencyRestoreSync()
app.exit(0)
})
})
// Electron doesn't reliably turn SIGTERM/SIGHUP into a quit on Linux.
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const) {
process.on(signal, () => {
quitting = true
app.quit()
})
}
process.on('uncaughtException', (err) => {
console.error('Uncaught exception:', err)
relay.emergencyRestoreSync()
app.exit(1)
})
process.on('unhandledRejection', (reason) => {
console.error('Unhandled rejection:', reason)
})
-2
View File
@@ -10,7 +10,6 @@ export interface Frame {
payload: Buffer
}
/** Buffers stream chunks and yields complete IPC frames as they arrive. */
export class FrameReader {
private buf = Buffer.alloc(0)
@@ -39,7 +38,6 @@ export function encodeFrame(op: number, payload: Buffer): Buffer {
return Buffer.concat([header, payload])
}
/** Parses a non-handshake frame's JSON payload, returning null if it isn't JSON. */
export function parseFramePayload(frame: Frame): Record<string, unknown> | null {
try {
return JSON.parse(frame.payload.toString('utf8'))
+10 -9
View File
@@ -1,10 +1,7 @@
import * as net from 'net'
import { encodeFrame, FrameReader, OP_FRAME, OP_HANDSHAKE } from './ipc-protocol'
/**
* A lazily-established connection to a secondary Discord instance that
* mirrors the primary connection's handshake and SET_ACTIVITY frames.
*/
const READY_TIMEOUT_MS = 10_000
export class MirrorConnection {
private readonly sock: net.Socket
@@ -16,23 +13,28 @@ export class MirrorConnection {
constructor(socketPath: string, handshakePayload: Buffer, onClose: () => void) {
this.sock = net.createConnection(socketPath)
const readyTimeout = setTimeout(() => this.sock.destroy(), READY_TIMEOUT_MS)
readyTimeout.unref()
this.sock.on('connect', () => {
this.sock.write(encodeFrame(OP_HANDSHAKE, handshakePayload))
})
// Discord sends a READY dispatch after the handshake; only once that
// arrives will it accept further commands like SET_ACTIVITY.
this.sock.on('data', (chunk: Buffer) => {
const hadFrames = this.reader.push(chunk).length > 0
if (hadFrames && !this.ready) {
this.ready = true
clearTimeout(readyTimeout)
for (const frame of this.pending.splice(0)) this.sock.write(frame)
if (this.closeAfterPending) this.sock.end()
}
})
this.sock.on('error', onClose)
this.sock.on('close', onClose)
this.sock.on('error', () => {})
this.sock.on('close', () => {
clearTimeout(readyTimeout)
onClose()
})
}
sendActivity(payload: Buffer): void {
@@ -44,7 +46,6 @@ export class MirrorConnection {
}
}
/** Sends a final frame (after the handshake) and closes the connection once it has been flushed. */
sendActivityAndClose(payload: Buffer): void {
const frame = encodeFrame(OP_FRAME, payload)
if (this.ready) {
+1
View File
@@ -2,6 +2,7 @@ import { PosixPlatform } from './posix'
import { UnsupportedPlatform } from './unsupported'
import type { RelayPlatform } from './types'
export { MAX_SOCKETS } from './types'
export type { ClaimedSocket, ProcessInfo, RelayPlatform } from './types'
export const platform: RelayPlatform =
+62 -23
View File
@@ -2,11 +2,12 @@ import * as fs from 'fs'
import * as net from 'net'
import * as path from 'path'
import { execFileSync } from 'child_process'
import type { ClaimedSocket, ProcessInfo, RelayPlatform } from './types'
import { MAX_SOCKETS, type ClaimedSocket, type ProcessInfo, type RelayPlatform } from './types'
const REAL_PREFIX = 'discord-ipc-real-'
const FAKE_INDEX = 0
const MAX_SOCKETS = 10
const ALIVE_CHECK_TIMEOUT_MS = 1000
const STALE_SOCKET_MIN_AGE_MS = 10_000
function runtimeDir(): string {
if (process.env.XDG_RUNTIME_DIR) return process.env.XDG_RUNTIME_DIR
@@ -43,47 +44,80 @@ export class PosixPlatform implements RelayPlatform {
async recoverLeftoverSockets(): Promise<void> {
for (let i = 0; i < MAX_SOCKETS; i++) {
const leftover = claimedPath(i)
const original = ipcPath(i)
if (!fs.existsSync(leftover)) continue
try {
const leftover = claimedPath(i)
const original = ipcPath(i)
if (!fs.existsSync(leftover)) continue
if (!fs.existsSync(original)) {
fs.renameSync(leftover, original)
continue
}
if (!fs.existsSync(original)) {
fs.renameSync(leftover, original)
continue
}
if (!(await isSocketAlive(original))) {
fs.unlinkSync(original)
fs.renameSync(leftover, original)
if (!(await isSocketAlive(original))) {
fs.unlinkSync(original)
fs.renameSync(leftover, original)
}
} catch {
// Another process may be touching the same files; keep recovering the rest.
}
}
}
discoverAndClaim(): ClaimedSocket[] {
async discoverAndClaim(): Promise<ClaimedSocket[]> {
const found: ClaimedSocket[] = []
for (let i = 0; i < MAX_SOCKETS; i++) {
const claimed = this.discoverNewSocket(i)
const claimed = await this.discoverNewSocket(i)
if (claimed) found.push(claimed)
}
return found
}
discoverNewSocket(index: number): ClaimedSocket | null {
async discoverNewSocket(index: number): Promise<ClaimedSocket | null> {
const src = ipcPath(index)
if (!fs.existsSync(src)) return null
if (!(await isSocketAlive(src))) {
try {
if (Date.now() - fs.statSync(src).mtimeMs > STALE_SOCKET_MIN_AGE_MS) fs.unlinkSync(src)
} catch {
// Already gone.
}
return null
}
const dst = claimedPath(index)
fs.renameSync(src, dst)
try {
fs.renameSync(src, dst)
} catch {
return null // socket replaced between the check and the rename
}
return { index, path: dst }
}
restoreSocket(claimed: ClaimedSocket): void {
const original = ipcPath(claimed.index)
if (fs.existsSync(claimed.path) && !fs.existsSync(original)) {
fs.renameSync(claimed.path, original)
try {
const original = ipcPath(claimed.index)
if (fs.existsSync(claimed.path) && !fs.existsSync(original)) {
fs.renameSync(claimed.path, original)
}
} catch {
// Best effort.
}
}
discardClaimedSocket(claimed: ClaimedSocket): void {
try {
fs.unlinkSync(claimed.path)
} catch {
// Already gone.
}
}
isSocketAlive(socketPath: string): Promise<boolean> {
return isSocketAlive(socketPath)
}
getInstanceProcess(index: number): ProcessInfo | null {
// /proc/net/unix reports the original bind path even after the file is renamed on disk
return findSocketOwner(ipcPath(index))
@@ -97,11 +131,16 @@ export class PosixPlatform implements RelayPlatform {
function isSocketAlive(socketPath: string): Promise<boolean> {
return new Promise((resolve) => {
const sock = net.createConnection(socketPath)
sock.once('connect', () => {
let settled = false
const done = (alive: boolean): void => {
if (settled) return
settled = true
sock.destroy()
resolve(true)
})
sock.once('error', () => resolve(false))
resolve(alive)
}
sock.setTimeout(ALIVE_CHECK_TIMEOUT_MS, () => done(false))
sock.once('connect', () => done(true))
sock.once('error', () => done(false))
})
}
+9 -14
View File
@@ -1,3 +1,5 @@
export const MAX_SOCKETS = 10
export interface ProcessInfo {
pid: number
name: string
@@ -8,13 +10,6 @@ export interface ClaimedSocket {
path: string
}
/**
* Platform-specific primitives the relay needs to take over Discord's IPC
* sockets and identify the processes on either end of a connection.
*
* Implement this interface to add support for a new OS; `RpcRelay` itself
* contains no platform-specific code.
*/
export interface RelayPlatform {
readonly isSupported: boolean
@@ -24,21 +19,21 @@ export interface RelayPlatform {
finalizeFakeSocket(fakePath: string): void
/** Checks whether the fake socket file still exists on disk (a restarted Discord client can unlink and rebind it out from under us). */
/** Checks whether Discord has unlinked our fake socket. */
fakeSocketExists(fakePath: string): boolean
/**
* Restores sockets left claimed from a previous run that crashed before
* it could call restoreSocket(), so a fresh start() can discover them.
*/
recoverLeftoverSockets(): Promise<void>
discoverAndClaim(): ClaimedSocket[]
discoverAndClaim(): Promise<ClaimedSocket[]>
discoverNewSocket(index: number): ClaimedSocket | null
discoverNewSocket(index: number): Promise<ClaimedSocket | null>
restoreSocket(claimed: ClaimedSocket): void
discardClaimedSocket(claimed: ClaimedSocket): void
isSocketAlive(socketPath: string): Promise<boolean>
getInstanceProcess(index: number): ProcessInfo | null
getPeerProcess(fd: number): ProcessInfo | null
+10 -5
View File
@@ -2,9 +2,6 @@ import type { ClaimedSocket, ProcessInfo, RelayPlatform } from './types'
const NOT_SUPPORTED = new Error('Platform not supported')
/**
* Placeholder for platforms without a real implementation yet.
*/
export class UnsupportedPlatform implements RelayPlatform {
readonly isSupported = false
@@ -28,11 +25,11 @@ export class UnsupportedPlatform implements RelayPlatform {
throw NOT_SUPPORTED
}
discoverAndClaim(): ClaimedSocket[] {
async discoverAndClaim(): Promise<ClaimedSocket[]> {
throw NOT_SUPPORTED
}
discoverNewSocket(): ClaimedSocket | null {
async discoverNewSocket(): Promise<ClaimedSocket | null> {
throw NOT_SUPPORTED
}
@@ -40,6 +37,14 @@ export class UnsupportedPlatform implements RelayPlatform {
throw NOT_SUPPORTED
}
discardClaimedSocket(): void {
throw NOT_SUPPORTED
}
async isSocketAlive(): Promise<boolean> {
return false
}
getInstanceProcess(): ProcessInfo | null {
return null
}
+360 -138
View File
@@ -3,11 +3,13 @@ import { EventEmitter } from 'events'
import * as net from 'net'
import { encodeFrame, Frame, FrameReader, OP_HANDSHAKE, parseFramePayload } from './ipc-protocol'
import { MirrorConnection } from './mirror-connection'
import { platform, type ClaimedSocket, type ProcessInfo } from './platform'
import { platform, MAX_SOCKETS, type ClaimedSocket, type ProcessInfo } from './platform'
const MAX_SOCKETS = 10
const DISCOVERY_INTERVAL_MS = 3000
const WAITING_RETRY_INTERVAL_MS = 3000
const MIRROR_RECONNECT_DELAY_MS = 2000
const SERVER_CLOSE_TIMEOUT_MS = 2000
const ASSET_FETCH_RETRY_MS = 60_000
interface AppAsset {
id: string
@@ -15,27 +17,42 @@ interface AppAsset {
name: string
}
const appAssetCache = new Map<string, Map<string, string>>()
interface AssetCacheEntry {
map: Map<string, string>
expires: number
}
const appAssetCache = new Map<string, AssetCacheEntry>()
async function getAppAssetMap(appId: string): Promise<Map<string, string>> {
const cached = appAssetCache.get(appId)
if (cached) return cached
if (cached && Date.now() < cached.expires) return cached.map
const map = new Map<string, string>()
let expires = Number.POSITIVE_INFINITY
try {
const res = await fetch(`https://discord.com/api/v9/oauth2/applications/${appId}/assets`)
if (res.ok) {
const assets = (await res.json()) as AppAsset[]
for (const asset of assets) map.set(asset.name, asset.id)
} else {
expires = Date.now() + ASSET_FETCH_RETRY_MS
}
} catch {
// Network error: cache an empty map so we don't retry every frame.
expires = Date.now() + ASSET_FETCH_RETRY_MS
}
appAssetCache.set(appId, map)
appAssetCache.set(appId, { map, expires })
return map
}
function clearActivityPayload(pid: number): Buffer {
return Buffer.from(
JSON.stringify({ cmd: 'SET_ACTIVITY', args: { pid, activity: null }, nonce: randomUUID() }),
'utf8'
)
}
async function resolveAssetImage(
key: string | undefined,
appId: string | null
@@ -68,9 +85,17 @@ export interface RelayInstance {
process: ProcessInfo | null
}
export interface BlacklistedApp {
id: string
name: string | null
}
export interface ConnectedClient {
id: number
process: ProcessInfo | null
appId: string | null
blacklisted: boolean
activity: LastActivity | null
}
export interface ActivityAssets {
@@ -106,30 +131,41 @@ export interface RelayStatus {
unsupported: boolean
instances: RelayInstance[]
connectedClients: ConnectedClient[]
lastActivity: LastActivity | null
blacklistedApps: BlacklistedApp[]
error: string | null
}
/**
* Takes over Discord's primary IPC socket (discord-ipc-0), passing every
* frame through to the real primary instance unchanged, while mirroring the
* handshake and SET_ACTIVITY frames to any other running Discord instances.
*/
interface ClientSession {
id: number
client: net.Socket
primary: net.Socket
process: ProcessInfo | null
handshakePayload: Buffer | null
appId: string | null
lastActivityPayload: Buffer | null
lastActivityPid: number | null
activity: LastActivity | null
mirrors: Map<number, MirrorConnection>
reconnectTimers: Map<number, NodeJS.Timeout>
closed: boolean
}
/** Hijacks discord-ipc-0, passthrough to primary, mirroring frames to other instances. */
export class RpcRelay extends EventEmitter {
private server: net.Server | null = null
private claimed: ClaimedSocket[] = []
private disabledMirrors = new Set<number>()
private connectedClients = new Map<number, ConnectedClient>()
private blacklistedApps = new Map<string, string | null>()
private sessions = new Map<number, ClientSession>()
private nextClientId = 1
private discoveryTimer: NodeJS.Timeout | null = null
private discovering = false
private waitingTimer: NodeJS.Timeout | null = null
private lastActivity: LastActivity | null = null
private running = false
private waiting = false
private fakeOwned = false
private lastError: string | null = null
private restarting = false
private mirrors = new Map<number, MirrorConnection>()
private lastActivityPid = new Map<number, number>()
getStatus(): RelayStatus {
const instances: RelayInstance[] = this.claimed.map(({ index, path: socketPath }, i) => ({
@@ -145,8 +181,14 @@ export class RpcRelay extends EventEmitter {
waiting: this.waiting,
unsupported: !platform.isSupported,
instances,
connectedClients: [...this.connectedClients.values()],
lastActivity: this.lastActivity,
connectedClients: [...this.sessions.values()].map((s) => ({
id: s.id,
process: s.process,
appId: s.appId,
blacklisted: this.isBlacklisted(s),
activity: s.activity
})),
blacklistedApps: this.getBlacklistedApps(),
error: this.lastError
}
}
@@ -172,6 +214,60 @@ export class RpcRelay extends EventEmitter {
this.disabledMirrors = new Set(indices)
}
getBlacklistedApps(): BlacklistedApp[] {
return [...this.blacklistedApps].map(([id, name]) => ({ id, name }))
}
setBlacklistedApps(apps: BlacklistedApp[]): void {
this.blacklistedApps = new Map(apps.map((a) => [a.id, a.name]))
}
setAppBlacklisted(appId: string, blacklisted: boolean): RelayStatus {
if (blacklisted) {
const session = [...this.sessions.values()].find((s) => s.appId === appId)
const name = session?.activity?.app ?? session?.process?.name ?? null
this.blacklistedApps.set(appId, name ?? this.blacklistedApps.get(appId) ?? null)
} else {
this.blacklistedApps.delete(appId)
}
for (const session of this.sessions.values()) {
if (session.appId !== appId) continue
if (blacklisted) {
this.clearSessionMirrors(session)
} else if (session.lastActivityPayload) {
for (let i = 1; i < this.claimed.length; i++) {
const target = this.claimed[i]
if (this.disabledMirrors.has(target.index)) continue
if (this.ensureMirror(session, target)) {
session.mirrors.get(target.index)?.sendActivity(session.lastActivityPayload)
}
}
}
}
this.emitStatus()
return this.getStatus()
}
private isBlacklisted(session: ClientSession): boolean {
return session.appId !== null && this.blacklistedApps.has(session.appId)
}
private clearSessionMirrors(session: ClientSession): void {
for (const timer of session.reconnectTimers.values()) clearTimeout(timer)
session.reconnectTimers.clear()
for (const mirror of session.mirrors.values()) {
if (session.lastActivityPid !== null) {
mirror.sendActivityAndClose(clearActivityPayload(session.lastActivityPid))
} else {
mirror.destroy()
}
}
session.mirrors.clear()
}
async start(): Promise<void> {
if (this.running) return
this.lastError = null
@@ -183,34 +279,46 @@ export class RpcRelay extends EventEmitter {
}
await platform.recoverLeftoverSockets()
this.claimed = platform.discoverAndClaim()
const claimed = await platform.discoverAndClaim()
if (this.claimed.length === 0) {
if (claimed.length === 0) {
this.waitForDiscord()
return
}
this.claimed = claimed
this.waiting = false
this.stopWaitingTimer()
const fake = platform.fakeSocketPath()
platform.removeFakeSocket(fake)
try {
const fake = platform.fakeSocketPath()
platform.removeFakeSocket(fake)
this.server = net.createServer((sock) => this.handleClient(sock))
this.server.on('error', (err) => {
this.lastError = err.message
this.emitStatus()
})
await new Promise<void>((resolve, reject) => {
this.server!.once('error', reject)
this.server!.listen(fake, () => {
this.server!.removeListener('error', reject)
resolve()
this.server = net.createServer((sock) => this.handleClient(sock))
this.server.on('error', (err) => {
this.lastError = err.message
this.emitStatus()
})
})
platform.finalizeFakeSocket(fake)
await new Promise<void>((resolve, reject) => {
this.server!.once('error', reject)
this.server!.listen(fake, () => {
this.server!.removeListener('error', reject)
resolve()
})
})
platform.finalizeFakeSocket(fake)
this.fakeOwned = true
} catch (err) {
this.server?.close()
this.server = null
for (const c of this.claimed) platform.restoreSocket(c)
this.claimed = []
this.lastError = err instanceof Error ? err.message : String(err)
this.emitStatus()
throw err
}
this.running = true
this.startDiscoveryTimer()
@@ -228,23 +336,41 @@ export class RpcRelay extends EventEmitter {
this.running = false
this.stopDiscoveryTimer()
for (const session of [...this.sessions.values()]) this.destroySession(session)
if (this.server) {
await new Promise<void>((resolve) => this.server!.close(() => resolve()))
const server = this.server
this.server = null
await new Promise<void>((resolve) => {
const timeout = setTimeout(resolve, SERVER_CLOSE_TIMEOUT_MS)
server.close(() => {
clearTimeout(timeout)
resolve()
})
})
}
platform.removeFakeSocket(platform.fakeSocketPath())
this.fakeOwned = false
for (const claimed of this.claimed) platform.restoreSocket(claimed)
for (const mirror of this.mirrors.values()) mirror.destroy()
this.mirrors.clear()
this.lastActivityPid.clear()
this.claimed = []
this.connectedClients.clear()
this.emitStatus()
}
emergencyRestoreSync(): void {
try {
if (this.fakeOwned) {
platform.removeFakeSocket(platform.fakeSocketPath())
this.fakeOwned = false
}
} catch {
// Best effort.
}
for (const claimed of this.claimed) platform.restoreSocket(claimed)
this.claimed = []
}
private primaryIndex(): number | undefined {
return this.claimed[0]?.index
}
@@ -273,7 +399,7 @@ export class RpcRelay extends EventEmitter {
private startDiscoveryTimer(): void {
this.stopDiscoveryTimer()
this.discoveryTimer = setInterval(() => this.discoverNewInstances(), DISCOVERY_INTERVAL_MS)
this.discoveryTimer = setInterval(() => void this.discoverInstances(), DISCOVERY_INTERVAL_MS)
}
private stopDiscoveryTimer(): void {
@@ -283,66 +409,104 @@ export class RpcRelay extends EventEmitter {
}
}
/** Picks up Discord instances launched after the relay started, and recovers from a stolen primary socket. */
private discoverNewInstances(): void {
if (!this.running) return
/** Periodic: recover stolen primary, prune dead instances, discover new ones. */
private async discoverInstances(): Promise<void> {
if (!this.running || this.discovering) return
this.discovering = true
try {
if (!platform.fakeSocketExists(platform.fakeSocketPath())) {
void this.restart()
return
}
if (!platform.fakeSocketExists(platform.fakeSocketPath())) {
void this.restart()
return
}
let changed = false
const claimedIndices = new Set(this.claimed.map((c) => c.index))
let changed = false
for (const claimed of [...this.claimed]) {
if (await platform.isSocketAlive(claimed.path)) continue
if (!this.running) return
for (let i = 1; i < MAX_SOCKETS; i++) {
if (claimedIndices.has(i)) continue
const found = platform.discoverNewSocket(i)
if (found) {
this.claimed.push(found)
if (claimed === this.claimed[0]) {
void this.restart()
return
}
this.claimed = this.claimed.filter((c) => c !== claimed)
platform.discardClaimedSocket(claimed)
for (const session of this.sessions.values()) {
this.dropSessionMirror(session, claimed.index)
}
changed = true
}
}
if (changed) this.emitStatus()
const claimedIndices = new Set(this.claimed.map((c) => c.index))
for (let i = 1; i < MAX_SOCKETS; i++) {
if (claimedIndices.has(i)) continue
const found = await platform.discoverNewSocket(i)
if (!found) continue
if (!this.running) {
platform.restoreSocket(found)
return
}
this.claimed.push(found)
changed = true
if (!this.disabledMirrors.has(found.index)) {
for (const session of this.sessions.values()) {
if (this.isBlacklisted(session)) continue
if (this.ensureMirror(session, found) && session.lastActivityPayload) {
session.mirrors.get(found.index)?.sendActivity(session.lastActivityPayload)
}
}
}
}
if (changed) this.emitStatus()
} catch (err) {
this.lastError = err instanceof Error ? err.message : String(err)
this.emitStatus()
} finally {
this.discovering = false
}
}
private handleClient(client: net.Socket): void {
const clientId = this.nextClientId++
const primaryClaimed = this.claimed[0]
if (!primaryClaimed) {
client.destroy()
return
}
const fd = (client as unknown as { _handle?: { fd?: number } })._handle?.fd
this.connectedClients.set(clientId, {
id: clientId,
process: fd !== undefined ? platform.getPeerProcess(fd) : null
})
const session: ClientSession = {
id: this.nextClientId++,
client,
primary: net.createConnection(primaryClaimed.path),
process: fd !== undefined ? platform.getPeerProcess(fd) : null,
handshakePayload: null,
appId: null,
lastActivityPayload: null,
lastActivityPid: null,
activity: null,
mirrors: new Map(),
reconnectTimers: new Map(),
closed: false
}
this.sessions.set(session.id, session)
this.emitStatus()
const primaryPath = this.claimed[0].path
const primary = net.createConnection(primaryPath)
const { primary } = session
const clientReader = new FrameReader()
const primaryReader = new FrameReader()
let handshakePayload: Buffer | null = null
const cleanup = (): void => {
client.destroy()
primary.destroy()
for (const mirror of this.mirrors.values()) mirror.destroy()
this.mirrors.clear()
this.lastActivityPid.clear()
this.connectedClients.delete(clientId)
this.emitStatus()
}
let clientAppId: string | null = null
let primaryConnected = false
const pendingToPrimary: Buffer[] = []
client.on('data', (chunk: Buffer) => {
for (const frame of clientReader.push(chunk)) {
if (frame.op === OP_HANDSHAKE) {
handshakePayload = frame.payload
clientAppId = (parseFramePayload(frame)?.client_id as string) ?? null
session.handshakePayload = frame.payload
session.appId = (parseFramePayload(frame)?.client_id as string) ?? null
}
const encoded = encodeFrame(frame.op, frame.payload)
@@ -352,8 +516,8 @@ export class RpcRelay extends EventEmitter {
pendingToPrimary.push(encoded)
}
if (handshakePayload) this.mirrorFrame(frame, handshakePayload)
void this.recordActivity(frame, clientAppId)
if (session.handshakePayload) this.mirrorFrame(session, frame)
void this.recordActivity(session, frame)
}
})
@@ -368,21 +532,48 @@ export class RpcRelay extends EventEmitter {
})
})
const cleanup = (): void => this.destroySession(session)
client.on('error', cleanup)
client.on('close', cleanup)
primary.on('close', cleanup)
primary.on('error', (err) => {
cleanup()
if ((err as NodeJS.ErrnoException).code === 'ECONNREFUSED') {
const code = (err as NodeJS.ErrnoException).code
if (code === 'ECONNREFUSED' || code === 'ENOENT') {
void this.restart()
} else {
this.lastError = `Primary connection error: ${err.message}`
this.emitStatus()
}
})
client.on('close', cleanup)
primary.on('close', cleanup)
}
/** Restarts the relay, picking up any Discord instance that has replaced its IPC socket. */
private destroySession(session: ClientSession): void {
if (session.closed) return
session.closed = true
session.client.destroy()
session.primary.destroy()
for (const timer of session.reconnectTimers.values()) clearTimeout(timer)
session.reconnectTimers.clear()
for (const mirror of session.mirrors.values()) mirror.destroy()
session.mirrors.clear()
this.sessions.delete(session.id)
this.emitStatus()
}
private dropSessionMirror(session: ClientSession, index: number): void {
const timer = session.reconnectTimers.get(index)
if (timer) {
clearTimeout(timer)
session.reconnectTimers.delete(index)
}
session.mirrors.get(index)?.destroy()
session.mirrors.delete(index)
}
private async restart(): Promise<void> {
if (this.restarting) return
this.restarting = true
@@ -397,79 +588,106 @@ export class RpcRelay extends EventEmitter {
}
}
/** Forwards the handshake and SET_ACTIVITY frames to every enabled mirror instance. */
private mirrorFrame(frame: Frame, handshakePayload: Buffer): void {
private mirrorFrame(session: ClientSession, frame: Frame): void {
const payload = frame.op !== OP_HANDSHAKE ? parseFramePayload(frame) : null
const isSetActivity = payload?.cmd === 'SET_ACTIVITY'
if (frame.op !== OP_HANDSHAKE && !isSetActivity) return
if (isSetActivity) {
session.lastActivityPayload = frame.payload
const pid = (payload!.args as { pid?: number } | undefined)?.pid
if (typeof pid === 'number') {
for (let i = 1; i < this.claimed.length; i++) {
this.lastActivityPid.set(this.claimed[i].index, pid)
}
}
if (typeof pid === 'number') session.lastActivityPid = pid
}
if (this.isBlacklisted(session)) return
for (let i = 1; i < this.claimed.length; i++) {
const { index, path: mirrorPath } = this.claimed[i]
const { index } = this.claimed[i]
if (this.disabledMirrors.has(index)) {
this.mirrors.get(index)?.destroy()
this.mirrors.delete(index)
this.dropSessionMirror(session, index)
continue
}
let mirror = this.mirrors.get(index)
if (!mirror) {
const newMirror: MirrorConnection = new MirrorConnection(
mirrorPath,
handshakePayload,
() => {
if (this.mirrors.get(index) === newMirror) this.mirrors.delete(index)
}
)
mirror = newMirror
this.mirrors.set(index, mirror)
if (frame.op === OP_HANDSHAKE) continue // handshake already sent on connect
this.ensureMirror(session, this.claimed[i])
if (frame.op !== OP_HANDSHAKE) session.mirrors.get(index)?.sendActivity(frame.payload)
}
}
private ensureMirror(session: ClientSession, target: ClaimedSocket): boolean {
if (session.closed || !session.handshakePayload) return false
if (session.mirrors.has(target.index)) return false
const mirror: MirrorConnection = new MirrorConnection(
target.path,
session.handshakePayload,
() => {
if (session.mirrors.get(target.index) === mirror) {
session.mirrors.delete(target.index)
this.scheduleMirrorReconnect(session, target.index)
}
}
)
session.mirrors.set(target.index, mirror)
return true
}
private scheduleMirrorReconnect(session: ClientSession, index: number): void {
if (session.closed || !this.running) return
if (session.reconnectTimers.has(index)) return
const timer = setTimeout(() => {
session.reconnectTimers.delete(index)
if (session.closed || !this.running) return
if (this.disabledMirrors.has(index) || this.isBlacklisted(session)) return
if (!session.lastActivityPayload) return
const target = this.claimed.find((c, i) => i > 0 && c.index === index)
if (!target) return
if (this.ensureMirror(session, target)) {
session.mirrors.get(index)?.sendActivity(session.lastActivityPayload)
}
}, MIRROR_RECONNECT_DELAY_MS)
session.reconnectTimers.set(index, timer)
}
private clearMirrorActivity(index: number): void {
for (const session of this.sessions.values()) {
const timer = session.reconnectTimers.get(index)
if (timer) {
clearTimeout(timer)
session.reconnectTimers.delete(index)
}
if (frame.op !== OP_HANDSHAKE) mirror.sendActivity(frame.payload)
const mirror = session.mirrors.get(index)
if (!mirror) continue
if (session.lastActivityPid !== null) {
mirror.sendActivityAndClose(clearActivityPayload(session.lastActivityPid))
} else {
mirror.destroy()
}
session.mirrors.delete(index)
}
}
/** Sends a SET_ACTIVITY frame clearing the presence on a mirror, then disconnects it. */
private clearMirrorActivity(index: number): void {
const mirror = this.mirrors.get(index)
if (!mirror) return
const pid = this.lastActivityPid.get(index)
if (pid !== undefined) {
const clearPayload = Buffer.from(
JSON.stringify({
cmd: 'SET_ACTIVITY',
args: { pid, activity: null },
nonce: randomUUID()
}),
'utf8'
)
mirror.sendActivityAndClose(clearPayload)
} else {
mirror.destroy()
}
this.mirrors.delete(index)
this.lastActivityPid.delete(index)
}
private async recordActivity(frame: Frame, appId: string | null): Promise<void> {
private async recordActivity(session: ClientSession, frame: Frame): Promise<void> {
if (frame.op === OP_HANDSHAKE) return
const data = parseFramePayload(frame)
if (data?.cmd !== 'SET_ACTIVITY') return
const activity = (data.args as { activity?: Record<string, unknown> })?.activity ?? {}
const at = Date.now()
const appId = session.appId
const activity = (data.args as { activity?: Record<string, unknown> | null })?.activity
if (!activity) {
session.activity = null
this.emitStatus()
return
}
const rawAssets = activity.assets as Record<string, string> | undefined
const assets: ActivityAssets | null = rawAssets
@@ -494,14 +712,18 @@ export class RpcRelay extends EventEmitter {
? rawButtons.map((b) => ({ label: b.label, url: b.url }))
: []
this.lastActivity = {
// Prevent stale network-delayed frames from overwriting newer ones.
if (session.closed) return
if (session.activity && session.activity.at > at) return
session.activity = {
app: (activity.name as string) ?? null,
details: (activity.details as string) ?? null,
state: (activity.state as string) ?? null,
assets,
timestamps,
buttons,
at: Date.now()
at
}
this.emitStatus()
}
+2
View File
@@ -16,6 +16,8 @@ const api = {
ipcRenderer.invoke('relay:set-start-minimized', enabled),
setMirrorEnabled: (index: number, enabled: boolean): Promise<RelayStatus> =>
ipcRenderer.invoke('relay:set-mirror-enabled', index, enabled),
setAppBlacklisted: (appId: string, blacklisted: boolean): Promise<RelayStatus> =>
ipcRenderer.invoke('relay:set-app-blacklisted', appId, blacklisted),
onStatus: (callback: (status: RelayStatus) => void): (() => void) => {
const listener = (_e: unknown, status: RelayStatus): void => callback(status)
ipcRenderer.on('relay:status', listener)
+181 -32
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { ChevronLeft, Info, Settings } from 'lucide-react'
import type { RelayStatus } from '../../main/relay'
import { useEffect, useRef, useState } from 'react'
import { ChevronLeft, ChevronRight, Eye, EyeOff, Info, Settings } from 'lucide-react'
import type { ConnectedClient, LastActivity, RelayStatus } from '../../main/relay'
import { InstanceRow } from './components/InstanceRow'
import { Toggle } from './components/Toggle'
import { Button, LinkButton } from './components/Button'
@@ -11,7 +11,7 @@ const EMPTY_STATUS: RelayStatus = {
unsupported: false,
instances: [],
connectedClients: [],
lastActivity: null,
blacklistedApps: [],
error: null
}
@@ -38,13 +38,7 @@ function formatDuration(ms: number): string {
return `${minutes}:${String(seconds).padStart(2, '0')}`
}
function ActivityPreview({
activity
}: {
activity: RelayStatus['lastActivity']
}): React.JSX.Element | null {
if (!activity) return null
function ActivityPreview({ activity }: { activity: LastActivity }): React.JSX.Element | null {
const { app, details, state, assets, timestamps, buttons, at } = activity
const elapsed = timestamps ? formatElapsed(timestamps.start, timestamps.end) : null
@@ -61,7 +55,7 @@ function ActivityPreview({
/>
) : (
<div className="w-16 h-16 rounded-lg bg-zinc-700 flex items-center justify-center text-zinc-500 text-xs">
{app ? app.slice(0, 2).toUpperCase() : ''}
{app ? app.slice(0, 2).toUpperCase() : '-'}
</div>
)}
{assets?.smallImage && (
@@ -99,6 +93,137 @@ function ActivityPreview({
)
}
type ToggleBlacklist = (appId: string, blacklisted: boolean) => void
function ClientRow({
client,
onToggleBlacklist
}: {
client: ConnectedClient
onToggleBlacklist: ToggleBlacklist
}): React.JSX.Element {
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<span className="text-zinc-300 truncate">
{client.process
? `${client.process.name} (pid ${client.process.pid})`
: 'unknown process'}
</span>
<Button
variant="ghost"
className="p-1 shrink-0"
disabled={!client.appId}
onClick={() => client.appId && onToggleBlacklist(client.appId, !client.blacklisted)}
aria-label={client.blacklisted ? 'Enable mirroring' : 'Disable mirroring'}
title={
!client.appId
? 'Waiting for handshake'
: client.blacklisted
? 'Blacklisted - not mirrored. Click to mirror again.'
: 'Mirrored. Click to blacklist this app.'
}
>
{client.blacklisted ? (
<EyeOff className="w-4 h-4 text-red-400" />
) : (
<Eye className="w-4 h-4" />
)}
</Button>
</div>
{client.blacklisted && <div className="text-xs text-red-400/90">Blacklisted</div>}
{client.activity && (
<div
className={`rounded-lg bg-zinc-900/60 border p-3 ${
client.blacklisted ? 'border-red-900/60 opacity-50' : 'border-zinc-700/60'
}`}
>
<ActivityPreview activity={client.activity} />
</div>
)}
</div>
)
}
function ClientCarousel({
clients,
onToggleBlacklist
}: {
clients: ConnectedClient[]
onToggleBlacklist: ToggleBlacklist
}): React.JSX.Element {
const scrollRef = useRef<HTMLDivElement>(null)
const [index, setIndex] = useState(0)
const current = Math.min(index, clients.length - 1)
const scrollTo = (i: number): void => {
scrollRef.current?.scrollTo({ left: i * scrollRef.current.clientWidth, behavior: 'smooth' })
}
const onScroll = (): void => {
const el = scrollRef.current
if (el) setIndex(Math.round(el.scrollLeft / el.clientWidth))
}
return (
<div className="flex flex-col gap-2">
<div
ref={scrollRef}
onScroll={onScroll}
className="flex overflow-x-auto snap-x snap-mandatory [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{clients.map((client) => (
<div key={client.id} className="w-full shrink-0 snap-center">
<ClientRow client={client} onToggleBlacklist={onToggleBlacklist} />
</div>
))}
</div>
{clients.length > 1 && (
<div className="flex items-center justify-between">
<Button
variant="ghost"
className="p-1"
onClick={() => scrollTo(current - 1)}
disabled={current === 0}
aria-label="Previous client"
>
<ChevronLeft className="w-4 h-4" />
</Button>
<div className="flex gap-1.5">
{clients.map((client, i) => (
<button
key={client.id}
onClick={() => scrollTo(i)}
aria-label={`Show client ${i + 1}`}
className={`w-1.5 h-1.5 rounded-full transition-colors ${
client.blacklisted
? i === current
? 'bg-red-400'
: 'bg-red-900 hover:bg-red-700'
: i === current
? 'bg-zinc-300'
: 'bg-zinc-600 hover:bg-zinc-500'
}`}
/>
))}
</div>
<Button
variant="ghost"
className="p-1"
onClick={() => scrollTo(current + 1)}
disabled={current === clients.length - 1}
aria-label="Next client"
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
)}
</div>
)
}
export function App(): React.JSX.Element {
const [status, setStatus] = useState<RelayStatus>(EMPTY_STATUS)
const [loading, setLoading] = useState(false)
@@ -137,6 +262,10 @@ export function App(): React.JSX.Element {
setStatus(await window.api.setMirrorEnabled(index, enabled))
}
const onToggleBlacklist = async (appId: string, blacklisted: boolean): Promise<void> => {
setStatus(await window.api.setAppBlacklisted(appId, blacklisted))
}
const onToggleAutostart = async (enabled: boolean): Promise<void> => {
setAutostart(await window.api.setAutostart(enabled))
}
@@ -196,6 +325,33 @@ export function App(): React.JSX.Element {
</div>
</section>
<section className="rounded-xl bg-zinc-800/60 border border-zinc-700 p-4 flex flex-col gap-2">
<h2 className="text-sm text-zinc-400 mb-1">Blacklisted apps</h2>
<div className="flex flex-col gap-1.5 text-sm">
{status.blacklistedApps.length > 0 ? (
status.blacklistedApps.map((app) => (
<div key={app.id} className="flex items-center justify-between gap-2">
<div className="flex flex-col min-w-0">
<span className="text-zinc-300 truncate">{app.name ?? 'Unknown app'}</span>
<span className="text-xs text-zinc-500 truncate">{app.id}</span>
</div>
<Button
variant="ghost"
className="p-1 shrink-0"
onClick={() => onToggleBlacklist(app.id, false)}
aria-label={`Remove ${app.name ?? app.id} from blacklist`}
title="Remove from blacklist and mirror again"
>
<EyeOff className="w-4 h-4 text-red-400" />
</Button>
</div>
))
) : (
<div className="text-zinc-500">No blacklisted apps</div>
)}
</div>
</section>
{appVersion && (
<p className="text-xs text-zinc-500 mt-auto">
Version {appVersion.version} ({appVersion.commit})
@@ -265,33 +421,26 @@ export function App(): React.JSX.Element {
</section>
<section className="rounded-xl bg-zinc-800/60 border border-zinc-700 p-4 flex flex-col gap-2">
<h2 className="text-sm text-zinc-400 mb-1">Connected RPC Clients</h2>
<div className="flex flex-col gap-1.5 text-sm">
<div className="flex items-center justify-between mb-1">
<h2 className="text-sm text-zinc-400">Connected RPC Clients</h2>
{status.connectedClients.length > 0 && (
<span className="text-xs text-zinc-500 bg-zinc-700/60 rounded-full px-2 py-0.5">
{status.connectedClients.length}
</span>
)}
</div>
<div className="text-sm">
{status.connectedClients.length > 0 ? (
status.connectedClients.map((client) => (
<div key={client.id} className="flex items-center justify-between gap-2">
<span className="text-zinc-300">
{client.process
? `${client.process.name} (pid ${client.process.pid})`
: 'unknown process'}
</span>
</div>
))
<ClientCarousel
clients={status.connectedClients}
onToggleBlacklist={onToggleBlacklist}
/>
) : (
<div className="text-zinc-500">No clients connected</div>
)}
</div>
</section>
<section className="rounded-xl bg-zinc-800/60 border border-zinc-700 p-4 flex flex-col gap-2">
<h2 className="text-sm text-zinc-400 mb-1">Last Mirrored Activity</h2>
{status.lastActivity ? (
<ActivityPreview activity={status.lastActivity} />
) : (
<div className="text-sm text-zinc-300">No activity yet</div>
)}
</section>
<p className="text-xs text-zinc-500 mt-auto">
Apps using Discord Rich Presence need to be restarted after toggling the relay to pick up
the change.