From 510400e4aef54b82a9068a484d6da0b2a7078ffe Mon Sep 17 00:00:00 2001 From: Zeedif Date: Thu, 26 Feb 2026 18:02:27 -0600 Subject: [PATCH 1/3] feat(DiscordRPC): add support for multiple Discord instances - Added dynamic scanning of active Discord IPC pipes (`getAvailablePipes`). - Automatically connects to the available pipe if only one is found. - Added a dropdown in settings to select the preferred instance (e.g., Stable vs PTB) when multiple clients are open. The setting remains hidden if only one instance is active. - Improved connection error handling to prevent UI timeout alerts. --- plugins/DiscordRPC/src/Settings.tsx | 36 ++++++++- plugins/DiscordRPC/src/discord.native.ts | 94 +++++++++++++++++++++--- plugins/DiscordRPC/src/updateActivity.ts | 4 +- 3 files changed, 120 insertions(+), 14 deletions(-) diff --git a/plugins/DiscordRPC/src/Settings.tsx b/plugins/DiscordRPC/src/Settings.tsx index 52668772..0b657909 100644 --- a/plugins/DiscordRPC/src/Settings.tsx +++ b/plugins/DiscordRPC/src/Settings.tsx @@ -1,10 +1,11 @@ -import { LunaSettings, LunaSwitchSetting, LunaTextSetting } from "@luna/ui"; +import { LunaSettings, LunaSwitchSetting, LunaTextSetting, LunaSelectSetting, LunaSelectItem } from "@luna/ui"; import { ReactiveStore } from "@luna/core"; import React from "react"; import { errSignal, trace } from "."; import { updateActivity } from "./updateActivity"; +import { getAvailablePipes } from "./discord.native"; const defaultCustomStatusText = "{track} by {artist}"; @@ -13,6 +14,7 @@ export const settings = await ReactiveStore.getPluginStorage("DiscordRPC", { displayArtistIcon: true, displayPlaylistButton: true, customStatusText: defaultCustomStatusText, + pipeId: -1, }); if (!settings.customStatusText || settings.customStatusText === "") settings.customStatusText = defaultCustomStatusText; @@ -22,9 +24,41 @@ export const Settings = () => { const [displayArtistIcon, setDisplayArtistIcon] = React.useState(settings.displayArtistIcon); const [displayPlaylistButton, setDisplayPlaylistButton] = React.useState(settings.displayPlaylistButton); const [customStatusText, setCustomStatusText] = React.useState(settings.customStatusText); + + const [pipeId, setPipeId] = React.useState(settings.pipeId); + const [availablePipes, setAvailablePipes] = React.useState([]); + + React.useEffect(() => { + getAvailablePipes().then((pipes) => { + setAvailablePipes(pipes); + if (pipes.length > 0 && !pipes.includes(settings.pipeId)) { + setPipeId((settings.pipeId = pipes[0])); + updateActivity().catch(() => {}); + } + }).catch(() => {}); + }, []); return ( + {availablePipes.length > 1 && ( + { + setPipeId((settings.pipeId = parseInt(e.target.value, 10))); + updateActivity() + .then(() => (errSignal!._ = undefined)) + .catch(trace.err.withContext("Failed to set activity")); + }} + > + {availablePipes.map((pipe) => ( + + Pipe {pipe} + + ))} + + )} { +let currentPipeId: number = -1; +let isConnecting: boolean = false; + +export const getAvailablePipes = async (): Promise => { + const pipes: number[] = []; + try { + if (process.platform === "win32") { + const files = fs.readdirSync("\\\\.\\pipe\\"); + for (const file of files) { + if (file.startsWith("discord-ipc-")) { + const id = parseInt(file.replace("discord-ipc-", ""), 10); + if (!isNaN(id)) pipes.push(id); + } + } + } else { + const { XDG_RUNTIME_DIR, TMPDIR, TMP, TEMP } = process.env; + const tempDir = fs.realpathSync(XDG_RUNTIME_DIR ?? TMPDIR ?? TMP ?? TEMP ?? `${path.sep}tmp`); + const files = fs.readdirSync(tempDir); + for (const file of files) { + if (file.startsWith("discord-ipc-")) { + const id = parseInt(file.replace("discord-ipc-", ""), 10); + if (!isNaN(id)) pipes.push(id); + } + } + } + } catch { + // Ignore read errors + } + return [...new Set(pipes)].sort((a, b) => a - b); +}; + +export const getClient = async (preferredPipeId: number = -1) => { + if (isConnecting) return null; + + const availablePipes = await getAvailablePipes(); + if (availablePipes.length === 0) { + if (rpcClient) await rpcClient.destroy().catch(() => {}); + rpcClient = null; + currentPipeId = -1; + return null; + } + + const targetPipe = availablePipes.includes(preferredPipeId) ? preferredPipeId : availablePipes[0]; + + if (rpcClient && currentPipeId !== targetPipe) { + await rpcClient.destroy().catch(() => {}); + rpcClient = null; + } + const isAvailable = rpcClient && rpcClient.transport.isConnected && rpcClient.user; if (isAvailable) return rpcClient!; - if (rpcClient) await rpcClient.destroy(); - rpcClient = new Client({ clientId: "1130698654987067493" }); - await rpcClient.connect(); + if (rpcClient) await rpcClient.destroy().catch(() => {}); + + const clientOptions: ClientOptions = { + clientId: "1130698654987067493", + pipeId: targetPipe + }; + + rpcClient = new Client(clientOptions); + currentPipeId = targetPipe; + isConnecting = true; + + try { + await rpcClient.connect(); + } catch { + rpcClient = null; + } finally { + isConnecting = false; + } return rpcClient; }; -export const setActivity = async (activity?: SetActivity) => { - const client = await getClient(); - if (!client.user) return; - if (!activity) return client.user.clearActivity(); - return client.user.setActivity(activity); +export const setActivity = async (activity?: SetActivity, preferredPipeId: number = -1) => { + try { + const client = await getClient(preferredPipeId); + if (!client || !client.user) return; + if (!activity) return client.user.clearActivity(); + return await client.user.setActivity(activity); + } catch { + return; + } +}; + +export const cleanupRPC = () => { + if (rpcClient) rpcClient.destroy().catch(() => {}); }; -export const cleanupRPC = () => rpcClient?.destroy()!; export const StatusDisplayTypeEnum = () => ({ Name: StatusDisplayType.NAME, State: StatusDisplayType.STATE, diff --git a/plugins/DiscordRPC/src/updateActivity.ts b/plugins/DiscordRPC/src/updateActivity.ts index e688a028..eccf1d22 100644 --- a/plugins/DiscordRPC/src/updateActivity.ts +++ b/plugins/DiscordRPC/src/updateActivity.ts @@ -9,7 +9,7 @@ import { settings } from "./Settings"; const StatusDisplayType = await StatusDisplayTypeEnum(); export const updateActivity = async (mediaItem?: MediaItem) => { - if (!PlayState.playing && !settings.displayOnPause) return await setActivity(); + if (!PlayState.playing && !settings.displayOnPause) return await setActivity(undefined, settings.pipeId); mediaItem ??= await MediaItem.fromPlaybackContext(); if (mediaItem === undefined) return; @@ -83,5 +83,5 @@ export const updateActivity = async (mediaItem?: MediaItem) => { activity.largeImageUrl = `https://tidal.com/album/${album.id}/u`; } - await setActivity(activity); + await setActivity(activity, settings.pipeId); }; From 6c6bbc21e960dbeb7809ce0020c31b16ae4b2798 Mon Sep 17 00:00:00 2001 From: Zeedif Date: Fri, 27 Feb 2026 15:47:39 -0600 Subject: [PATCH 2/3] refactor(DiscordRPC): apply PR review suggestions for pipe detection - Extracted pipe detection logic into its own file (`getAvailablePipes.native.ts`). - Updated file system operations to use `node:fs/promises`. - Implemented `os.tmpdir()` for OS-agnostic temp directory resolution. - Optimized pipe ID collection using a `Set`. - Wrapped `getClient` with `asyncDebounce` to efficiently handle concurrent connection attempts. - Removed silent `try/catch` in `setActivity` to ensure errors properly propagate up. --- plugins/DiscordRPC/src/Settings.tsx | 3 +- plugins/DiscordRPC/src/discord.native.ts | 78 +++++-------------- .../src/getAvailablePipes.native.ts | 29 +++++++ 3 files changed, 51 insertions(+), 59 deletions(-) create mode 100644 plugins/DiscordRPC/src/getAvailablePipes.native.ts diff --git a/plugins/DiscordRPC/src/Settings.tsx b/plugins/DiscordRPC/src/Settings.tsx index 0b657909..ed42a4ca 100644 --- a/plugins/DiscordRPC/src/Settings.tsx +++ b/plugins/DiscordRPC/src/Settings.tsx @@ -5,7 +5,7 @@ import { ReactiveStore } from "@luna/core"; import React from "react"; import { errSignal, trace } from "."; import { updateActivity } from "./updateActivity"; -import { getAvailablePipes } from "./discord.native"; +import { getAvailablePipes } from "./getAvailablePipes.native"; const defaultCustomStatusText = "{track} by {artist}"; @@ -18,6 +18,7 @@ export const settings = await ReactiveStore.getPluginStorage("DiscordRPC", { }); if (!settings.customStatusText || settings.customStatusText === "") settings.customStatusText = defaultCustomStatusText; +if (settings.pipeId === undefined) settings.pipeId = -1; export const Settings = () => { const [displayOnPause, setDisplayOnPause] = React.useState(settings.displayOnPause); diff --git a/plugins/DiscordRPC/src/discord.native.ts b/plugins/DiscordRPC/src/discord.native.ts index 73edd6fa..8fba4729 100644 --- a/plugins/DiscordRPC/src/discord.native.ts +++ b/plugins/DiscordRPC/src/discord.native.ts @@ -1,52 +1,18 @@ import { Client, StatusDisplayType, type ClientOptions, type SetActivity } from "@xhayper/discord-rpc"; -import fs from "node:fs"; -import path from "node:path"; +import { asyncDebounce } from "@inrixia/helpers"; +import { getAvailablePipes } from "./getAvailablePipes.native"; let rpcClient: Client | null = null; let currentPipeId: number = -1; -let isConnecting: boolean = false; - -export const getAvailablePipes = async (): Promise => { - const pipes: number[] = []; - try { - if (process.platform === "win32") { - const files = fs.readdirSync("\\\\.\\pipe\\"); - for (const file of files) { - if (file.startsWith("discord-ipc-")) { - const id = parseInt(file.replace("discord-ipc-", ""), 10); - if (!isNaN(id)) pipes.push(id); - } - } - } else { - const { XDG_RUNTIME_DIR, TMPDIR, TMP, TEMP } = process.env; - const tempDir = fs.realpathSync(XDG_RUNTIME_DIR ?? TMPDIR ?? TMP ?? TEMP ?? `${path.sep}tmp`); - const files = fs.readdirSync(tempDir); - for (const file of files) { - if (file.startsWith("discord-ipc-")) { - const id = parseInt(file.replace("discord-ipc-", ""), 10); - if (!isNaN(id)) pipes.push(id); - } - } - } - } catch { - // Ignore read errors - } - return [...new Set(pipes)].sort((a, b) => a - b); -}; - -export const getClient = async (preferredPipeId: number = -1) => { - if (isConnecting) return null; +export const getClient = asyncDebounce(async (preferredPipeId: number = -1) => { const availablePipes = await getAvailablePipes(); - if (availablePipes.length === 0) { - if (rpcClient) await rpcClient.destroy().catch(() => {}); - rpcClient = null; - currentPipeId = -1; - return null; + + let targetPipe = preferredPipeId; + if (availablePipes.length > 0) { + targetPipe = availablePipes.includes(preferredPipeId) ? preferredPipeId : availablePipes[0]; } - const targetPipe = availablePipes.includes(preferredPipeId) ? preferredPipeId : availablePipes[0]; - if (rpcClient && currentPipeId !== targetPipe) { await rpcClient.destroy().catch(() => {}); rpcClient = null; @@ -58,34 +24,30 @@ export const getClient = async (preferredPipeId: number = -1) => { if (rpcClient) await rpcClient.destroy().catch(() => {}); const clientOptions: ClientOptions = { - clientId: "1130698654987067493", - pipeId: targetPipe + clientId: "1130698654987067493" }; + if (targetPipe >= 0) { + clientOptions.pipeId = targetPipe; + } + rpcClient = new Client(clientOptions); currentPipeId = targetPipe; - isConnecting = true; try { await rpcClient.connect(); - } catch { + return rpcClient; + } catch (error) { rpcClient = null; - } finally { - isConnecting = false; + throw error; } - - return rpcClient; -}; +}); export const setActivity = async (activity?: SetActivity, preferredPipeId: number = -1) => { - try { - const client = await getClient(preferredPipeId); - if (!client || !client.user) return; - if (!activity) return client.user.clearActivity(); - return await client.user.setActivity(activity); - } catch { - return; - } + const client = await getClient(preferredPipeId); + if (!client || !client.user) return; + if (!activity) return client.user.clearActivity(); + return await client.user.setActivity(activity); }; export const cleanupRPC = () => { diff --git a/plugins/DiscordRPC/src/getAvailablePipes.native.ts b/plugins/DiscordRPC/src/getAvailablePipes.native.ts new file mode 100644 index 00000000..8d93ad8b --- /dev/null +++ b/plugins/DiscordRPC/src/getAvailablePipes.native.ts @@ -0,0 +1,29 @@ +import { readdir } from "node:fs/promises"; +import { tmpdir } from "node:os"; + +export const getAvailablePipes = async (): Promise => { + const pipes = new Set(); + try { + if (process.platform === "win32") { + const files = await readdir("\\\\.\\pipe\\"); + for (const file of files) { + if (file.startsWith("discord-ipc-")) { + const id = parseInt(file.replace("discord-ipc-", ""), 10); + if (!isNaN(id)) pipes.add(id); + } + } + } else { + const tempDir = process.env.XDG_RUNTIME_DIR ?? tmpdir(); + const files = await readdir(tempDir); + for (const file of files) { + if (file.startsWith("discord-ipc-")) { + const id = parseInt(file.replace("discord-ipc-", ""), 10); + if (!isNaN(id)) pipes.add(id); + } + } + } + } catch { + // Ignore read errors + } + return Array.from(pipes).sort((a, b) => a - b); +}; From f5e8e93e85e8c64b21dded240f0cade596bf1d33 Mon Sep 17 00:00:00 2001 From: Zeedif Date: Sat, 28 Feb 2026 15:26:46 -0600 Subject: [PATCH 3/3] refactor(DiscordRPC): improve error handling - Removed silent `.catch(() => {})` on `destroy()` calls to properly throw errors. - Removed `try/catch` wrapper around `connect()` to let connection errors propagate up naturally. - Removed redundant `rpcClient = null` assignments. - Simplified user validation in `setActivity` using optional chaining (`client?.user`). - Refactored `cleanupRPC` to correctly bubble up potential errors. --- plugins/DiscordRPC/src/discord.native.ts | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/plugins/DiscordRPC/src/discord.native.ts b/plugins/DiscordRPC/src/discord.native.ts index 8fba4729..98c783f0 100644 --- a/plugins/DiscordRPC/src/discord.native.ts +++ b/plugins/DiscordRPC/src/discord.native.ts @@ -14,14 +14,13 @@ export const getClient = asyncDebounce(async (preferredPipeId: number = -1) => { } if (rpcClient && currentPipeId !== targetPipe) { - await rpcClient.destroy().catch(() => {}); - rpcClient = null; + await rpcClient.destroy(); } const isAvailable = rpcClient && rpcClient.transport.isConnected && rpcClient.user; if (isAvailable) return rpcClient!; - if (rpcClient) await rpcClient.destroy().catch(() => {}); + if (rpcClient) await rpcClient.destroy(); const clientOptions: ClientOptions = { clientId: "1130698654987067493" @@ -34,25 +33,18 @@ export const getClient = asyncDebounce(async (preferredPipeId: number = -1) => { rpcClient = new Client(clientOptions); currentPipeId = targetPipe; - try { - await rpcClient.connect(); - return rpcClient; - } catch (error) { - rpcClient = null; - throw error; - } + await rpcClient.connect(); + return rpcClient; }); export const setActivity = async (activity?: SetActivity, preferredPipeId: number = -1) => { const client = await getClient(preferredPipeId); - if (!client || !client.user) return; + if (!client?.user) return; if (!activity) return client.user.clearActivity(); return await client.user.setActivity(activity); }; -export const cleanupRPC = () => { - if (rpcClient) rpcClient.destroy().catch(() => {}); -}; +export const cleanupRPC = () => rpcClient?.destroy(); export const StatusDisplayTypeEnum = () => ({ Name: StatusDisplayType.NAME,