diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9d87a04..eb9299e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,6 +7,7 @@ onlyBuiltDependencies: minimumReleaseAge: 4320 allowBuilds: + electron: true electron-winstaller: true esbuild: true koffi: true diff --git a/src/main/index.ts b/src/main/index.ts index a851a4c..52508a1 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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((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) }) diff --git a/src/main/ipc-protocol.ts b/src/main/ipc-protocol.ts index b2acda7..3b8a6d1 100644 --- a/src/main/ipc-protocol.ts +++ b/src/main/ipc-protocol.ts @@ -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 | null { try { return JSON.parse(frame.payload.toString('utf8')) diff --git a/src/main/mirror-connection.ts b/src/main/mirror-connection.ts index d3625cd..7942a5f 100644 --- a/src/main/mirror-connection.ts +++ b/src/main/mirror-connection.ts @@ -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) { diff --git a/src/main/platform/index.ts b/src/main/platform/index.ts index 79d6ecc..c1754ea 100644 --- a/src/main/platform/index.ts +++ b/src/main/platform/index.ts @@ -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 = diff --git a/src/main/platform/posix.ts b/src/main/platform/posix.ts index a241192..b5166bb 100644 --- a/src/main/platform/posix.ts +++ b/src/main/platform/posix.ts @@ -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 { 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 { 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 { 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 { + 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 { 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)) }) } diff --git a/src/main/platform/types.ts b/src/main/platform/types.ts index 9d7bcbd..21a632c 100644 --- a/src/main/platform/types.ts +++ b/src/main/platform/types.ts @@ -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 - discoverAndClaim(): ClaimedSocket[] + discoverAndClaim(): Promise - discoverNewSocket(index: number): ClaimedSocket | null + discoverNewSocket(index: number): Promise restoreSocket(claimed: ClaimedSocket): void + discardClaimedSocket(claimed: ClaimedSocket): void + + isSocketAlive(socketPath: string): Promise + getInstanceProcess(index: number): ProcessInfo | null getPeerProcess(fd: number): ProcessInfo | null diff --git a/src/main/platform/unsupported.ts b/src/main/platform/unsupported.ts index fd38826..08b3e09 100644 --- a/src/main/platform/unsupported.ts +++ b/src/main/platform/unsupported.ts @@ -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 { throw NOT_SUPPORTED } - discoverNewSocket(): ClaimedSocket | null { + async discoverNewSocket(): Promise { throw NOT_SUPPORTED } @@ -40,6 +37,14 @@ export class UnsupportedPlatform implements RelayPlatform { throw NOT_SUPPORTED } + discardClaimedSocket(): void { + throw NOT_SUPPORTED + } + + async isSocketAlive(): Promise { + return false + } + getInstanceProcess(): ProcessInfo | null { return null } diff --git a/src/main/relay.ts b/src/main/relay.ts index c684057..ad9dbb5 100644 --- a/src/main/relay.ts +++ b/src/main/relay.ts @@ -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>() +interface AssetCacheEntry { + map: Map + expires: number +} + +const appAssetCache = new Map() async function getAppAssetMap(appId: string): Promise> { const cached = appAssetCache.get(appId) - if (cached) return cached + if (cached && Date.now() < cached.expires) return cached.map const map = new Map() + 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 + reconnectTimers: Map + 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() - private connectedClients = new Map() + private blacklistedApps = new Map() + private sessions = new Map() 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() - private lastActivityPid = new Map() 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 { 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((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((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((resolve) => this.server!.close(() => resolve())) + const server = this.server this.server = null + await new Promise((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 { + 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 { 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 { + private async recordActivity(session: ClientSession, frame: Frame): Promise { if (frame.op === OP_HANDSHAKE) return const data = parseFramePayload(frame) if (data?.cmd !== 'SET_ACTIVITY') return - const activity = (data.args as { activity?: Record })?.activity ?? {} + const at = Date.now() + const appId = session.appId + const activity = (data.args as { activity?: Record | null })?.activity + + if (!activity) { + session.activity = null + this.emitStatus() + return + } const rawAssets = activity.assets as Record | 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() } diff --git a/src/preload/index.ts b/src/preload/index.ts index 7a8e995..1d9428d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -16,6 +16,8 @@ const api = { ipcRenderer.invoke('relay:set-start-minimized', enabled), setMirrorEnabled: (index: number, enabled: boolean): Promise => ipcRenderer.invoke('relay:set-mirror-enabled', index, enabled), + setAppBlacklisted: (appId: string, blacklisted: boolean): Promise => + 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) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 4ecf08c..1edf4d0 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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({ /> ) : (
- {app ? app.slice(0, 2).toUpperCase() : '—'} + {app ? app.slice(0, 2).toUpperCase() : '-'}
)} {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 ( +
+
+ + {client.process + ? `${client.process.name} (pid ${client.process.pid})` + : 'unknown process'} + + +
+ {client.blacklisted &&
Blacklisted
} + {client.activity && ( +
+ +
+ )} +
+ ) +} + +function ClientCarousel({ + clients, + onToggleBlacklist +}: { + clients: ConnectedClient[] + onToggleBlacklist: ToggleBlacklist +}): React.JSX.Element { + const scrollRef = useRef(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 ( +
+
+ {clients.map((client) => ( +
+ +
+ ))} +
+ + {clients.length > 1 && ( +
+ +
+ {clients.map((client, i) => ( +
+ +
+ )} +
+ ) +} + export function App(): React.JSX.Element { const [status, setStatus] = useState(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 => { + setStatus(await window.api.setAppBlacklisted(appId, blacklisted)) + } + const onToggleAutostart = async (enabled: boolean): Promise => { setAutostart(await window.api.setAutostart(enabled)) } @@ -196,6 +325,33 @@ export function App(): React.JSX.Element { +
+

Blacklisted apps

+
+ {status.blacklistedApps.length > 0 ? ( + status.blacklistedApps.map((app) => ( +
+
+ {app.name ?? 'Unknown app'} + {app.id} +
+ +
+ )) + ) : ( +
No blacklisted apps
+ )} +
+
+ {appVersion && (

Version {appVersion.version} ({appVersion.commit}) @@ -265,33 +421,26 @@ export function App(): React.JSX.Element {

-

Connected RPC Clients

-
+
+

Connected RPC Clients

+ {status.connectedClients.length > 0 && ( + + {status.connectedClients.length} + + )} +
+
{status.connectedClients.length > 0 ? ( - status.connectedClients.map((client) => ( -
- - {client.process - ? `${client.process.name} (pid ${client.process.pid})` - : 'unknown process'} - -
- )) + ) : (
No clients connected
)}
-
-

Last Mirrored Activity

- {status.lastActivity ? ( - - ) : ( -
No activity yet
- )} -
-

Apps using Discord Rich Presence need to be restarted after toggling the relay to pick up the change.