Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 74 additions & 10 deletions src/userplugins/customkeybinds/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof NativeModule>
: null;
const SoundModule = findByProps("playSound") as Partial<SoundPlayer> | 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;
Expand Down Expand Up @@ -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",
Expand All @@ -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);
});
}
});
91 changes: 91 additions & 0 deletions src/userplugins/customkeybinds/native.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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);
}
Loading