Add headless puppet bridge: control socket, renderer handlers, MCP daemon
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* Vesktop, a desktop app aiming to give you a snappier Discord Experience
|
||||
* Copyright (c) 2026 Vendicated and Vesktop contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { app } from "electron";
|
||||
import { rmSync } from "fs";
|
||||
import { rm } from "fs/promises";
|
||||
import { createServer, Server, Socket } from "net";
|
||||
import { join } from "path";
|
||||
import { IpcCommands } from "shared/IpcEvents";
|
||||
|
||||
import { sendRendererCommand } from "./ipcCommands";
|
||||
|
||||
const { env, platform } = process;
|
||||
|
||||
let server: Server;
|
||||
|
||||
const SOCKET_PATH =
|
||||
platform === "win32"
|
||||
? "\\\\?\\pipe\\vesktop.sock"
|
||||
: join(env.XDG_RUNTIME_DIR || env.TMPDIR || env.TMP || env.TEMP || "/tmp", "vesktop.sock");
|
||||
|
||||
/** JSON command name -> renderer IpcCommand. Extend here to expose more interactions. */
|
||||
const COMMANDS: Record<string, IpcCommands> = {
|
||||
"state:get": IpcCommands.STATE_GET,
|
||||
"message:send": IpcCommands.MESSAGE_SEND,
|
||||
"message:read": IpcCommands.MESSAGE_READ,
|
||||
"dm:send": IpcCommands.DM_SEND
|
||||
};
|
||||
|
||||
export async function initSocket() {
|
||||
await rm(SOCKET_PATH, { force: true });
|
||||
|
||||
server = createServer(socket => {
|
||||
socket.setEncoding("utf-8");
|
||||
let buffer = "";
|
||||
|
||||
socket.on("data", (chunk: string) => {
|
||||
buffer += chunk;
|
||||
|
||||
let newline: number;
|
||||
while ((newline = buffer.indexOf("\n")) !== -1) {
|
||||
const line = buffer.slice(0, newline).trim();
|
||||
buffer = buffer.slice(newline + 1);
|
||||
if (line) void handleLine(socket, line);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("error", () => socket.destroy());
|
||||
});
|
||||
|
||||
server.listen(SOCKET_PATH);
|
||||
server.on("error", err => {
|
||||
console.error("Vesktop IPC Socket Error:", err);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleLine(socket: Socket, line: string) {
|
||||
let request: { id?: string | number; command?: string; data?: unknown };
|
||||
try {
|
||||
request = JSON.parse(line);
|
||||
} catch {
|
||||
socket.write(JSON.stringify({ ok: false, error: "Invalid JSON" }) + "\n");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await dispatch(request.command, request.data);
|
||||
socket.write(JSON.stringify({ id: request.id, ok: true, data }) + "\n");
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e.message : String(e);
|
||||
socket.write(JSON.stringify({ id: request.id, ok: false, error }) + "\n");
|
||||
}
|
||||
}
|
||||
|
||||
async function dispatch(command: string | undefined, data: unknown) {
|
||||
if (command === "ping") return "pong";
|
||||
|
||||
const ipcCommand = command && COMMANDS[command];
|
||||
if (!ipcCommand) throw new Error(`Unknown command: ${command}`);
|
||||
|
||||
return sendRendererCommand(ipcCommand, data);
|
||||
}
|
||||
|
||||
app.on("will-quit", () => {
|
||||
server?.close();
|
||||
rmSync(SOCKET_PATH, { force: true });
|
||||
});
|
||||
@@ -14,6 +14,7 @@ import { app, BrowserWindow, nativeTheme } from "electron";
|
||||
|
||||
import { DATA_DIR } from "./constants";
|
||||
import { createFirstLaunchTour } from "./firstLaunch";
|
||||
import { initSocket } from "./ipcSocket";
|
||||
import { createWindows, mainWin } from "./mainWindow";
|
||||
import { registerMediaPermissionsHandler } from "./mediaPermissions";
|
||||
import { registerScreenShareHandler } from "./screenShare";
|
||||
@@ -105,6 +106,7 @@ function init() {
|
||||
|
||||
registerScreenShareHandler();
|
||||
registerMediaPermissionsHandler();
|
||||
initSocket();
|
||||
|
||||
bootstrap();
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { SettingsRouter } from "@vencord/types/webpack/common";
|
||||
import { openPrivateChannel, sendMessage } from "@vencord/types/utils";
|
||||
import { ChannelStore, GuildStore, MessageStore, SettingsRouter, UserStore } from "@vencord/types/webpack/common";
|
||||
import { IpcCommands } from "shared/IpcEvents";
|
||||
|
||||
import { openScreenSharePicker } from "./components/ScreenSharePicker";
|
||||
@@ -52,3 +53,68 @@ onIpcCommand(IpcCommands.NAVIGATE_SETTINGS, () => {
|
||||
onIpcCommand(IpcCommands.GET_LANGUAGES, () => navigator.languages);
|
||||
|
||||
onIpcCommand(IpcCommands.SCREEN_SHARE_PICKER, data => openScreenSharePicker(data.screens, data.skipPicker));
|
||||
|
||||
/* Veskslop puppet handlers */
|
||||
|
||||
onIpcCommand(IpcCommands.STATE_GET, () => {
|
||||
const user = UserStore.getCurrentUser();
|
||||
const guilds = GuildStore.getGuildsArray().map(guild => {
|
||||
const channels = Object.values(ChannelStore.getMutableBasicGuildChannelsForGuild(guild.id)).map(channel => ({
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
type: channel.type
|
||||
}));
|
||||
|
||||
return { id: guild.id, name: guild.name, channels };
|
||||
});
|
||||
|
||||
return { user: { id: user.id, username: user.username }, guilds };
|
||||
});
|
||||
|
||||
onIpcCommand(IpcCommands.MESSAGE_SEND, async (data: { channelId?: string; content?: string }) => {
|
||||
if (!data?.channelId || typeof data.content !== "string") {
|
||||
throw new Error("message:send requires { channelId, content }");
|
||||
}
|
||||
|
||||
await sendMessage(data.channelId, { content: data.content });
|
||||
return { channelId: data.channelId, sent: true };
|
||||
});
|
||||
|
||||
onIpcCommand(IpcCommands.MESSAGE_READ, (data: { channelId?: string; limit?: number }) => {
|
||||
if (!data?.channelId) throw new Error("message:read requires { channelId }");
|
||||
|
||||
const limit = Math.min(Math.max(data.limit ?? 20, 1), 100);
|
||||
const messages = MessageStore.getMessages(data.channelId)?._array ?? [];
|
||||
|
||||
return {
|
||||
channelId: data.channelId,
|
||||
cached: messages.length,
|
||||
messages: messages.slice(-limit).map(message => ({
|
||||
id: message.id,
|
||||
author: { id: message.author.id, username: message.author.username },
|
||||
content: message.content,
|
||||
timestamp: message.timestamp
|
||||
}))
|
||||
};
|
||||
});
|
||||
|
||||
onIpcCommand(IpcCommands.DM_SEND, async (data: { userId?: string; content?: string }) => {
|
||||
if (!data?.userId || typeof data.content !== "string") {
|
||||
throw new Error("dm:send requires { userId, content }");
|
||||
}
|
||||
|
||||
let channelId = ChannelStore.getDMFromUserId(data.userId);
|
||||
if (!channelId) {
|
||||
openPrivateChannel(data.userId);
|
||||
|
||||
for (let i = 0; i < 20 && !channelId; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
channelId = ChannelStore.getDMFromUserId(data.userId);
|
||||
}
|
||||
}
|
||||
|
||||
if (!channelId) throw new Error(`No DM channel for user ${data.userId} yet`);
|
||||
|
||||
await sendMessage(channelId, { content: data.content });
|
||||
return { userId: data.userId, channelId, sent: true };
|
||||
});
|
||||
|
||||
@@ -83,5 +83,11 @@ export const enum IpcCommands {
|
||||
|
||||
GET_LANGUAGES = "navigator.languages",
|
||||
|
||||
SCREEN_SHARE_PICKER = "screenshare:picker"
|
||||
SCREEN_SHARE_PICKER = "screenshare:picker",
|
||||
|
||||
/* Veskslop puppet commands */
|
||||
STATE_GET = "state:get",
|
||||
MESSAGE_SEND = "message:send",
|
||||
MESSAGE_READ = "message:read",
|
||||
DM_SEND = "dm:send"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user