diff --git a/companion-bridge/index.js b/companion-bridge/index.js new file mode 100644 index 0000000000..7bf77720a4 --- /dev/null +++ b/companion-bridge/index.js @@ -0,0 +1,71 @@ +/* eslint-disable no-console */ + +const { GlobalKeyboardListener } = require("node-global-key-listener"); +const { WebSocketServer } = require("ws"); + +const PORT = 6969; +const pressedKeys = new Set(); + +const wss = new WebSocketServer({ host: "127.0.0.1", port: PORT }); +const keyboard = new GlobalKeyboardListener(); + +function broadcast(action) { + const payload = JSON.stringify({ action }); + + for (const client of wss.clients) { + if (client.readyState === client.OPEN) { + client.send(payload); + } + } +} + +function handleKeyDown(name) { + if (pressedKeys.has(name)) return; + + pressedKeys.add(name); + + if (name === "INSERT") { + broadcast("TOGGLE_MUTE"); + } else if (name === "HOME") { + broadcast("TOGGLE_DEAFEN"); + } +} + +function handleKeyUp(name) { + pressedKeys.delete(name); +} + +keyboard.addListener(event => { + if (event.state === "DOWN") { + handleKeyDown(event.name); + return; + } + + if (event.state === "UP") { + handleKeyUp(event.name); + } +}); + +wss.on("listening", () => { + console.log(`Companion bridge listening on ws://localhost:${PORT}`); +}); + +wss.on("connection", socket => { + socket.on("error", error => { + console.error("Companion bridge socket error:", error); + }); +}); + +wss.on("error", error => { + console.error("Companion bridge server error:", error); +}); + +function shutdown() { + keyboard.kill(); + wss.close(() => { + process.exit(0); + }); +} + +process.on("SIGINT", shutdown); +process.on("SIGTERM", shutdown); diff --git a/companion-bridge/package.json b/companion-bridge/package.json new file mode 100644 index 0000000000..386134fae5 --- /dev/null +++ b/companion-bridge/package.json @@ -0,0 +1,15 @@ +{ + "name": "companion-bridge", + "private": true, + "version": "1.0.0", + "description": "Sidecar bridge for Vencord global mute and deafen keybinds", + "main": "index.js", + "license": "GPL-3.0-or-later", + "scripts": { + "start": "node index.js" + }, + "dependencies": { + "node-global-key-listener": "^0.3.0", + "ws": "^8.18.3" + } +} diff --git a/src/userplugins/customkeybinds/index.ts b/src/userplugins/customkeybinds/index.ts index 593b8b4eb2..fcfd02985c 100644 --- a/src/userplugins/customkeybinds/index.ts +++ b/src/userplugins/customkeybinds/index.ts @@ -5,32 +5,18 @@ */ import { Logger } from "@utils/Logger"; -import definePlugin, { PluginNative } from "@utils/types"; +import definePlugin from "@utils/types"; import { findByProps } from "@webpack"; import { FluxDispatcher, showToast, Toasts } from "@webpack/common"; - -import type * as NativeModule from "./native"; - -type GlobalToggleAction = "mute" | "deafen"; +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 ToggleEventName = "vc-custom-keybinds-global-toggle"; -const ErrorEventName = "vc-custom-keybinds-global-error"; - -function getDesktopBridge(): PluginNative { - if (!Native) { - throw new Error("CustomKeybinds requires the desktop native bridge."); - } - - return Native; -} +let bridgeSocket: WebSocket | null = null; function getSoundPlayer(): SoundPlayer { const playSound = SoundModule?.playSound; @@ -42,53 +28,50 @@ function getSoundPlayer(): SoundPlayer { return { playSound }; } -function handleToggle(action: GlobalToggleAction) { +function handleToggle(action: BridgeAction) { const soundPlayer = getSoundPlayer(); FluxDispatcher.dispatch({ - type: action === "mute" + type: action === "TOGGLE_MUTE" ? "AUDIO_TOGGLE_SELF_MUTE" : "AUDIO_TOGGLE_SELF_DEAF" }); - soundPlayer.playSound(action, 0.5); + soundPlayer.playSound(action === "TOGGLE_MUTE" ? "mute" : "deafen", 0.5); } -function onGlobalToggle(event: Event) { - const action = (event as CustomEvent).detail; - - if (action !== "mute" && action !== "deafen") { - const message = `Received invalid global toggle action: ${String(action)}`; - logger.error(message); - showToast(message, Toasts.Type.FAILURE); +function onBridgeMessage(event: MessageEvent) { + if (typeof event.data !== "string") { + logger.error("Received non-text message from companion bridge:", event.data); return; } - handleToggle(action); -} + let payload: BridgeMessage; -function onNativeError(event: Event) { - const message = (event as CustomEvent).detail; + try { + payload = JSON.parse(event.data) as BridgeMessage; + } catch (error) { + logger.error("Failed to parse companion bridge message:", error); + showToast("CustomKeybinds received invalid JSON from the companion bridge.", Toasts.Type.FAILURE); + return; + } - if (typeof message !== "string" || !message.length) { - logger.error("Received invalid native bridge error payload:", message); - showToast("CustomKeybinds native bridge failed with an invalid error payload.", Toasts.Type.FAILURE); + if (payload.action !== "TOGGLE_MUTE" && payload.action !== "TOGGLE_DEAFEN") { + const message = `Received invalid companion bridge action: ${String(payload.action)}`; + logger.error(message); + showToast(message, Toasts.Type.FAILURE); return; } - logger.error(message); - showToast(message, Toasts.Type.FAILURE); + handleToggle(payload.action); } export default definePlugin({ name: "CustomKeybinds", - description: "Registers true global mute and deafen toggles for Vesktop/Discord desktop", + description: "Receives true global mute and deafen toggles from the companion bridge", authors: [{ name: "Jules", id: 0n }], start() { - let native: PluginNative; - try { - native = getDesktopBridge(); getSoundPlayer(); } catch (error) { logger.error("Failed to initialize CustomKeybinds:", error); @@ -96,27 +79,23 @@ export default definePlugin({ return; } - window.addEventListener(ToggleEventName, onGlobalToggle as EventListener); - window.addEventListener(ErrorEventName, onNativeError as EventListener); + bridgeSocket?.close(); - void native.start().catch(error => { - const message = error instanceof Error ? error.message : "Failed to start native global toggle bridge."; - logger.error("Failed to start native global toggle bridge:", error); - showToast(message, Toasts.Type.FAILURE); + bridgeSocket = new WebSocket(BridgeUrl); + bridgeSocket.addEventListener("message", onBridgeMessage); + bridgeSocket.addEventListener("error", error => { + logger.error("Companion bridge WebSocket error:", error); }); - }, - stop() { - window.removeEventListener(ToggleEventName, onGlobalToggle as EventListener); - window.removeEventListener(ErrorEventName, onNativeError as EventListener); - - if (!Native) return; + bridgeSocket.addEventListener("close", event => { + if (event.wasClean) return; - void Native.stop().catch(error => { - logger.error("Failed to stop native global toggle bridge:", error); - showToast( - error instanceof Error ? error.message : "Failed to stop native global toggle bridge.", - Toasts.Type.FAILURE - ); + logger.error(`Companion bridge connection closed unexpectedly (code: ${event.code}).`); + showToast("CustomKeybinds lost its connection to the companion bridge.", Toasts.Type.FAILURE); }); + }, + stop() { + bridgeSocket?.removeEventListener("message", onBridgeMessage); + bridgeSocket?.close(); + bridgeSocket = null; } }); diff --git a/src/userplugins/customkeybinds/native.ts b/src/userplugins/customkeybinds/native.ts deleted file mode 100644 index a747404d66..0000000000 --- a/src/userplugins/customkeybinds/native.ts +++ /dev/null @@ -1,265 +0,0 @@ -/* - * Vencord, a Discord client mod - * Copyright (c) 2026 Vendicated and contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -import { ChildProcessByStdio, spawn } from "child_process"; -import { IpcMainInvokeEvent, WebContents } from "electron"; -import { createInterface, Interface } from "readline"; -import { Readable } from "stream"; - -type GlobalToggleAction = "mute" | "deafen"; - -const ToggleEventName = "vc-custom-keybinds-global-toggle"; -const ErrorEventName = "vc-custom-keybinds-global-error"; - -interface ActiveBridge { - cleanup: () => void; - stderr: Interface; - stdout: Interface; - process: ChildProcessByStdio; - sender: WebContents; -} - -let activeBridge: ActiveBridge | null = null; - -function dispatchRendererEvent(sender: WebContents, eventName: string, detail: unknown) { - if (sender.isDestroyed()) return; - - void sender.executeJavaScript( - `window.dispatchEvent(new CustomEvent(${JSON.stringify(eventName)}, { detail: ${JSON.stringify(detail)} }));` - ); -} - -function dispatchError(sender: WebContents, message: string) { - dispatchRendererEvent(sender, ErrorEventName, message); -} - -function dispatchToggle(sender: WebContents, action: GlobalToggleAction) { - dispatchRendererEvent(sender, ToggleEventName, action); -} - -function getPowerShellScript() { - return ` -$ErrorActionPreference = "Stop" -Add-Type -TypeDefinition @" -using System; -using System.ComponentModel; -using System.Runtime.InteropServices; - -public static class VencordKeyboardHook { - private const int WH_KEYBOARD_LL = 13; - private const int WM_KEYDOWN = 0x0100; - private const int WM_KEYUP = 0x0101; - private const int WM_SYSKEYDOWN = 0x0104; - private const int WM_SYSKEYUP = 0x0105; - private const int VK_HOME = 0x24; - private const int VK_INSERT = 0x2D; - - private static readonly HookProc HookCallbackDelegate = HookCallback; - private static IntPtr hookId = IntPtr.Zero; - private static bool homeDown; - private static bool insertDown; - - public static void Run() { - hookId = SetWindowsHookEx(WH_KEYBOARD_LL, HookCallbackDelegate, GetModuleHandle(null), 0); - if (hookId == IntPtr.Zero) - throw new Win32Exception(Marshal.GetLastWin32Error()); - - try { - MSG message; - while (GetMessage(out message, IntPtr.Zero, 0, 0) > 0) { - TranslateMessage(ref message); - DispatchMessage(ref message); - } - } finally { - if (hookId != IntPtr.Zero) - UnhookWindowsHookEx(hookId); - } - } - - private static IntPtr HookCallback(int nCode, IntPtr wParam, IntPtr lParam) { - if (nCode >= 0) { - int message = wParam.ToInt32(); - KBDLLHOOKSTRUCT keyboard = Marshal.PtrToStructure(lParam); - - switch (message) { - case WM_KEYDOWN: - case WM_SYSKEYDOWN: - if (keyboard.vkCode == VK_INSERT) { - if (!insertDown) { - insertDown = true; - Console.WriteLine("mute"); - Console.Out.Flush(); - } - } else if (keyboard.vkCode == VK_HOME) { - if (!homeDown) { - homeDown = true; - Console.WriteLine("deafen"); - Console.Out.Flush(); - } - } - break; - case WM_KEYUP: - case WM_SYSKEYUP: - if (keyboard.vkCode == VK_INSERT) - insertDown = false; - else if (keyboard.vkCode == VK_HOME) - homeDown = false; - break; - } - } - - return CallNextHookEx(hookId, nCode, wParam, lParam); - } - - private delegate IntPtr HookProc(int nCode, IntPtr wParam, IntPtr lParam); - - [StructLayout(LayoutKind.Sequential)] - private struct KBDLLHOOKSTRUCT { - public int vkCode; - public int scanCode; - public int flags; - public int time; - public IntPtr dwExtraInfo; - } - - [StructLayout(LayoutKind.Sequential)] - private struct MSG { - public IntPtr hwnd; - public uint message; - public UIntPtr wParam; - public IntPtr lParam; - public uint time; - public POINT pt; - } - - [StructLayout(LayoutKind.Sequential)] - private struct POINT { - public int x; - public int y; - } - - [DllImport("user32.dll", SetLastError = true)] - private static extern IntPtr SetWindowsHookEx(int idHook, HookProc lpfn, IntPtr hMod, uint dwThreadId); - - [DllImport("user32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool UnhookWindowsHookEx(IntPtr hhk); - - [DllImport("user32.dll", SetLastError = true)] - private static extern IntPtr CallNextHookEx(IntPtr hhk, int nCode, IntPtr wParam, IntPtr lParam); - - [DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)] - private static extern IntPtr GetModuleHandle(string lpModuleName); - - [DllImport("user32.dll")] - private static extern sbyte GetMessage(out MSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax); - - [DllImport("user32.dll")] - private static extern bool TranslateMessage([In] ref MSG lpMsg); - - [DllImport("user32.dll")] - private static extern IntPtr DispatchMessage([In] ref MSG lpMsg); -} -"@ -[VencordKeyboardHook]::Run() -`; -} - -function createBridge(sender: WebContents) { - if (process.platform !== "win32") { - throw new Error( - "CustomKeybinds currently only implements non-consuming global toggles on Windows. " + - "Electron globalShortcut was intentionally not used because it can steal the keys from other apps." - ); - } - - const script = getPowerShellScript(); - const encodedCommand = Buffer.from(script, "utf16le").toString("base64"); - const child = spawn( - "powershell.exe", - ["-NoLogo", "-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-EncodedCommand", encodedCommand], - { - stdio: ["ignore", "pipe", "pipe"], - windowsHide: true - } - ); - - const stdout = createInterface({ input: child.stdout }); - const stderr = createInterface({ input: child.stderr }); - - const cleanup = () => { - stdout.close(); - stderr.close(); - - if (!child.killed) { - child.kill(); - } - - if (activeBridge?.process === child) { - activeBridge = null; - } - }; - - stdout.on("line", line => { - if (line === "mute" || line === "deafen") { - dispatchToggle(sender, line); - return; - } - - dispatchError(sender, `CustomKeybinds native bridge emitted an unknown event: ${line}`); - }); - - stderr.on("line", line => { - if (!line.length) return; - - dispatchError(sender, `CustomKeybinds native bridge error: ${line}`); - }); - - child.once("error", error => { - dispatchError(sender, `Failed to start CustomKeybinds native bridge: ${error.message}`); - cleanup(); - }); - - child.once("exit", (code, signal) => { - if (code === 0 || signal === "SIGTERM" || sender.isDestroyed()) { - cleanup(); - return; - } - - dispatchError( - sender, - `CustomKeybinds native bridge exited unexpectedly (code: ${code ?? "null"}, signal: ${signal ?? "null"}).` - ); - cleanup(); - }); - - sender.once("destroyed", cleanup); - - activeBridge = { - cleanup, - stderr, - stdout, - process: child, - sender - }; -} - -export async function start(event: IpcMainInvokeEvent) { - if (activeBridge?.sender === event.sender) { - return; - } - - activeBridge?.cleanup(); - createBridge(event.sender); -} - -export async function stop(event: IpcMainInvokeEvent) { - if (!activeBridge || activeBridge.sender !== event.sender) { - return; - } - - activeBridge.cleanup(); -}