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