3 Commits
Author SHA1 Message Date
yuzu 7ea3354bf6 feat: Per-client sessions, app blacklist, robustness rework 2026-07-12 14:28:46 +07:00
yuzuandGitHub 4674142f52 📚 docs: LICENSE 2026-06-16 03:50:18 +07:00
yuzuGitHubCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
998e7d6fa8 👷 ci: set workflow permissions
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-06-16 03:44:02 +07:00
13 changed files with 712 additions and 230 deletions
+3
View File
@@ -8,6 +8,9 @@ on:
- 'v*'
pull_request:
permissions:
contents: read
jobs:
check:
strategy:
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 YuzuZensai
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+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 =
+48 -9
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,6 +44,7 @@ export class PosixPlatform implements RelayPlatform {
async recoverLeftoverSockets(): Promise<void> {
for (let i = 0; i < MAX_SOCKETS; i++) {
try {
const leftover = claimedPath(i)
const original = ipcPath(i)
if (!fs.existsSync(leftover)) continue
@@ -56,32 +58,64 @@ export class PosixPlatform implements RelayPlatform {
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)
try {
fs.renameSync(src, dst)
} catch {
return null // socket replaced between the check and the rename
}
return { index, path: dst }
}
restoreSocket(claimed: ClaimedSocket): void {
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 {
@@ -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
}
+325 -103
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,16 +279,18 @@ 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()
try {
const fake = platform.fakeSocketPath()
platform.removeFakeSocket(fake)
@@ -211,6 +309,16 @@ export class RpcRelay extends EventEmitter {
})
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
}
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
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
}
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 = platform.discoverNewSocket(i)
if (found) {
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,
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 (this.mirrors.get(index) === newMirror) this.mirrors.delete(index)
if (session.mirrors.get(target.index) === mirror) {
session.mirrors.delete(target.index)
this.scheduleMirrorReconnect(session, target.index)
}
}
)
mirror = newMirror
this.mirrors.set(index, mirror)
if (frame.op === OP_HANDSHAKE) continue // handshake already sent on connect
session.mirrors.set(target.index, mirror)
return true
}
if (frame.op !== OP_HANDSHAKE) mirror.sendActivity(frame.payload)
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)
}
/** 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
for (const session of this.sessions.values()) {
const timer = session.reconnectTimers.get(index)
if (timer) {
clearTimeout(timer)
session.reconnectTimers.delete(index)
}
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)
const mirror = session.mirrors.get(index)
if (!mirror) continue
if (session.lastActivityPid !== null) {
mirror.sendActivityAndClose(clearActivityPayload(session.lastActivityPid))
} else {
mirror.destroy()
}
this.mirrors.delete(index)
this.lastActivityPid.delete(index)
session.mirrors.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)
+180 -31
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">
{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'}
<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 ? (
<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.