From f745641af2a471484741216353fafa28c576b251 Mon Sep 17 00:00:00 2001 From: Trapston3 Date: Tue, 17 Mar 2026 21:51:28 +0530 Subject: [PATCH] feat: implement fully automated native background server and websocket bridge --- src/userplugins/customkeybinds/index.ts | 84 +++++++++++++++++++--- src/userplugins/customkeybinds/native.ts | 91 ++++++++++++++++++++++++ 2 files changed, 165 insertions(+), 10 deletions(-) create mode 100644 src/userplugins/customkeybinds/native.ts diff --git a/src/userplugins/customkeybinds/index.ts b/src/userplugins/customkeybinds/index.ts index fcfd02985c..9e0db6e9c0 100644 --- a/src/userplugins/customkeybinds/index.ts +++ b/src/userplugins/customkeybinds/index.ts @@ -5,18 +5,27 @@ */ import { Logger } from "@utils/Logger"; -import definePlugin from "@utils/types"; +import definePlugin, { PluginNative } from "@utils/types"; import { findByProps } from "@webpack"; import { FluxDispatcher, showToast, Toasts } from "@webpack/common"; + +import type * as NativeModule from "./native"; + type BridgeAction = "TOGGLE_MUTE" | "TOGGLE_DEAFEN"; type SoundPlayer = { playSound: (sound: string, volume?: number) => void; }; type BridgeMessage = { action?: unknown; }; const logger = new Logger("CustomKeybinds"); +const Native = IS_DISCORD_DESKTOP || IS_VESKTOP + ? VencordNative.pluginHelpers.CustomKeybinds as PluginNative + : null; const SoundModule = findByProps("playSound") as Partial | undefined; const BridgeUrl = "ws://localhost:6969"; +const ReconnectDelay = 1000; let bridgeSocket: WebSocket | null = null; +let reconnectTimer: number | null = null; +let shouldReconnect = false; function getSoundPlayer(): SoundPlayer { const playSound = SoundModule?.playSound; @@ -66,6 +75,47 @@ function onBridgeMessage(event: MessageEvent) { handleToggle(payload.action); } +function clearReconnectTimer() { + if (reconnectTimer === null) return; + + window.clearTimeout(reconnectTimer); + reconnectTimer = null; +} + +function scheduleReconnect() { + if (!shouldReconnect || reconnectTimer !== null) return; + + reconnectTimer = window.setTimeout(() => { + reconnectTimer = null; + connectBridge(); + }, ReconnectDelay); +} + +function connectBridge() { + if (!shouldReconnect) return; + + const socket = new WebSocket(BridgeUrl); + bridgeSocket = socket; + + socket.addEventListener("message", onBridgeMessage); + socket.addEventListener("open", () => { + clearReconnectTimer(); + }); + socket.addEventListener("error", error => { + logger.error("Companion bridge WebSocket error:", error); + }); + socket.addEventListener("close", event => { + if (bridgeSocket === socket) { + bridgeSocket = null; + } + + if (!shouldReconnect) return; + + logger.error(`Companion bridge connection closed (code: ${event.code}). Retrying...`); + scheduleReconnect(); + }); +} + export default definePlugin({ name: "CustomKeybinds", description: "Receives true global mute and deafen toggles from the companion bridge", @@ -79,23 +129,37 @@ export default definePlugin({ return; } + shouldReconnect = true; + clearReconnectTimer(); bridgeSocket?.close(); + bridgeSocket = null; - bridgeSocket = new WebSocket(BridgeUrl); - bridgeSocket.addEventListener("message", onBridgeMessage); - bridgeSocket.addEventListener("error", error => { - logger.error("Companion bridge WebSocket error:", error); - }); - bridgeSocket.addEventListener("close", event => { - if (event.wasClean) return; + if (!Native) { + showToast("CustomKeybinds companion server auto-start is only available on desktop.", Toasts.Type.FAILURE); + return; + } - logger.error(`Companion bridge connection closed unexpectedly (code: ${event.code}).`); - showToast("CustomKeybinds lost its connection to the companion bridge.", Toasts.Type.FAILURE); + void Native.startCompanionServer().then(() => { + connectBridge(); + }).catch(error => { + logger.error("Failed to start companion bridge:", error); + showToast( + error instanceof Error ? error.message : "Failed to start the CustomKeybinds companion server.", + Toasts.Type.FAILURE + ); }); }, stop() { + shouldReconnect = false; + clearReconnectTimer(); bridgeSocket?.removeEventListener("message", onBridgeMessage); bridgeSocket?.close(); bridgeSocket = null; + + if (!Native) return; + + void Native.stopCompanionServer().catch(error => { + logger.error("Failed to stop companion bridge:", error); + }); } }); diff --git a/src/userplugins/customkeybinds/native.ts b/src/userplugins/customkeybinds/native.ts new file mode 100644 index 0000000000..d8d90a0f85 --- /dev/null +++ b/src/userplugins/customkeybinds/native.ts @@ -0,0 +1,91 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { exec } from "child_process"; +import { IpcMainInvokeEvent } from "electron"; +import { existsSync } from "fs"; +import { join, resolve } from "path"; + +function runCommand(command: string) { + return new Promise((resolvePromise, reject) => { + exec(command, error => { + if (error) { + reject(error); + return; + } + + resolvePromise(); + }); + }); +} + +function escapePowerShellString(value: string) { + return value.replaceAll("'", "''"); +} + +function getCompanionScriptPath() { + const candidates = [ + resolve(process.cwd(), "companion-bridge", "index.js"), + resolve(__dirname, "..", "companion-bridge", "index.js"), + resolve(__dirname, "..", "..", "companion-bridge", "index.js"), + resolve(__dirname, "..", "..", "..", "companion-bridge", "index.js"), + join(process.resourcesPath, "app.asar.unpacked", "companion-bridge", "index.js") + ]; + + for (const candidate of candidates) { + if (existsSync(candidate)) { + return candidate; + } + } + + throw new Error("Could not resolve companion-bridge/index.js."); +} + +function buildPowerShellCommand(script: string) { + return `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "${script.replaceAll("\"", "\\\"")}"`; +} + +export async function startCompanionServer(_: IpcMainInvokeEvent) { + if (process.platform !== "win32") { + throw new Error("CustomKeybinds companion server auto-start is only supported on Windows."); + } + + const scriptPath = escapePowerShellString(getCompanionScriptPath()); + const command = buildPowerShellCommand( + `& { + $scriptPath = '${scriptPath}' + $existing = Get-CimInstance Win32_Process | Where-Object { + $_.Name -match '^node(\\.exe)?$' -and $_.CommandLine -match [Regex]::Escape($scriptPath) + } | Select-Object -First 1 + + if (-not $existing) { + Start-Process node -ArgumentList $scriptPath -Verb RunAs -WindowStyle Hidden | Out-Null + } + }` + ); + + await runCommand(command); +} + +export async function stopCompanionServer(_: IpcMainInvokeEvent) { + if (process.platform !== "win32") { + return; + } + + const scriptPath = escapePowerShellString(getCompanionScriptPath()); + const command = buildPowerShellCommand( + `& { + $scriptPath = '${scriptPath}' + Get-CimInstance Win32_Process | Where-Object { + $_.Name -match '^node(\\.exe)?$' -and $_.CommandLine -match [Regex]::Escape($scriptPath) + } | ForEach-Object { + Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue + } + }` + ); + + await runCommand(command); +}