diff --git a/src/main/index.ts b/src/main/index.ts index 52508a1..ac44aa9 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -15,6 +15,7 @@ interface Settings { disabledMirrors: number[] blacklistedApps: BlacklistedApp[] startMinimized: boolean + lockedPrimary: string | null } function parseBlacklistedApps(raw: unknown): BlacklistedApp[] { @@ -33,10 +34,11 @@ function loadSettings(): Settings { return { disabledMirrors: Array.isArray(data?.disabledMirrors) ? data.disabledMirrors : [], blacklistedApps: parseBlacklistedApps(data?.blacklistedApps), - startMinimized: data?.startMinimized === true + startMinimized: data?.startMinimized === true, + lockedPrimary: typeof data?.lockedPrimary === 'string' ? data.lockedPrimary : null } } catch { - return { disabledMirrors: [], blacklistedApps: [], startMinimized: false } + return { disabledMirrors: [], blacklistedApps: [], startMinimized: false, lockedPrimary: null } } } @@ -217,6 +219,7 @@ app.whenReady().then(() => { const settings = loadSettings() relay.setDisabledMirrors(settings.disabledMirrors) relay.setBlacklistedApps(settings.blacklistedApps) + relay.setLockedPrimary(settings.lockedPrimary) ipcMain.handle('relay:get-version', () => ({ version: app.getVersion(), @@ -265,6 +268,18 @@ app.whenReady().then(() => { saveSettings({ ...current, blacklistedApps: relay.getBlacklistedApps() }) return status }) + ipcMain.handle('relay:unlock-primary', () => { + const status = relay.unlockPrimary() + const current = loadSettings() + saveSettings({ ...current, lockedPrimary: relay.getLockedPrimary() }) + return status + }) + ipcMain.handle('relay:promote-to-primary', async (_e, index: number) => { + const status = await relay.promoteToPrimary(index) + const current = loadSettings() + saveSettings({ ...current, lockedPrimary: relay.getLockedPrimary() }) + return status + }) relay.on('status', (status: RelayStatus) => { updateTrayMenu(status) diff --git a/src/main/platform/posix.ts b/src/main/platform/posix.ts index b5166bb..6d32127 100644 --- a/src/main/platform/posix.ts +++ b/src/main/platform/posix.ts @@ -1,7 +1,7 @@ import * as fs from 'fs' import * as net from 'net' import * as path from 'path' -import { execFileSync } from 'child_process' +import { execFileSync, spawn } from 'child_process' import { MAX_SOCKETS, type ClaimedSocket, type ProcessInfo, type RelayPlatform } from './types' const REAL_PREFIX = 'discord-ipc-real-' @@ -9,9 +9,27 @@ const FAKE_INDEX = 0 const ALIVE_CHECK_TIMEOUT_MS = 1000 const STALE_SOCKET_MIN_AGE_MS = 10_000 +let darwinTempDir: string | null | undefined + +// $TMPDIR can be overridden by a shell, so ask the OS where Discord really binds. +function resolveDarwinTempDir(): string | null { + if (darwinTempDir !== undefined) return darwinTempDir + try { + darwinTempDir = execFileSync('getconf', ['DARWIN_USER_TEMP_DIR'], { + encoding: 'utf8', + timeout: 1000 + }).trim() + } catch { + darwinTempDir = null + } + return darwinTempDir +} + function runtimeDir(): string { if (process.env.XDG_RUNTIME_DIR) return process.env.XDG_RUNTIME_DIR - if (process.platform === 'darwin') return process.env.TMPDIR ?? '/tmp' + if (process.platform === 'darwin') { + return resolveDarwinTempDir() ?? process.env.TMPDIR ?? '/tmp' + } return `/run/user/${process.getuid?.() ?? 0}` } @@ -30,6 +48,10 @@ export class PosixPlatform implements RelayPlatform { return ipcPath(FAKE_INDEX) } + ipcSocketPath(index: number): string { + return ipcPath(index) + } + removeFakeSocket(fakePath: string): void { if (fs.existsSync(fakePath)) fs.unlinkSync(fakePath) } @@ -118,14 +140,129 @@ export class PosixPlatform implements RelayPlatform { return isSocketAlive(socketPath) } + async removeStaleIpcSocket(index: number): Promise { + const p = ipcPath(index) + if (!fs.existsSync(p)) return + if (await isSocketAlive(p)) return + try { + fs.unlinkSync(p) + } catch { + // Already gone or replaced. + } + } + getInstanceProcess(index: number): ProcessInfo | null { - // /proc/net/unix reports the original bind path even after the file is renamed on disk + // lsof/procfs still report the original bind path even after we rename to -real-N. return findSocketOwner(ipcPath(index)) } getPeerProcess(fd: number): ProcessInfo | null { return findPeerProcess(fd) } + + killProcess(pid: number): boolean { + try { + process.kill(pid, 'SIGTERM') + return true + } catch { + return false + } + } + + isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (err) { + // EPERM means it's alive, we just aren't allowed to touch it. + return (err as NodeJS.ErrnoException).code === 'EPERM' + } + } + + launchExecutable(executable: string): boolean { + try { + // Point Discord at the temp dir we watch, whatever TMPDIR we inherited. + const env = { ...process.env } + + if (process.platform === 'darwin') { + // Spawning the .app binary directly just dies, so let `open` launch it. + const canonical = resolveDarwinTempDir() + if (canonical) env.TMPDIR = canonical + + const appPath = executable.includes('.app/') + ? executable.slice(0, executable.indexOf('.app/') + '.app'.length) + : executable + const child = spawn('open', ['-a', appPath], { detached: true, stdio: 'ignore', env }) + child.unref() + return true + } + + const child = spawn(executable, [], { detached: true, stdio: 'ignore', env }) + child.unref() + return true + } catch { + return false + } + } +} + +// The pid holding the socket is usually a helper; walk up to the real app process. +function resolveMainAppProcess(pid: number): ProcessInfo | null { + if (process.platform !== 'darwin') return null + + const exe = executablePath(pid) + const bundle = bundleRoot(exe) + if (!bundle) return null + + let mainPid = pid + let mainExe = exe + for (let depth = 0; depth < 20; depth++) { + const ppid = parentPid(mainPid) + if (ppid === null || ppid <= 1) break + const parentExe = executablePath(ppid) + if (bundleRoot(parentExe) !== bundle) break + mainPid = ppid + mainExe = parentExe + } + + return { pid: mainPid, name: path.basename(mainExe ?? String(mainPid)), executable: mainExe } +} + +function bundleRoot(exe: string | null): string | null { + if (!exe) return null + const idx = exe.indexOf('.app/Contents/') + return idx >= 0 ? exe.slice(0, idx + '.app'.length) : null +} + +function parentPid(pid: number): number | null { + try { + const out = execFileSync('ps', ['-p', String(pid), '-o', 'ppid='], { + encoding: 'utf8', + timeout: 1000 + }).trim() + const ppid = parseInt(out, 10) + return Number.isNaN(ppid) ? null : ppid + } catch { + return null + } +} + +function executablePath(pid: number): string | null { + try { + if (process.platform === 'linux') { + return fs.readlinkSync(`/proc/${pid}/exe`) + } + if (process.platform === 'darwin') { + const out = execFileSync('ps', ['-p', String(pid), '-o', 'comm='], { + encoding: 'utf8', + timeout: 1000 + }).trim() + return out || null + } + } catch { + return null + } + return null } function isSocketAlive(socketPath: string): Promise { @@ -176,7 +313,13 @@ function locateSocketOwnerMacOS(socketPath: string): ProcessInfo | null { currentName = line.slice(1) } else if (line.startsWith('n') && line.slice(1) === socketPath) { if (currentPid > 0 && currentPid !== process.pid) { - return { pid: currentPid, name: currentName } + return ( + resolveMainAppProcess(currentPid) ?? { + pid: currentPid, + name: currentName, + executable: executablePath(currentPid) + } + ) } } } @@ -236,7 +379,8 @@ function findProcessForInodes(inodes: Set): ProcessInfo | null { } const match = /^socket:\[(\d+)\]$/.exec(link) if (match && inodes.has(match[1])) { - return { pid: parseInt(pidStr, 10), name: processName(pidStr) } + const pid = parseInt(pidStr, 10) + return { pid, name: processName(pidStr), executable: executablePath(pid) } } } } @@ -299,7 +443,7 @@ function findPeerProcess(localFd: number): ProcessInfo | null { if (fn(localFd, SOL_LOCAL, LOCAL_PEERPID, optval, optlen) !== 0) return null const pid = optval.readInt32LE(0) if (pid <= 0) return null - return { pid, name: macProcessName(pid) } + return { pid, name: macProcessName(pid), executable: executablePath(pid) } } const optval = Buffer.alloc(UCRED_SIZE) @@ -311,7 +455,7 @@ function findPeerProcess(localFd: number): ProcessInfo | null { const pid = optval.readInt32LE(0) if (pid <= 0) return null - return { pid, name: processName(String(pid)) } + return { pid, name: processName(String(pid)), executable: executablePath(pid) } } function macProcessName(pid: number): string { diff --git a/src/main/platform/types.ts b/src/main/platform/types.ts index 21a632c..235d36b 100644 --- a/src/main/platform/types.ts +++ b/src/main/platform/types.ts @@ -3,6 +3,7 @@ export const MAX_SOCKETS = 10 export interface ProcessInfo { pid: number name: string + executable: string | null } export interface ClaimedSocket { @@ -15,6 +16,8 @@ export interface RelayPlatform { fakeSocketPath(): string + ipcSocketPath(index: number): string + removeFakeSocket(fakePath: string): void finalizeFakeSocket(fakePath: string): void @@ -34,7 +37,15 @@ export interface RelayPlatform { isSocketAlive(socketPath: string): Promise + removeStaleIpcSocket(index: number): Promise + getInstanceProcess(index: number): ProcessInfo | null getPeerProcess(fd: number): ProcessInfo | null + + killProcess(pid: number): boolean + + isProcessAlive(pid: number): boolean + + launchExecutable(executable: string): boolean } diff --git a/src/main/platform/unsupported.ts b/src/main/platform/unsupported.ts index 08b3e09..063dac2 100644 --- a/src/main/platform/unsupported.ts +++ b/src/main/platform/unsupported.ts @@ -9,6 +9,10 @@ export class UnsupportedPlatform implements RelayPlatform { throw NOT_SUPPORTED } + ipcSocketPath(): string { + throw NOT_SUPPORTED + } + removeFakeSocket(): void { throw NOT_SUPPORTED } @@ -45,6 +49,10 @@ export class UnsupportedPlatform implements RelayPlatform { return false } + async removeStaleIpcSocket(): Promise { + throw NOT_SUPPORTED + } + getInstanceProcess(): ProcessInfo | null { return null } @@ -52,4 +60,16 @@ export class UnsupportedPlatform implements RelayPlatform { getPeerProcess(): ProcessInfo | null { return null } + + killProcess(): boolean { + return false + } + + isProcessAlive(): boolean { + return false + } + + launchExecutable(): boolean { + return false + } } diff --git a/src/main/relay.ts b/src/main/relay.ts index ad9dbb5..a1ffb85 100644 --- a/src/main/relay.ts +++ b/src/main/relay.ts @@ -11,6 +11,11 @@ const MIRROR_RECONNECT_DELAY_MS = 2000 const SERVER_CLOSE_TIMEOUT_MS = 2000 const ASSET_FETCH_RETRY_MS = 60_000 +const PROCESS_EXIT_TIMEOUT_MS = 8000 +const PROCESS_EXIT_POLL_MS = 200 +const SLOT_BIND_TIMEOUT_MS = 20_000 +const SLOT_BIND_POLL_MS = 300 + interface AppAsset { id: string type: number @@ -46,6 +51,10 @@ async function getAppAssetMap(appId: string): Promise> { return map } +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + function clearActivityPayload(pid: number): Buffer { return Buffer.from( JSON.stringify({ cmd: 'SET_ACTIVITY', args: { pid, activity: null }, nonce: randomUUID() }), @@ -82,6 +91,7 @@ export interface RelayInstance { path: string isPrimary: boolean enabled: boolean + locked: boolean process: ProcessInfo | null } @@ -132,6 +142,9 @@ export interface RelayStatus { instances: RelayInstance[] connectedClients: ConnectedClient[] blacklistedApps: BlacklistedApp[] + lockedPrimary: string | null + reordering: boolean + primaryOutOfOrder: boolean error: string | null } @@ -166,15 +179,21 @@ export class RpcRelay extends EventEmitter { private fakeOwned = false private lastError: string | null = null private restarting = false + private lockedPrimary: string | null = null + private reordering = false getStatus(): RelayStatus { - const instances: RelayInstance[] = this.claimed.map(({ index, path: socketPath }, i) => ({ - index, - path: socketPath, - isPrimary: i === 0, - enabled: i === 0 || !this.disabledMirrors.has(index), - process: platform.getInstanceProcess(index) - })) + const instances: RelayInstance[] = this.claimed.map(({ index, path: socketPath }, i) => { + const process = platform.getInstanceProcess(index) + return { + index, + path: socketPath, + isPrimary: i === 0, + enabled: i === 0 || !this.disabledMirrors.has(index), + locked: this.isLockedInstance(process), + process + } + }) return { running: this.running, @@ -189,10 +208,27 @@ export class RpcRelay extends EventEmitter { activity: s.activity })), blacklistedApps: this.getBlacklistedApps(), + lockedPrimary: this.lockedPrimary, + reordering: this.reordering, + primaryOutOfOrder: this.primaryOutOfOrder(instances), error: this.lastError } } + private isLockedInstance(process: ProcessInfo | null): boolean { + return ( + this.lockedPrimary !== null && + process?.executable != null && + process.executable === this.lockedPrimary + ) + } + + private primaryOutOfOrder(instances: RelayInstance[]): boolean { + if (this.lockedPrimary === null) return false + const locked = instances.find((inst) => inst.locked) + return locked !== undefined && !locked.isPrimary + } + setMirrorEnabled(index: number, enabled: boolean): RelayStatus { if (index === this.primaryIndex()) return this.getStatus() @@ -214,6 +250,33 @@ export class RpcRelay extends EventEmitter { this.disabledMirrors = new Set(indices) } + getLockedPrimary(): string | null { + return this.lockedPrimary + } + + setLockedPrimary(executable: string | null): void { + this.lockedPrimary = executable + this.emitStatus() + } + + unlockPrimary(): RelayStatus { + this.lockedPrimary = null + this.emitStatus() + return this.getStatus() + } + + async promoteToPrimary(index: number): Promise { + const process = platform.getInstanceProcess(index) + if (!process?.executable) { + this.lastError = 'Cannot resolve this Discord’s executable path' + this.emitStatus() + return this.getStatus() + } + this.lockedPrimary = process.executable + this.emitStatus() + return this.reorderForPrimary() + } + getBlacklistedApps(): BlacklistedApp[] { return [...this.blacklistedApps].map(([id, name]) => ({ id, name })) } @@ -588,6 +651,85 @@ export class RpcRelay extends EventEmitter { } } + private async reorderForPrimary(): Promise { + if (this.reordering) return this.getStatus() + if (this.lockedPrimary === null) return this.getStatus() + + const lockedSlot = this.claimed.findIndex( + (c) => platform.getInstanceProcess(c.index)?.executable === this.lockedPrimary + ) + if (lockedSlot < 0) { + this.lastError = 'Locked Discord is not currently running' + this.emitStatus() + return this.getStatus() + } + if (lockedSlot === 0) return this.getStatus() + + this.reordering = true + this.lastError = null + this.emitStatus() + + try { + // Grab what we need now before stop() wipes claimed below. + const others: string[] = [] + const toKill: number[] = [] + const freeIndices: number[] = [] + for (let i = 0; i <= lockedSlot; i++) { + freeIndices.push(this.claimed[i].index) + const proc = platform.getInstanceProcess(this.claimed[i].index) + if (!proc) continue + toKill.push(proc.pid) + if (proc.executable && proc.executable !== this.lockedPrimary) { + others.push(proc.executable) + } + } + const lockedExecutable = this.lockedPrimary + + await this.stop() + + for (const pid of toKill) platform.killProcess(pid) + await this.waitForProcessesExit(toKill) + + for (const index of freeIndices) { + await platform.removeStaleIpcSocket(index) + } + + if (!platform.launchExecutable(lockedExecutable)) { + throw new Error('Failed to relaunch the locked Discord') + } + await this.waitForSlotBound(0) + + for (const executable of others) platform.launchExecutable(executable) + } catch (err) { + this.lastError = err instanceof Error ? err.message : String(err) + } finally { + this.reordering = false + await this.start().catch((err) => { + this.lastError = err instanceof Error ? err.message : String(err) + }) + this.emitStatus() + } + + return this.getStatus() + } + + private async waitForProcessesExit(pids: number[]): Promise { + const deadline = Date.now() + PROCESS_EXIT_TIMEOUT_MS + while (Date.now() < deadline) { + if (pids.every((pid) => !platform.isProcessAlive(pid))) return + await delay(PROCESS_EXIT_POLL_MS) + } + } + + private async waitForSlotBound(index: number): Promise { + const deadline = Date.now() + SLOT_BIND_TIMEOUT_MS + while (Date.now() < deadline) { + if (await platform.isSocketAlive(platform.ipcSocketPath(index))) return + await delay(SLOT_BIND_POLL_MS) + } + throw new Error('Timed out waiting for the locked Discord to start') + } + private mirrorFrame(session: ClientSession, frame: Frame): void { const payload = frame.op !== OP_HANDSHAKE ? parseFramePayload(frame) : null const isSetActivity = payload?.cmd === 'SET_ACTIVITY' diff --git a/src/preload/index.ts b/src/preload/index.ts index 1d9428d..120f5af 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -18,6 +18,9 @@ const api = { ipcRenderer.invoke('relay:set-mirror-enabled', index, enabled), setAppBlacklisted: (appId: string, blacklisted: boolean): Promise => ipcRenderer.invoke('relay:set-app-blacklisted', appId, blacklisted), + unlockPrimary: (): Promise => ipcRenderer.invoke('relay:unlock-primary'), + promoteToPrimary: (index: number): Promise => + ipcRenderer.invoke('relay:promote-to-primary', index), 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 1edf4d0..336b3f0 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -12,6 +12,9 @@ const EMPTY_STATUS: RelayStatus = { instances: [], connectedClients: [], blacklistedApps: [], + lockedPrimary: null, + reordering: false, + primaryOutOfOrder: false, error: null } @@ -266,6 +269,14 @@ export function App(): React.JSX.Element { setStatus(await window.api.setAppBlacklisted(appId, blacklisted)) } + const onUnlockPrimary = async (): Promise => { + setStatus(await window.api.unlockPrimary()) + } + + const onPromote = async (index: number): Promise => { + setStatus(await window.api.promoteToPrimary(index)) + } + const onToggleAutostart = async (enabled: boolean): Promise => { setAutostart(await window.api.setAutostart(enabled)) } @@ -411,13 +422,22 @@ export function App(): React.JSX.Element { )) ) : (
No Discord instances detected
)} + + {status.reordering && ( +
+ Restarting Discord clients to promote the new primary… +
+ )}
diff --git a/src/renderer/src/components/InstanceRow.tsx b/src/renderer/src/components/InstanceRow.tsx index 2b11e8c..b49614e 100644 --- a/src/renderer/src/components/InstanceRow.tsx +++ b/src/renderer/src/components/InstanceRow.tsx @@ -1,6 +1,7 @@ -import { Info } from 'lucide-react' +import { ArrowUpToLine, Info, Lock } from 'lucide-react' import type { RelayInstance } from '../../../main/relay' import { Toggle } from './Toggle' +import { Button } from './Button' function shortPath(p: string): string { return p.split('/').pop() ?? p @@ -8,14 +9,54 @@ function shortPath(p: string): string { interface Props { instance: RelayInstance + reordering: boolean onToggleMirror: (index: number, enabled: boolean) => Promise + onUnlock: () => Promise + onPromote: (index: number) => Promise } -export function InstanceRow({ instance, onToggleMirror }: Props): React.JSX.Element { +export function InstanceRow({ + instance, + reordering, + onToggleMirror, + onUnlock, + onPromote +}: Props): React.JSX.Element { const procLabel = instance.process ? `${instance.process.name} (pid ${instance.process.pid})` : 'unknown process' + const canPromote = instance.process?.executable != null + + const promoteButton = ( + + ) + + const unlockButton = ( + + ) + const info = (
@@ -29,6 +70,7 @@ export function InstanceRow({ instance, onToggleMirror }: Props): React.JSX.Elem mirror #{instance.index} )} + {instance.locked && locked}
{procLabel}
@@ -38,14 +80,19 @@ export function InstanceRow({ instance, onToggleMirror }: Props): React.JSX.Elem return (
{info} - - - The primary instance gets full passthrough and can't be turned off. It's - always the Discord instance that started first (discord-ipc-0). To make a different - instance primary, close this one first so the other claims that slot, then restart the - relay. - - +
+ {instance.locked ? ( + unlockButton + ) : ( + + + The primary instance gets full passthrough and can't be turned off. It's + the Discord that started first (discord-ipc-0). Use Promote on another instance to + make it primary instead. + + + )} +
) } @@ -53,10 +100,14 @@ export function InstanceRow({ instance, onToggleMirror }: Props): React.JSX.Elem return (
{info} - onToggleMirror(instance.index, checked)} - /> +
+ {instance.locked && unlockButton} + {promoteButton} + onToggleMirror(instance.index, checked)} + /> +
) }