mirror of
https://github.com/YuzuZensai/Discord-Presence-Relay.git
synced 2026-09-13 17:01:36 +00:00
✨ feat: primary promote and locking
This commit is contained in:
+17
-2
@@ -15,6 +15,7 @@ interface Settings {
|
|||||||
disabledMirrors: number[]
|
disabledMirrors: number[]
|
||||||
blacklistedApps: BlacklistedApp[]
|
blacklistedApps: BlacklistedApp[]
|
||||||
startMinimized: boolean
|
startMinimized: boolean
|
||||||
|
lockedPrimary: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseBlacklistedApps(raw: unknown): BlacklistedApp[] {
|
function parseBlacklistedApps(raw: unknown): BlacklistedApp[] {
|
||||||
@@ -33,10 +34,11 @@ function loadSettings(): Settings {
|
|||||||
return {
|
return {
|
||||||
disabledMirrors: Array.isArray(data?.disabledMirrors) ? data.disabledMirrors : [],
|
disabledMirrors: Array.isArray(data?.disabledMirrors) ? data.disabledMirrors : [],
|
||||||
blacklistedApps: parseBlacklistedApps(data?.blacklistedApps),
|
blacklistedApps: parseBlacklistedApps(data?.blacklistedApps),
|
||||||
startMinimized: data?.startMinimized === true
|
startMinimized: data?.startMinimized === true,
|
||||||
|
lockedPrimary: typeof data?.lockedPrimary === 'string' ? data.lockedPrimary : null
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return { disabledMirrors: [], blacklistedApps: [], startMinimized: false }
|
return { disabledMirrors: [], blacklistedApps: [], startMinimized: false, lockedPrimary: null }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,6 +219,7 @@ app.whenReady().then(() => {
|
|||||||
const settings = loadSettings()
|
const settings = loadSettings()
|
||||||
relay.setDisabledMirrors(settings.disabledMirrors)
|
relay.setDisabledMirrors(settings.disabledMirrors)
|
||||||
relay.setBlacklistedApps(settings.blacklistedApps)
|
relay.setBlacklistedApps(settings.blacklistedApps)
|
||||||
|
relay.setLockedPrimary(settings.lockedPrimary)
|
||||||
|
|
||||||
ipcMain.handle('relay:get-version', () => ({
|
ipcMain.handle('relay:get-version', () => ({
|
||||||
version: app.getVersion(),
|
version: app.getVersion(),
|
||||||
@@ -265,6 +268,18 @@ app.whenReady().then(() => {
|
|||||||
saveSettings({ ...current, blacklistedApps: relay.getBlacklistedApps() })
|
saveSettings({ ...current, blacklistedApps: relay.getBlacklistedApps() })
|
||||||
return status
|
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) => {
|
relay.on('status', (status: RelayStatus) => {
|
||||||
updateTrayMenu(status)
|
updateTrayMenu(status)
|
||||||
|
|||||||
+151
-7
@@ -1,7 +1,7 @@
|
|||||||
import * as fs from 'fs'
|
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, spawn } from 'child_process'
|
||||||
import { MAX_SOCKETS, type ClaimedSocket, type ProcessInfo, type 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-'
|
||||||
@@ -9,9 +9,27 @@ const FAKE_INDEX = 0
|
|||||||
const ALIVE_CHECK_TIMEOUT_MS = 1000
|
const ALIVE_CHECK_TIMEOUT_MS = 1000
|
||||||
const STALE_SOCKET_MIN_AGE_MS = 10_000
|
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 {
|
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
|
||||||
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}`
|
return `/run/user/${process.getuid?.() ?? 0}`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,6 +48,10 @@ export class PosixPlatform implements RelayPlatform {
|
|||||||
return ipcPath(FAKE_INDEX)
|
return ipcPath(FAKE_INDEX)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ipcSocketPath(index: number): string {
|
||||||
|
return ipcPath(index)
|
||||||
|
}
|
||||||
|
|
||||||
removeFakeSocket(fakePath: string): void {
|
removeFakeSocket(fakePath: string): void {
|
||||||
if (fs.existsSync(fakePath)) fs.unlinkSync(fakePath)
|
if (fs.existsSync(fakePath)) fs.unlinkSync(fakePath)
|
||||||
}
|
}
|
||||||
@@ -118,14 +140,129 @@ export class PosixPlatform implements RelayPlatform {
|
|||||||
return isSocketAlive(socketPath)
|
return isSocketAlive(socketPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async removeStaleIpcSocket(index: number): Promise<void> {
|
||||||
|
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 {
|
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))
|
return findSocketOwner(ipcPath(index))
|
||||||
}
|
}
|
||||||
|
|
||||||
getPeerProcess(fd: number): ProcessInfo | null {
|
getPeerProcess(fd: number): ProcessInfo | null {
|
||||||
return findPeerProcess(fd)
|
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<boolean> {
|
function isSocketAlive(socketPath: string): Promise<boolean> {
|
||||||
@@ -176,7 +313,13 @@ function locateSocketOwnerMacOS(socketPath: string): ProcessInfo | null {
|
|||||||
currentName = line.slice(1)
|
currentName = line.slice(1)
|
||||||
} else if (line.startsWith('n') && line.slice(1) === socketPath) {
|
} else if (line.startsWith('n') && line.slice(1) === socketPath) {
|
||||||
if (currentPid > 0 && currentPid !== process.pid) {
|
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<string>): ProcessInfo | null {
|
|||||||
}
|
}
|
||||||
const match = /^socket:\[(\d+)\]$/.exec(link)
|
const match = /^socket:\[(\d+)\]$/.exec(link)
|
||||||
if (match && inodes.has(match[1])) {
|
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
|
if (fn(localFd, SOL_LOCAL, LOCAL_PEERPID, optval, optlen) !== 0) return null
|
||||||
const pid = optval.readInt32LE(0)
|
const pid = optval.readInt32LE(0)
|
||||||
if (pid <= 0) return null
|
if (pid <= 0) return null
|
||||||
return { pid, name: macProcessName(pid) }
|
return { pid, name: macProcessName(pid), executable: executablePath(pid) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const optval = Buffer.alloc(UCRED_SIZE)
|
const optval = Buffer.alloc(UCRED_SIZE)
|
||||||
@@ -311,7 +455,7 @@ function findPeerProcess(localFd: number): ProcessInfo | null {
|
|||||||
const pid = optval.readInt32LE(0)
|
const pid = optval.readInt32LE(0)
|
||||||
if (pid <= 0) return null
|
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 {
|
function macProcessName(pid: number): string {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export const MAX_SOCKETS = 10
|
|||||||
export interface ProcessInfo {
|
export interface ProcessInfo {
|
||||||
pid: number
|
pid: number
|
||||||
name: string
|
name: string
|
||||||
|
executable: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClaimedSocket {
|
export interface ClaimedSocket {
|
||||||
@@ -15,6 +16,8 @@ export interface RelayPlatform {
|
|||||||
|
|
||||||
fakeSocketPath(): string
|
fakeSocketPath(): string
|
||||||
|
|
||||||
|
ipcSocketPath(index: number): string
|
||||||
|
|
||||||
removeFakeSocket(fakePath: string): void
|
removeFakeSocket(fakePath: string): void
|
||||||
|
|
||||||
finalizeFakeSocket(fakePath: string): void
|
finalizeFakeSocket(fakePath: string): void
|
||||||
@@ -34,7 +37,15 @@ export interface RelayPlatform {
|
|||||||
|
|
||||||
isSocketAlive(socketPath: string): Promise<boolean>
|
isSocketAlive(socketPath: string): Promise<boolean>
|
||||||
|
|
||||||
|
removeStaleIpcSocket(index: number): Promise<void>
|
||||||
|
|
||||||
getInstanceProcess(index: number): ProcessInfo | null
|
getInstanceProcess(index: number): ProcessInfo | null
|
||||||
|
|
||||||
getPeerProcess(fd: number): ProcessInfo | null
|
getPeerProcess(fd: number): ProcessInfo | null
|
||||||
|
|
||||||
|
killProcess(pid: number): boolean
|
||||||
|
|
||||||
|
isProcessAlive(pid: number): boolean
|
||||||
|
|
||||||
|
launchExecutable(executable: string): boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ export class UnsupportedPlatform implements RelayPlatform {
|
|||||||
throw NOT_SUPPORTED
|
throw NOT_SUPPORTED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ipcSocketPath(): string {
|
||||||
|
throw NOT_SUPPORTED
|
||||||
|
}
|
||||||
|
|
||||||
removeFakeSocket(): void {
|
removeFakeSocket(): void {
|
||||||
throw NOT_SUPPORTED
|
throw NOT_SUPPORTED
|
||||||
}
|
}
|
||||||
@@ -45,6 +49,10 @@ export class UnsupportedPlatform implements RelayPlatform {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async removeStaleIpcSocket(): Promise<void> {
|
||||||
|
throw NOT_SUPPORTED
|
||||||
|
}
|
||||||
|
|
||||||
getInstanceProcess(): ProcessInfo | null {
|
getInstanceProcess(): ProcessInfo | null {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -52,4 +60,16 @@ export class UnsupportedPlatform implements RelayPlatform {
|
|||||||
getPeerProcess(): ProcessInfo | null {
|
getPeerProcess(): ProcessInfo | null {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
killProcess(): boolean {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
isProcessAlive(): boolean {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
launchExecutable(): boolean {
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+145
-3
@@ -11,6 +11,11 @@ const MIRROR_RECONNECT_DELAY_MS = 2000
|
|||||||
const SERVER_CLOSE_TIMEOUT_MS = 2000
|
const SERVER_CLOSE_TIMEOUT_MS = 2000
|
||||||
const ASSET_FETCH_RETRY_MS = 60_000
|
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 {
|
interface AppAsset {
|
||||||
id: string
|
id: string
|
||||||
type: number
|
type: number
|
||||||
@@ -46,6 +51,10 @@ async function getAppAssetMap(appId: string): Promise<Map<string, string>> {
|
|||||||
return map
|
return map
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
|
|
||||||
function clearActivityPayload(pid: number): Buffer {
|
function clearActivityPayload(pid: number): Buffer {
|
||||||
return Buffer.from(
|
return Buffer.from(
|
||||||
JSON.stringify({ cmd: 'SET_ACTIVITY', args: { pid, activity: null }, nonce: randomUUID() }),
|
JSON.stringify({ cmd: 'SET_ACTIVITY', args: { pid, activity: null }, nonce: randomUUID() }),
|
||||||
@@ -82,6 +91,7 @@ export interface RelayInstance {
|
|||||||
path: string
|
path: string
|
||||||
isPrimary: boolean
|
isPrimary: boolean
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
|
locked: boolean
|
||||||
process: ProcessInfo | null
|
process: ProcessInfo | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,6 +142,9 @@ export interface RelayStatus {
|
|||||||
instances: RelayInstance[]
|
instances: RelayInstance[]
|
||||||
connectedClients: ConnectedClient[]
|
connectedClients: ConnectedClient[]
|
||||||
blacklistedApps: BlacklistedApp[]
|
blacklistedApps: BlacklistedApp[]
|
||||||
|
lockedPrimary: string | null
|
||||||
|
reordering: boolean
|
||||||
|
primaryOutOfOrder: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,15 +179,21 @@ export class RpcRelay extends EventEmitter {
|
|||||||
private fakeOwned = false
|
private fakeOwned = false
|
||||||
private lastError: string | null = null
|
private lastError: string | null = null
|
||||||
private restarting = false
|
private restarting = false
|
||||||
|
private lockedPrimary: string | null = null
|
||||||
|
private reordering = false
|
||||||
|
|
||||||
getStatus(): RelayStatus {
|
getStatus(): RelayStatus {
|
||||||
const instances: RelayInstance[] = this.claimed.map(({ index, path: socketPath }, i) => ({
|
const instances: RelayInstance[] = this.claimed.map(({ index, path: socketPath }, i) => {
|
||||||
|
const process = platform.getInstanceProcess(index)
|
||||||
|
return {
|
||||||
index,
|
index,
|
||||||
path: socketPath,
|
path: socketPath,
|
||||||
isPrimary: i === 0,
|
isPrimary: i === 0,
|
||||||
enabled: i === 0 || !this.disabledMirrors.has(index),
|
enabled: i === 0 || !this.disabledMirrors.has(index),
|
||||||
process: platform.getInstanceProcess(index)
|
locked: this.isLockedInstance(process),
|
||||||
}))
|
process
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
running: this.running,
|
running: this.running,
|
||||||
@@ -189,10 +208,27 @@ export class RpcRelay extends EventEmitter {
|
|||||||
activity: s.activity
|
activity: s.activity
|
||||||
})),
|
})),
|
||||||
blacklistedApps: this.getBlacklistedApps(),
|
blacklistedApps: this.getBlacklistedApps(),
|
||||||
|
lockedPrimary: this.lockedPrimary,
|
||||||
|
reordering: this.reordering,
|
||||||
|
primaryOutOfOrder: this.primaryOutOfOrder(instances),
|
||||||
error: this.lastError
|
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 {
|
setMirrorEnabled(index: number, enabled: boolean): RelayStatus {
|
||||||
if (index === this.primaryIndex()) return this.getStatus()
|
if (index === this.primaryIndex()) return this.getStatus()
|
||||||
|
|
||||||
@@ -214,6 +250,33 @@ export class RpcRelay extends EventEmitter {
|
|||||||
this.disabledMirrors = new Set(indices)
|
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<RelayStatus> {
|
||||||
|
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[] {
|
getBlacklistedApps(): BlacklistedApp[] {
|
||||||
return [...this.blacklistedApps].map(([id, name]) => ({ id, name }))
|
return [...this.blacklistedApps].map(([id, name]) => ({ id, name }))
|
||||||
}
|
}
|
||||||
@@ -588,6 +651,85 @@ export class RpcRelay extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async reorderForPrimary(): Promise<RelayStatus> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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 {
|
private mirrorFrame(session: ClientSession, frame: Frame): 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'
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ const api = {
|
|||||||
ipcRenderer.invoke('relay:set-mirror-enabled', index, enabled),
|
ipcRenderer.invoke('relay:set-mirror-enabled', index, enabled),
|
||||||
setAppBlacklisted: (appId: string, blacklisted: boolean): Promise<RelayStatus> =>
|
setAppBlacklisted: (appId: string, blacklisted: boolean): Promise<RelayStatus> =>
|
||||||
ipcRenderer.invoke('relay:set-app-blacklisted', appId, blacklisted),
|
ipcRenderer.invoke('relay:set-app-blacklisted', appId, blacklisted),
|
||||||
|
unlockPrimary: (): Promise<RelayStatus> => ipcRenderer.invoke('relay:unlock-primary'),
|
||||||
|
promoteToPrimary: (index: number): Promise<RelayStatus> =>
|
||||||
|
ipcRenderer.invoke('relay:promote-to-primary', index),
|
||||||
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)
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ const EMPTY_STATUS: RelayStatus = {
|
|||||||
instances: [],
|
instances: [],
|
||||||
connectedClients: [],
|
connectedClients: [],
|
||||||
blacklistedApps: [],
|
blacklistedApps: [],
|
||||||
|
lockedPrimary: null,
|
||||||
|
reordering: false,
|
||||||
|
primaryOutOfOrder: false,
|
||||||
error: null
|
error: null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,6 +269,14 @@ export function App(): React.JSX.Element {
|
|||||||
setStatus(await window.api.setAppBlacklisted(appId, blacklisted))
|
setStatus(await window.api.setAppBlacklisted(appId, blacklisted))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onUnlockPrimary = async (): Promise<void> => {
|
||||||
|
setStatus(await window.api.unlockPrimary())
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPromote = async (index: number): Promise<void> => {
|
||||||
|
setStatus(await window.api.promoteToPrimary(index))
|
||||||
|
}
|
||||||
|
|
||||||
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))
|
||||||
}
|
}
|
||||||
@@ -411,13 +422,22 @@ export function App(): React.JSX.Element {
|
|||||||
<InstanceRow
|
<InstanceRow
|
||||||
key={instance.index}
|
key={instance.index}
|
||||||
instance={instance}
|
instance={instance}
|
||||||
|
reordering={status.reordering}
|
||||||
onToggleMirror={onToggleMirror}
|
onToggleMirror={onToggleMirror}
|
||||||
|
onUnlock={onUnlockPrimary}
|
||||||
|
onPromote={onPromote}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="text-zinc-500">No Discord instances detected</div>
|
<div className="text-zinc-500">No Discord instances detected</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{status.reordering && (
|
||||||
|
<div className="mt-1 text-xs text-amber-300/90">
|
||||||
|
Restarting Discord clients to promote the new primary…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</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">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Info } from 'lucide-react'
|
import { ArrowUpToLine, Info, Lock } from 'lucide-react'
|
||||||
import type { RelayInstance } from '../../../main/relay'
|
import type { RelayInstance } from '../../../main/relay'
|
||||||
import { Toggle } from './Toggle'
|
import { Toggle } from './Toggle'
|
||||||
|
import { Button } from './Button'
|
||||||
|
|
||||||
function shortPath(p: string): string {
|
function shortPath(p: string): string {
|
||||||
return p.split('/').pop() ?? p
|
return p.split('/').pop() ?? p
|
||||||
@@ -8,14 +9,54 @@ function shortPath(p: string): string {
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
instance: RelayInstance
|
instance: RelayInstance
|
||||||
|
reordering: boolean
|
||||||
onToggleMirror: (index: number, enabled: boolean) => Promise<void>
|
onToggleMirror: (index: number, enabled: boolean) => Promise<void>
|
||||||
|
onUnlock: () => Promise<void>
|
||||||
|
onPromote: (index: number) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
const procLabel = instance.process
|
||||||
? `${instance.process.name} (pid ${instance.process.pid})`
|
? `${instance.process.name} (pid ${instance.process.pid})`
|
||||||
: 'unknown process'
|
: 'unknown process'
|
||||||
|
|
||||||
|
const canPromote = instance.process?.executable != null
|
||||||
|
|
||||||
|
const promoteButton = (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="p-1 shrink-0"
|
||||||
|
disabled={!canPromote || reordering}
|
||||||
|
onClick={() => onPromote(instance.index)}
|
||||||
|
aria-label="Promote to primary"
|
||||||
|
title={
|
||||||
|
!canPromote
|
||||||
|
? 'Cannot resolve this Discord’s executable path'
|
||||||
|
: 'Make this Discord primary: restarts Discord clients so it starts first.'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ArrowUpToLine className="w-4 h-4 text-emerald-400" />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
|
||||||
|
const unlockButton = (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="p-1 shrink-0"
|
||||||
|
onClick={() => onUnlock()}
|
||||||
|
aria-label="Unlock primary"
|
||||||
|
title="Locked as primary. Click to unlock."
|
||||||
|
>
|
||||||
|
<Lock className="w-4 h-4 text-amber-400" />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
|
||||||
const info = (
|
const info = (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -29,6 +70,7 @@ export function InstanceRow({ instance, onToggleMirror }: Props): React.JSX.Elem
|
|||||||
mirror #{instance.index}
|
mirror #{instance.index}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{instance.locked && <span className="text-xs text-amber-400">locked</span>}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-zinc-500">{procLabel}</span>
|
<span className="text-xs text-zinc-500">{procLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -38,14 +80,19 @@ export function InstanceRow({ instance, onToggleMirror }: Props): React.JSX.Elem
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
{info}
|
{info}
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
{instance.locked ? (
|
||||||
|
unlockButton
|
||||||
|
) : (
|
||||||
<Info className="w-4 h-4 text-zinc-400 hover:text-zinc-100 cursor-help transition-colors">
|
<Info className="w-4 h-4 text-zinc-400 hover:text-zinc-100 cursor-help transition-colors">
|
||||||
<title>
|
<title>
|
||||||
The primary instance gets full passthrough and can't be turned off. It's
|
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
|
the Discord that started first (discord-ipc-0). Use Promote on another instance to
|
||||||
instance primary, close this one first so the other claims that slot, then restart the
|
make it primary instead.
|
||||||
relay.
|
|
||||||
</title>
|
</title>
|
||||||
</Info>
|
</Info>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -53,10 +100,14 @@ export function InstanceRow({ instance, onToggleMirror }: Props): React.JSX.Elem
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
{info}
|
{info}
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
{instance.locked && unlockButton}
|
||||||
|
{promoteButton}
|
||||||
<Toggle
|
<Toggle
|
||||||
checked={instance.enabled}
|
checked={instance.enabled}
|
||||||
onChange={(checked) => onToggleMirror(instance.index, checked)}
|
onChange={(checked) => onToggleMirror(instance.index, checked)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user