Wire validated core bridge, migrate daemon to it, verify live

This commit is contained in:
Fisto
2026-09-11 14:15:03 +02:00
parent e62d3007d8
commit 7d6650ce4e
27 changed files with 754 additions and 266 deletions
+10 -10
View File
@@ -44,22 +44,22 @@ next steps. Read it before you start. Update it before you stop.
| Area | Status | | Area | Status |
| --- | --- | | --- | --- |
| Action protocol and renderer actions | Implemented | | Action protocol and renderer actions | Implemented |
| Main bridge, event push, hooks | Pending | | Main bridge, event push, hooks | Implemented |
| Daemon on the CDP transport | Implemented and verified live | | Daemon on the core-bridge transport | Implemented and verified live |
| Daemon on the core-bridge transport | Pending |
| QR login, pacing, trigger log | Implemented | | QR login, pacing, trigger log | Implemented |
| Auth persistence in the core | Verified, then removed during the CDP pivot. To re-add. | | Auth persistence in the core | Implemented and verified live |
| CDP transport | Kept as a development fallback |
| Voice, audio, video | Not started | | Voice, audio, video | Not started |
## Work items ## Work items
See the todo list in the current session. The main items are: See [CURRENT.md](CURRENT.md) for the live list. The main items are:
1. Wire the core bridge: `events.ts`, `index.ts`, `puppetBridge.ts`, and the upstream hooks. 1. Voice join and leave actions.
2. Re-add auth persistence: pinned `DATA_DIR`, token file, preload seeding, `VESKSLOP_HEADLESS`. 2. Audio in and out with PipeWire.
3. Move the daemon to the core-bridge transport. 3. Video streaming.
4. Add a mock core for tests. 4. Fetch-based message read.
5. Later: voice actions, audio, video, fetch-based read, client lightening, web debug UI. 5. Client lightening and the web debug UI.
## Dev commands ## Dev commands
+17 -13
View File
@@ -11,28 +11,31 @@ into the external daemon. Keep upstream divergence small.
## In progress ## In progress
- Core bridge wiring. The protocol and the renderer actions exist. The events, the main bridge, and - None. The rework milestone is complete and verified live.
the upstream hooks are not wired yet.
## Done ## Done
- Audit of the earlier Vesktop patches. `src/` was reverted to upstream. - Audit of the earlier Vesktop patches. `src/` was reverted to upstream.
- Action protocol and validated renderer actions: `src/shared/puppetProtocol.ts`, - Action protocol and validated renderer actions: `src/shared/puppetProtocol.ts`,
`src/renderer/puppet/actions.ts`. `src/renderer/puppet/actions.ts`.
- CDP feasibility proven. The daemon works on CDP. `get_state` and `send_dm` were verified live. - Core bridge wired: events, renderer init, main bridge, and the upstream hooks.
- QR login verified end-to-end. The token file works. - Auth persistence re-added: pinned `DATA_DIR`, token file, preload seeding, `VESKSLOP_HEADLESS`.
- Pacing policy (500 to 2800 ms), trigger log, and trust model logic live in the daemon. - Daemon moved to the core-bridge transport. CDP stays as a development fallback.
- Mock core and trigger test added. The daemon runs without Electron.
- Live verification (2026-09-11): `get_state` returned the real account and 7 guilds, `send_dm`
reached zzib, and a synthetic owner message flowed through the real event path into the daemon
log with the command parsed.
- Module docs in `docs/` and the agent entry point in `AGENTS.md`. - Module docs in `docs/` and the agent entry point in `AGENTS.md`.
## Next (in order) ## Next (in order)
1. Wire the core bridge: `events.ts`, `index.ts`, `puppetBridge.ts`, and the upstream hooks. 1. Keep `CURRENT.md`, `AGENTS.md`, and `docs/` in sync with each change.
2. Re-add auth persistence to the core: pinned `DATA_DIR`, token file, preload seeding, 2. Voice join and leave actions. Validated actions, then a trigger command.
`VESKSLOP_HEADLESS`. 3. Audio in and out with PipeWire.
3. Move the daemon to the core-bridge transport. Keep CDP as a development fallback. 4. Video streaming.
4. Add a mock core. Run the daemon tests without Electron. 5. Fetch-based message read. The current read is cache-only.
5. Type check, build, and run a live test. The live send target is zzib only. 6. Client lightening and the web debug UI.
6. Later: voice join and leave, audio, video, fetch-based read, client lightening, web debug UI. 7. Remote client authentication over LAN or Tailscale.
## Blocked / needs input ## Blocked / needs input
@@ -43,4 +46,5 @@ into the external daemon. Keep upstream divergence small.
- Design documents: `docs/INDEX.md` - Design documents: `docs/INDEX.md`
- Agent rules: `AGENTS.md` - Agent rules: `AGENTS.md`
- Daemon code: `poc/src/` - Daemon code: `poc/src/`
- Core bridge code: `src/shared/puppetProtocol.ts`, `src/renderer/puppet/` - Core bridge code: `src/shared/puppetProtocol.ts`, `src/renderer/puppet/`,
`src/main/puppetBridge.ts`
+6 -6
View File
@@ -46,14 +46,14 @@ when the action list changes.
| --- | --- | --- | | --- | --- | --- |
| Action protocol | `src/shared/puppetProtocol.ts` | Implemented | | Action protocol | `src/shared/puppetProtocol.ts` | Implemented |
| Validated actions | `src/renderer/puppet/actions.ts` | Implemented | | Validated actions | `src/renderer/puppet/actions.ts` | Implemented |
| Event push | `src/renderer/puppet/events.ts` | Pending | | Event push | `src/renderer/puppet/events.ts` | Implemented |
| Main bridge | `src/main/puppetBridge.ts` | Pending | | Main bridge | `src/main/puppetBridge.ts` | Implemented |
| Core hooks | `src/main/main.ts`, `src/renderer/ipcCommands.ts`, `src/shared/IpcEvents.ts`, `src/preload/VesktopNative.ts` | Pending | | Core hooks | `src/main/main.ts`, `src/renderer/ipcCommands.ts`, `src/shared/IpcEvents.ts`, `src/preload/VesktopNative.ts` | Implemented |
| Daemon (CDP transport) | `poc/src/` | Implemented and verified live | | Daemon (core-bridge transport) | `poc/src/core-client.ts` | Implemented and verified live |
| Daemon (core-bridge transport) | `poc/src/core-client.ts` | Pending | | Daemon (CDP transport) | `poc/src/cdp-client.ts` | Kept as a development fallback |
| QR login | `poc/src/qr-login.ts` | Implemented and verified live | | QR login | `poc/src/qr-login.ts` | Implemented and verified live |
| Pacing | `poc/src/pacing.ts` | Implemented | | Pacing | `poc/src/pacing.ts` | Implemented |
| Trigger log and trust list | `poc/src/communication.ts`, `poc/src/puppet.ts` | Implemented on the CDP transport | | Trigger log and trust list | `poc/src/communication.ts`, `poc/src/puppet.ts` | Implemented and verified live |
| Voice, audio, video | Not started | Not started | | Voice, audio, video | Not started | Not started |
## Data flow ## Data flow
+7 -3
View File
@@ -9,9 +9,13 @@ describes the protocol, the validation rules, and the hooks into upstream files.
| --- | --- | --- | | --- | --- | --- |
| `src/shared/puppetProtocol.ts` | Action names, error codes, limits, and shared types | Implemented | | `src/shared/puppetProtocol.ts` | Action names, error codes, limits, and shared types | Implemented |
| `src/renderer/puppet/actions.ts` | Validated action handlers and the core rate limit | Implemented | | `src/renderer/puppet/actions.ts` | Validated action handlers and the core rate limit | Implemented |
| `src/renderer/puppet/events.ts` | Event normalization and push to main | Pending | | `src/renderer/puppet/events.ts` | Event normalization and push to main | Implemented |
| `src/renderer/puppet/index.ts` | Renderer initialization | Pending | | `src/renderer/puppet/index.ts` | Renderer initialization | Implemented |
| `src/main/puppetBridge.ts` | Unix socket server, request forwarding, event fan-out | Pending | | `src/main/puppetBridge.ts` | Unix socket server, request forwarding, event fan-out | Implemented |
**Event subscription timing.** The event module must wait for the flux dispatcher with
`waitFor(filters.byProps("dispatch", "subscribe"), ...)`. A direct subscribe at module load can run
before Vencord registers the dispatcher. That mistake crashes the whole renderer bundle.
## Transport ## Transport
+21 -16
View File
@@ -6,29 +6,27 @@ The daemon runs outside Vesktop. It holds all logic. It talks to the core bridge
| File | Role | Status | | File | Role | Status |
| --- | --- | --- | | --- | --- | --- |
| `poc/src/control-bridge.ts` | MCP server, tool list, health endpoint | Implemented on CDP | | `poc/src/control-bridge.ts` | MCP server, tool list, health endpoint | Implemented |
| `poc/src/puppet.ts` | Trust list, command handling, trigger log | Implemented | | `poc/src/puppet.ts` | Trust list, command handling, trigger log | Implemented |
| `poc/src/communication.ts` | Pure log and parser logic | Implemented | | `poc/src/communication.ts` | Pure log and parser logic | Implemented |
| `poc/src/pacing.ts` | Paced queue and query coalescing | Implemented | | `poc/src/pacing.ts` | Paced queue and query coalescing | Implemented |
| `poc/src/cdp-client.ts` | Chrome DevTools Protocol client | Implemented (current transport) | | `poc/src/core-client.ts` | Client actions over the core bridge | Implemented (primary transport) |
| `poc/src/discord.ts` | Client actions over CDP | Implemented (current transport) | | `poc/src/cdp-client.ts` | Chrome DevTools Protocol client | Development fallback |
| `poc/src/core-client.ts` | Client actions over the core bridge | Pending (target transport) |
| `poc/src/qr-login.ts` | Terminal QR login | Implemented and verified | | `poc/src/qr-login.ts` | Terminal QR login | Implemented and verified |
| `poc/src/test-communication.ts` | Unit tests for the pure logic | Implemented | | `poc/src/test-communication.ts` | Unit tests for the pure logic | Implemented |
| `poc/src/cdp-probe.ts` | CDP feasibility probe | Implemented (dev tool) | | `poc/src/mock-core.ts` | Mock core bridge for daemon tests | Implemented |
| `poc/src/test-trigger.ts` | Inject a trigger event into the mock core | Implemented |
| `poc/src/cdp-probe.ts` | CDP feasibility probe | Development tool |
| `poc/src/cdp-dispatch.ts` | Run one CDP expression in the client | Development tool |
## Transport ## Transport
The daemon supports two transports. The primary transport is the validated core bridge. The daemon connects to
`vesktop-puppet.sock`. All actions go through the validated capability layer. See
[core-bridge.md](core-bridge.md).
1. **CDP (current)** — the daemon starts Vesktop with `--remote-debugging-port=9222`. The daemon The CDP transport is a development fallback. It needs no Vesktop patch. It evaluates raw code in
connects to the Discord page target. It evaluates the action code in the page and receives the page, so it has no core-side guardrails. Use it for debugging only.
events through a CDP binding. This transport needs no Vesktop patch. The project used it to
prove the concept and to test the client.
2. **Core bridge (target)** — the daemon connects to `vesktop-puppet.sock`. All actions go through
the validated capability layer. This transport gives the core-side guardrails.
The daemon logic is the same on both transports. Only the client class changes.
## Pacing ## Pacing
@@ -56,17 +54,24 @@ The daemon exposes MCP over Streamable HTTP. The default address is `http://127.
## Run commands ## Run commands
```sh ```sh
# Start the daemon. It connects to the client on CDP port 9222. # Start the daemon. It connects to the core bridge.
pnpm control:bridge pnpm control:bridge
# Start a mock core for tests without Electron.
VESKSLOP_SOCKET=/tmp/vesktop-puppet-mock.sock pnpm control:mock
# Inject a trigger event into the mock core.
VESKSLOP_SOCKET=/tmp/vesktop-puppet-mock.sock pnpm test:trigger
# Send one test DM. The only permitted live target is zzib. # Send one test DM. The only permitted live target is zzib.
pnpm poc:dm pnpm poc:dm
# Run the pure-logic unit tests. # Run the pure-logic unit tests.
pnpm test:comm pnpm test:comm
# Probe CDP access and events. # Development tools for the CDP fallback.
pnpm cdp:probe pnpm cdp:probe
CDP_EXPRESSION='...' pnpm cdp:dispatch
``` ```
## Health endpoint ## Health endpoint
+30 -7
View File
@@ -31,14 +31,35 @@ pnpm build
The build writes the application to `dist/`. The build writes the application to `dist/`.
## Mock core test
The mock core runs the daemon without Electron.
```sh
# Terminal 1: mock core
VESKSLOP_SOCKET=/tmp/vesktop-puppet-mock.sock pnpm control:mock
# Terminal 2: daemon
VESKSLOP_SOCKET=/tmp/vesktop-puppet-mock.sock pnpm control:bridge
# Terminal 3: inject an owner command
VESKSLOP_SOCKET=/tmp/vesktop-puppet-mock.sock TEST_TRIGGER_CONTENT="whitelist list" pnpm test:trigger
```
Expected result: the daemon log shows one trusted entry with the parsed command. The mock log shows
the reply (`message.send`). The reply wait is inside the 500 to 2800 ms window.
## CDP probe ## CDP probe
The probe checks the current CDP transport. Start the client with The probe checks the CDP fallback transport. Start the client with
`--remote-debugging-port=9222` first. `--remote-debugging-port=9222` first.
```sh ```sh
cd poc cd poc
pnpm cdp:probe pnpm cdp:probe
# Run one expression in the client.
CDP_EXPRESSION='Vencord.Webpack.Common.FluxDispatcher.dispatch({...})' pnpm cdp:dispatch
``` ```
A good result shows the Vencord global, the Flux dispatcher, the current user, and one binding A good result shows the Vencord global, the Flux dispatcher, the current user, and one binding
@@ -82,20 +103,22 @@ cd poc
pnpm poc:dm pnpm poc:dm
``` ```
## CDP live results ## Live results
The CDP transport was verified on 2026-09-11: The core bridge was verified live on 2026-09-11:
- `get_state` returned the real account and 7 guilds. - `get_state` returned the real account and 7 guilds.
- `send_dm` delivered a real DM to zzib. - `send_dm` delivered a real DM to zzib.
- The client ran headless under Xvfb. - A synthetic owner message flowed through the real event path. The daemon log held the entry with
- The token was seeded from the token file. the parsed command.
- The client ran headless under Xvfb with the token seeded from the token file.
The CDP transport was verified earlier with the same checks.
## Known gaps ## Known gaps
| Gap | Effect | | Gap | Effect |
| --- | --- | | --- | --- |
| Mock core is not in the tree. | The daemon tests need the real client. | | Live trigger tests need an incoming message. | Use the mock core, or send from zzib's account. |
| The trigger event path needs an incoming message. | Use zzib's account for a live test. |
| `message.read` is cache-only. | A channel that the client never opened returns zero messages. | | `message.read` is cache-only. | A channel that the client never opened returns zero messages. |
| Voice, audio, and video are not started. | No test coverage. | | Voice, audio, and video are not started. | No test coverage. |
+11 -7
View File
@@ -16,19 +16,22 @@ pnpm install
| --- | --- | | --- | --- |
| `pnpm poc:qr` | QR login. Writes the token file. | | `pnpm poc:qr` | QR login. Writes the token file. |
| `pnpm control:bridge` | Start the daemon. It listens on `http://127.0.0.1:8788/mcp`. | | `pnpm control:bridge` | Start the daemon. It listens on `http://127.0.0.1:8788/mcp`. |
| `pnpm control:mock` | Start the mock core. Test the daemon without Electron. |
| `pnpm test:trigger` | Inject a trigger event into the mock core. |
| `pnpm poc:dm` | Send one test DM to zzib. | | `pnpm poc:dm` | Send one test DM to zzib. |
| `pnpm test:comm` | Run the unit tests for the pure logic. | | `pnpm test:comm` | Run the unit tests for the pure logic. |
| `pnpm cdp:probe` | Check CDP access and event delivery. | | `pnpm cdp:probe` | Check CDP access and event delivery. |
| `pnpm cdp:dispatch` | Run one CDP expression in the client. |
| `pnpm check` | Type check the daemon code. | | `pnpm check` | Type check the daemon code. |
## Transport ## Transport
The daemon uses Chrome DevTools Protocol at this time. Start the client with The primary transport is the validated core bridge at `vesktop-puppet.sock`. The daemon sends
`--remote-debugging-port=9222`. The daemon connects to the Discord page and runs the actions in the validated actions and receives normalized events. See
real client. [../docs/core-bridge.md](../docs/core-bridge.md).
The target transport is the validated core bridge. See The CDP transport is a development fallback. It needs no Vesktop patch, and it has no
[../docs/core-bridge.md](../docs/core-bridge.md). The daemon logic is the same on both transports. core-side guardrails. Use it for debugging only.
## Pacing ## Pacing
@@ -44,8 +47,9 @@ The only permitted live-send target is user zzib. See
## Verified ## Verified
- QR login works end-to-end. The token came from a phone scan. - QR login works end-to-end. The token came from a phone scan.
- The CDP transport works. `get_state` returned the real account and 7 guilds. `send_dm` delivered - The core bridge works live. `get_state` returned the real account and 7 guilds. `send_dm` reached
a real DM to zzib. zzib. A synthetic owner message reached the daemon log with the command parsed.
- The mock path works. The daemon handles state, sends, commands, and the two log buckets.
- Pacing stayed inside the 500 to 2800 ms window. - Pacing stayed inside the 500 to 2800 ms window.
## Findings ## Findings
+4 -1
View File
@@ -10,7 +10,10 @@
"test:comm": "tsx src/test-communication.ts", "test:comm": "tsx src/test-communication.ts",
"cdp:probe": "tsx src/cdp-probe.ts", "cdp:probe": "tsx src/cdp-probe.ts",
"control:bridge": "tsx src/control-bridge.ts", "control:bridge": "tsx src/control-bridge.ts",
"check": "tsc --noEmit" "check": "tsc --noEmit",
"control:mock": "tsx src/mock-core.ts",
"test:trigger": "tsx src/test-trigger.ts",
"cdp:dispatch": "tsx src/cdp-dispatch.ts"
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.30.0", "@modelcontextprotocol/sdk": "^1.30.0",
+55
View File
@@ -0,0 +1,55 @@
/*
* Veskslop development tool — run one expression in the real client over CDP.
*
* Use it with the client started on --remote-debugging-port=9222. The main use
* is to dispatch synthetic events for end-to-end tests.
*
* Usage:
* CDP_EXPRESSION='Vencord.Webpack.Common.FluxDispatcher.dispatch({...})' pnpm cdp:dispatch
*/
import WebSocket from "ws";
const expression = process.env.CDP_EXPRESSION;
if (!expression) {
console.error("CDP_EXPRESSION is required");
process.exit(1);
}
const cdpUrl = process.env.CDP_URL ?? "http://127.0.0.1:9222";
const targets = (await (await fetch(`${cdpUrl}/json`)).json()) as any[];
const page = targets.find(target => target.type === "page" && target.url.includes("discord.com"));
if (!page) {
console.error("No Discord page target");
process.exit(1);
}
const ws = new WebSocket(page.webSocketDebuggerUrl);
await new Promise<void>((resolve, reject) => {
ws.once("open", () => resolve());
ws.once("error", reject);
});
const response = await new Promise<any>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("CDP evaluate timed out")), 10_000);
ws.on("message", raw => {
const message = JSON.parse(raw.toString());
if (message.id !== 1) return;
clearTimeout(timer);
resolve(message);
});
ws.send(
JSON.stringify({
id: 1,
method: "Runtime.evaluate",
params: { expression, returnByValue: true, awaitPromise: true }
})
);
});
console.log(JSON.stringify(response.result ?? response.error));
ws.close();
+27 -27
View File
@@ -1,13 +1,12 @@
/* /*
* Veskslop daemon — MCP control bridge over Chrome DevTools Protocol. * Veskslop daemon — MCP control bridge over the validated core bridge.
* *
* Talks to an unmodified Vesktop through --remote-debugging-port, evaluates * The daemon holds all logic. It sends validated actions to Vesktop and
* actions against the real client, receives events through a CDP binding, and * receives normalized events. See docs/core-bridge.md.
* runs the trigger/whitelist/log logic locally.
* *
* Usage: * Usage:
* VESKSLOP_TOKEN_FILE=... pnpm control:bridge * pnpm control:bridge
* CDP_URL=http://127.0.0.1:9222 MCP_PORT=8788 pnpm control:bridge * VESKSLOP_SOCKET=/path/to/vesktop-puppet.sock MCP_PORT=8788 pnpm control:bridge
*/ */
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
@@ -15,45 +14,43 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import * as z from "zod/v4"; import * as z from "zod/v4";
import { CdpClient } from "./cdp-client.js"; import { CoreClient, coreSocketPath } from "./core-client.js";
import { Discord } from "./discord.js";
import { dedupe, getQueryCount, paced, sleep } from "./pacing.js"; import { dedupe, getQueryCount, paced, sleep } from "./pacing.js";
import { Puppet } from "./puppet.js"; import { Puppet } from "./puppet.js";
const HOST = process.env.MCP_HOST ?? "127.0.0.1"; const HOST = process.env.MCP_HOST ?? "127.0.0.1";
const PORT = Number(process.env.MCP_PORT ?? 8788); const PORT = Number(process.env.MCP_PORT ?? 8788);
let discord: Discord | undefined; let core: CoreClient | undefined;
let puppet: Puppet | undefined; let puppet: Puppet | undefined;
let cdpConnected = false; let coreConnected = false;
async function connectLoop() { async function connectLoop() {
for (;;) { for (;;) {
try { try {
const cdp = await CdpClient.connect(); const client = await CoreClient.connect();
discord = new Discord(cdp); core = client;
puppet = new Puppet(discord); puppet = new Puppet(client);
await discord.installEventBridge(); client.onEvent(event => {
cdp.onEvent(event => {
puppet!.handleEvent(event).catch(error => console.error("[daemon] event error:", error)); puppet!.handleEvent(event).catch(error => console.error("[daemon] event error:", error));
}); });
cdpConnected = true; coreConnected = true;
console.log("[daemon] connected to Vesktop via CDP"); console.log(`[daemon] connected to the core bridge at ${coreSocketPath}`);
return; return;
} catch (error) { } catch (error) {
console.warn(`[daemon] waiting for Vesktop CDP: ${(error as Error).message}`); console.warn(`[daemon] waiting for the core bridge: ${(error as Error).message}`);
await sleep(2000); await sleep(2000);
} }
} }
} }
function requireDiscord(): Discord { function requireCore(): CoreClient {
if (!cdpConnected || !discord) throw new Error("Not connected to Vesktop (run it with --remote-debugging-port)"); if (!coreConnected || !core) throw new Error("Not connected to Vesktop (is the core bridge running?)");
return discord; return core;
} }
function requirePuppet(): Puppet { function requirePuppet(): Puppet {
@@ -73,7 +70,7 @@ function buildServer(): McpServer {
{ {
description: "Get the logged-in user, guilds and channels. Cache-only, no Discord queries." description: "Get the logged-in user, guilds and channels. Cache-only, no Discord queries."
}, },
async () => jsonText(await requireDiscord().getState()) async () => jsonText(await requireCore().getState())
); );
server.registerTool( server.registerTool(
@@ -86,7 +83,7 @@ function buildServer(): McpServer {
} }
}, },
async ({ channelId, content }) => { async ({ channelId, content }) => {
await paced(() => requireDiscord().sendMessage(channelId, content)); await paced(() => requireCore().sendMessage(channelId, content));
return jsonText({ channelId, sent: true, queriesUsed: getQueryCount() }); return jsonText({ channelId, sent: true, queriesUsed: getQueryCount() });
} }
); );
@@ -101,7 +98,7 @@ function buildServer(): McpServer {
} }
}, },
async ({ userId, content }) => { async ({ userId, content }) => {
const result = await paced(() => requireDiscord().sendDm(userId, content)); const result = await paced(() => requireCore().sendDm(userId, content));
return jsonText({ ...result, userId, queriesUsed: getQueryCount() }); return jsonText({ ...result, userId, queriesUsed: getQueryCount() });
} }
); );
@@ -117,7 +114,7 @@ function buildServer(): McpServer {
}, },
async ({ channelId, limit }) => { async ({ channelId, limit }) => {
const result = await dedupe<{ cached: number; messages: unknown[] }>(`read:${channelId}:${limit}`, () => const result = await dedupe<{ cached: number; messages: unknown[] }>(`read:${channelId}:${limit}`, () =>
paced(() => requireDiscord().readMessages(channelId, limit)) paced(() => requireCore().readMessages(channelId, limit))
); );
return jsonText({ ...result, channelId, queriesUsed: getQueryCount() }); return jsonText({ ...result, channelId, queriesUsed: getQueryCount() });
} }
@@ -203,7 +200,7 @@ app.delete("/mcp", (_req, res) => {
}); });
app.get("/health", (_req, res) => { app.get("/health", (_req, res) => {
res.json({ ok: true, cdpConnected, ownerId: puppet?.owner, queries: getQueryCount() }); res.json({ ok: true, coreConnected, socketPath: coreSocketPath, ownerId: puppet?.owner, queries: getQueryCount() });
}); });
app.listen(PORT, HOST, (error?: Error) => { app.listen(PORT, HOST, (error?: Error) => {
@@ -214,4 +211,7 @@ app.listen(PORT, HOST, (error?: Error) => {
console.log(`[daemon] MCP listening on http://${HOST}:${PORT}/mcp`); console.log(`[daemon] MCP listening on http://${HOST}:${PORT}/mcp`);
}); });
process.on("SIGINT", () => process.exit(0)); process.on("SIGINT", () => {
core?.close();
process.exit(0);
});
+137
View File
@@ -0,0 +1,137 @@
/*
* Veskslop daemon — client for the validated core bridge.
*
* Speaks newline-delimited JSON over the core socket. A request gets one
* response line. Events arrive as { type: "event" } lines after subscribe.
*/
import net from "node:net";
export const coreSocketPath =
process.env.VESKSLOP_SOCKET ??
(process.env.XDG_RUNTIME_DIR ? `${process.env.XDG_RUNTIME_DIR}/vesktop-puppet.sock` : "/tmp/vesktop-puppet.sock");
export class CoreError extends Error {
constructor(
public readonly code: string,
message: string
) {
super(message);
}
}
export type CoreEventListener = (event: any) => void;
interface Pending {
resolve: (value: any) => void;
reject: (error: Error) => void;
}
export class CoreClient {
private nextId = 1;
private readonly pending = new Map<number, Pending>();
private readonly listeners = new Set<CoreEventListener>();
private constructor(private readonly socket: net.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);
buffer = buffer.slice(newline + 1);
if (line) this.onLine(line);
}
});
socket.on("close", () => {
for (const request of this.pending.values()) {
request.reject(new CoreError("DISCONNECTED", "core disconnected"));
}
this.pending.clear();
});
socket.on("error", () => socket.destroy());
}
static connect(path = coreSocketPath): Promise<CoreClient> {
return new Promise((resolve, reject) => {
const socket = net.connect(path);
const client = new CoreClient(socket);
socket.once("connect", async () => {
try {
await client.request("events.subscribe");
resolve(client);
} catch (error) {
reject(error);
}
});
socket.once("error", reject);
});
}
private onLine(line: string) {
let message: any;
try {
message = JSON.parse(line);
} catch {
return;
}
if (message.type === "event") {
for (const listener of this.listeners) listener(message.event);
return;
}
const request = this.pending.get(message.id);
if (!request) return;
this.pending.delete(message.id);
if (message.ok) request.resolve(message.result);
else request.reject(new CoreError(message.error?.code ?? "UNKNOWN", message.error?.message ?? "core error"));
}
request<T = any>(action: string, params?: Record<string, unknown>): Promise<T> {
const id = this.nextId++;
return new Promise<T>((resolve, reject) => {
this.pending.set(id, { resolve, reject });
this.socket.write(JSON.stringify({ id, action, params }) + "\n");
});
}
onEvent(listener: CoreEventListener) {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
/* Convenience actions */
async getState() {
return this.request("state.get");
}
async getSelfId(): Promise<string | undefined> {
return (await this.getState())?.user?.id;
}
async sendMessage(channelId: string, content: string) {
await this.request("message.send", { channelId, content });
}
async sendDm(userId: string, content: string) {
return this.request("dm.send", { userId, content });
}
async readMessages(channelId: string, limit: number) {
return this.request("message.read", { channelId, limit });
}
close() {
this.socket.destroy();
}
}
-159
View File
@@ -1,159 +0,0 @@
/*
* Veskslop — Discord actions executed inside the real client via CDP.
*
* Every expression runs in the Discord page against Vencord's exposed globals,
* so all traffic is performed by the official client.
*/
import { CdpClient } from "./cdp-client.js";
export interface DiscordUser {
id: string;
username: string;
}
export interface DiscordChannel {
id: string;
name: string;
type: number;
}
export interface DiscordGuild {
id: string;
name: string;
channels: DiscordChannel[];
}
export interface DiscordState {
user: DiscordUser;
guilds: DiscordGuild[];
}
const stringify = (value: unknown) => JSON.stringify(value);
export class Discord {
constructor(private readonly cdp: CdpClient) {}
/** Install the page-side event pusher (idempotent across reconnects). */
async installEventBridge(): Promise<void> {
await this.cdp.evaluate(`
(() => {
if (globalThis.__veskslopBridgeInstalled) return "already";
globalThis.__veskslopBridgeInstalled = true;
const { FluxDispatcher, ChannelStore } = Vencord.Webpack.Common;
FluxDispatcher.subscribe("MESSAGE_CREATE", event => {
try {
const message = event.message;
if (!message?.author) return;
veskSlopEmit(JSON.stringify({
type: "MESSAGE_CREATE",
id: message.id,
channelId: event.channelId,
guildId: event.guildId ?? null,
channelType: ChannelStore.getChannel(event.channelId)?.type ?? null,
author: {
id: message.author.id,
username: message.author.username,
bot: !!message.author.bot
},
content: message.content ?? "",
mentions: [
...(message.mentions ?? []).map(user => user.id),
...(message.mentionIds ?? [])
],
timestamp: message.timestamp ?? new Date().toISOString()
}));
} catch {
// never break the client because of the bridge
}
});
return "installed";
})()
`);
}
async getState(): Promise<DiscordState | null> {
return this.cdp.evaluate<DiscordState | null>(`
(() => {
const { UserStore, GuildStore, ChannelStore } = Vencord.Webpack.Common;
const user = UserStore.getCurrentUser();
if (!user) return null;
return {
user: { id: user.id, username: user.username },
guilds: GuildStore.getGuildsArray().map(guild => ({
id: guild.id,
name: guild.name,
channels: Object.values(ChannelStore.getMutableBasicGuildChannelsForGuild(guild.id)).map(channel => ({
id: channel.id,
name: channel.name,
type: channel.type
}))
}))
};
})()
`);
}
async getSelfId(): Promise<string | undefined> {
return (await this.getState())?.user.id;
}
async sendMessage(channelId: string, content: string): Promise<void> {
await this.cdp.evaluate(`
Vencord.Util.sendMessage(${stringify(channelId)}, { content: ${stringify(content)} }, false)
`);
}
async sendDm(userId: string, content: string): Promise<{ sent: boolean; channelId?: string; error?: string }> {
return this.cdp.evaluate(`
(async () => {
const { ChannelStore } = Vencord.Webpack.Common;
const { openPrivateChannel, sendMessage } = Vencord.Util;
let channelId = ChannelStore.getDMFromUserId(${stringify(userId)});
if (!channelId) {
openPrivateChannel(${stringify(userId)});
for (let i = 0; i < 20 && !channelId; i++) {
await new Promise(resolve => setTimeout(resolve, 100));
channelId = ChannelStore.getDMFromUserId(${stringify(userId)});
}
}
if (!channelId) return { sent: false, error: "No DM channel yet" };
await sendMessage(channelId, { content: ${stringify(content)} }, false);
return { sent: true, channelId };
})()
`);
}
async readMessages(channelId: string, limit: number) {
return this.cdp.evaluate(`
(() => {
const messages = Vencord.Webpack.Common.MessageStore.getMessages(${stringify(channelId)})?._array ?? [];
return {
cached: messages.length,
messages: messages.slice(${-Math.abs(limit)}).map(message => ({
id: message.id,
author: { id: message.author.id, username: message.author.username },
content: message.content,
timestamp: message.timestamp
}))
};
})()
`);
}
/** Seed the auth token and reload the client. Used for fresh profiles. */
async seedToken(token: string): Promise<void> {
await this.cdp.evaluate(`
localStorage.setItem("token", ${stringify(JSON.stringify(token))});
location.reload();
"reloading"
`);
}
}
+109
View File
@@ -0,0 +1,109 @@
/*
* Veskslop — mock core bridge for daemon tests.
*
* Implements the validated protocol with fake data. Use it to test the daemon
* without Electron or Discord. The `debug.emit` action injects an event into
* all subscribers and exists only in the mock.
*
* Usage:
* VESKSLOP_SOCKET=/tmp/vesktop-puppet-mock.sock pnpm control:mock
*/
import { rmSync } from "node:fs";
import { createServer, Socket } from "node:net";
const socketPath = process.env.VESKSLOP_SOCKET ?? "/tmp/vesktop-puppet-mock.sock";
const subscribers = new Set<Socket>();
const FAKE_GUILDS = [
{
id: "100000000000000001",
name: "Mock Guild",
channels: [
{ id: "200000000000000001", name: "general", type: 0 },
{ id: "200000000000000002", name: "voice", type: 2 }
]
}
];
const FAKE_MESSAGES = [
{
id: "300000000000000001",
author: { id: "1", username: "mockuser" },
content: "hello from the mock",
timestamp: new Date().toISOString()
}
];
function handle(action: string | undefined, params: any) {
switch (action) {
case "ping":
return "pong";
case "state.get":
return { user: { id: "999", username: "mockuser" }, guilds: FAKE_GUILDS };
case "message.send":
return { channelId: params?.channelId, sent: true };
case "message.read":
return { cached: FAKE_MESSAGES.length, messages: FAKE_MESSAGES };
case "dm.send":
return { userId: params?.userId, channelId: `dm-${params?.userId}`, sent: true };
case "debug.emit": {
const line = JSON.stringify({ type: "event", event: params?.event }) + "\n";
for (const subscriber of subscribers) subscriber.write(line);
return { emitted: true };
}
default:
throw new Error(`Unknown action: ${action}`);
}
}
rmSync(socketPath, { force: true });
const 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) continue;
try {
const request = JSON.parse(line);
if (request.action === "events.subscribe") {
subscribers.add(socket);
socket.write(JSON.stringify({ id: request.id, ok: true, result: { subscribed: true } }) + "\n");
console.log(`[mock] ${new Date().toISOString()} events.subscribe`);
continue;
}
const result = handle(request.action, request.params);
console.log(`[mock] ${new Date().toISOString()} ${request.action}`);
socket.write(JSON.stringify({ id: request.id, ok: true, result }) + "\n");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.log(`[mock] ${new Date().toISOString()} ERROR ${message}`);
socket.write(JSON.stringify({ ok: false, error: { code: "UNKNOWN_ACTION", message } }) + "\n");
}
}
});
socket.on("error", () => subscribers.delete(socket));
socket.on("close", () => subscribers.delete(socket));
});
server.listen(socketPath, () => {
console.log(`[mock] listening on ${socketPath}`);
});
process.on("SIGINT", () => {
server.close();
rmSync(socketPath, { force: true });
process.exit(0);
});
+7 -2
View File
@@ -15,7 +15,12 @@ import {
type ParsedCommand, type ParsedCommand,
type PuppetMessage type PuppetMessage
} from "./communication.js"; } from "./communication.js";
import type { Discord } from "./discord.js";
/** The subset of the transport that the puppet logic needs. */
export interface PuppetTransport {
getSelfId(): Promise<string | undefined>;
sendMessage(channelId: string, content: string): Promise<void>;
}
const DM_CHANNEL = 1; const DM_CHANNEL = 1;
const GROUP_DM_CHANNEL = 3; const GROUP_DM_CHANNEL = 3;
@@ -36,7 +41,7 @@ export class Puppet {
private whitelistIds: string[] = []; private whitelistIds: string[] = [];
private selfId?: string; private selfId?: string;
constructor(private readonly discord: Discord) { constructor(private readonly discord: PuppetTransport) {
try { try {
const state = JSON.parse(readFileSync(STATE_FILE, "utf8")) as PersistedState; const state = JSON.parse(readFileSync(STATE_FILE, "utf8")) as PersistedState;
if (state.ownerId) this.ownerId = state.ownerId; if (state.ownerId) this.ownerId = state.ownerId;
+6 -9
View File
@@ -1,5 +1,5 @@
/* /*
* Veskslop — live DM send test through a running client. * Veskslop — live DM send test through the core bridge.
* *
* Sanctioned test target: user zzib (defaults to their user ID). * Sanctioned test target: user zzib (defaults to their user ID).
* *
@@ -8,8 +8,7 @@
* TEST_DM_USER_ID=... TEST_DM_CONTENT="hello" pnpm poc:dm * TEST_DM_USER_ID=... TEST_DM_CONTENT="hello" pnpm poc:dm
*/ */
import { CdpClient } from "./cdp-client.js"; import { CoreClient } from "./core-client.js";
import { Discord } from "./discord.js";
/** zzib — the only sanctioned live-send target. */ /** zzib — the only sanctioned live-send target. */
const DEFAULT_TEST_DM_USER_ID = "177117296140091393"; const DEFAULT_TEST_DM_USER_ID = "177117296140091393";
@@ -17,10 +16,8 @@ const DEFAULT_TEST_DM_USER_ID = "177117296140091393";
const userId = process.env.TEST_DM_USER_ID ?? DEFAULT_TEST_DM_USER_ID; const userId = process.env.TEST_DM_USER_ID ?? DEFAULT_TEST_DM_USER_ID;
const content = process.env.TEST_DM_CONTENT ?? "veskslop test dm"; const content = process.env.TEST_DM_CONTENT ?? "veskslop test dm";
const cdp = await CdpClient.connect(); const core = await CoreClient.connect();
const discord = new Discord(cdp); console.log("[dm] connected to the core bridge");
console.log("[dm]", await core.sendDm(userId, content));
console.log("[dm] connected via CDP"); core.close();
console.log("[dm]", await discord.sendDm(userId, content));
cdp.close();
+42
View File
@@ -0,0 +1,42 @@
/*
* Veskslop — inject a synthetic trigger event into the mock core.
* Use it to test the daemon trigger path without Discord.
*
* Usage:
* VESKSLOP_SOCKET=/tmp/vesktop-puppet-mock.sock pnpm test:trigger
* TEST_TRIGGER_CONTENT="whitelist add @123" pnpm test:trigger
*/
import net from "node:net";
import { coreSocketPath } from "./core-client.js";
const authorId = process.env.TEST_TRIGGER_AUTHOR ?? "177117296140091393";
const content = process.env.TEST_TRIGGER_CONTENT ?? "whitelist list";
const event = {
type: "MESSAGE_CREATE",
id: `30000000000000${Date.now() % 10000}`,
channelId: "805255536223715370",
guildId: null,
channelType: 1,
author: { id: authorId, username: "zzib", bot: false },
content,
mentions: [],
timestamp: new Date().toISOString()
};
await new Promise<void>((resolve, reject) => {
const socket = net.connect(coreSocketPath);
socket.setEncoding("utf-8");
socket.once("connect", () =>
socket.write(JSON.stringify({ id: 1, action: "debug.emit", params: { event } }) + "\n")
);
socket.once("data", data => {
console.log("[trigger]", data.toString().trim());
socket.destroy();
resolve();
});
socket.once("error", reject);
});
+6 -1
View File
@@ -18,10 +18,15 @@ export const PORTABLE =
!existsSync(join(vesktopDir, "Uninstall Vesktop.exe")); !existsSync(join(vesktopDir, "Uninstall Vesktop.exe"));
export const DATA_DIR = export const DATA_DIR =
process.env.VENCORD_USER_DATA_DIR || (PORTABLE ? join(vesktopDir, "Data") : join(app.getPath("userData"))); process.env.VENCORD_USER_DATA_DIR ||
process.env.VESKSLOP_DATA_DIR ||
(PORTABLE ? join(vesktopDir, "Data") : join(app.getPath("appData"), "veskslop"));
mkdirSync(DATA_DIR, { recursive: true }); mkdirSync(DATA_DIR, { recursive: true });
/** Durable auth credential. The preload seeds it into the client before boot. */
export const TOKEN_FILE = process.env.VESKSLOP_TOKEN_FILE || join(DATA_DIR, "token");
export const SESSION_DATA_DIR = join(DATA_DIR, "sessionData"); export const SESSION_DATA_DIR = join(DATA_DIR, "sessionData");
app.setPath("sessionData", SESSION_DATA_DIR); app.setPath("sessionData", SESSION_DATA_DIR);
+12
View File
@@ -26,6 +26,7 @@ import { join } from "path";
import { IpcEvents } from "../shared/IpcEvents"; import { IpcEvents } from "../shared/IpcEvents";
import { setBadgeCount } from "./appBadge"; import { setBadgeCount } from "./appBadge";
import { autoStart } from "./autoStart"; import { autoStart } from "./autoStart";
import { TOKEN_FILE } from "./constants";
import { enableHardwareAcceleration } from "./main"; import { enableHardwareAcceleration } from "./main";
import { mainWin } from "./mainWindow"; import { mainWin } from "./mainWindow";
import { Settings, State } from "./settings"; import { Settings, State } from "./settings";
@@ -60,6 +61,17 @@ handleSync(IpcEvents.GET_SETTINGS, () => Settings.plain);
handleSync(IpcEvents.GET_VERSION, () => app.getVersion()); handleSync(IpcEvents.GET_VERSION, () => app.getVersion());
handleSync(IpcEvents.GET_ENABLE_HARDWARE_ACCELERATION, () => enableHardwareAcceleration); handleSync(IpcEvents.GET_ENABLE_HARDWARE_ACCELERATION, () => enableHardwareAcceleration);
handleSync(IpcEvents.VESKSLOP_GET_TOKEN, () => {
let token: string | undefined;
try {
token = readFileSync(TOKEN_FILE, "utf8").trim() || undefined;
} catch {
// No token file: the client falls back to its normal login.
}
return { token, force: process.env.VESKSLOP_FORCE_TOKEN === "1" };
});
handleSync( handleSync(
IpcEvents.SUPPORTS_WINDOWS_TRANSPARENCY, IpcEvents.SUPPORTS_WINDOWS_TRANSPARENCY,
() => process.platform === "win32" && Number(release().split(".").pop()) >= 22621 () => process.platform === "win32" && Number(release().split(".").pop()) >= 22621
+5 -3
View File
@@ -16,6 +16,7 @@ import { DATA_DIR } from "./constants";
import { createFirstLaunchTour } from "./firstLaunch"; import { createFirstLaunchTour } from "./firstLaunch";
import { createWindows, mainWin } from "./mainWindow"; import { createWindows, mainWin } from "./mainWindow";
import { registerMediaPermissionsHandler } from "./mediaPermissions"; import { registerMediaPermissionsHandler } from "./mediaPermissions";
import { initPuppetBridge } from "./puppetBridge";
import { registerScreenShareHandler } from "./screenShare"; import { registerScreenShareHandler } from "./screenShare";
import { Settings, State } from "./settings"; import { Settings, State } from "./settings";
import { setAsDefaultProtocolClient } from "./utils/setAsDefaultProtocolClient"; import { setAsDefaultProtocolClient } from "./utils/setAsDefaultProtocolClient";
@@ -105,6 +106,7 @@ function init() {
registerScreenShareHandler(); registerScreenShareHandler();
registerMediaPermissionsHandler(); registerMediaPermissionsHandler();
initPuppetBridge();
bootstrap(); bootstrap();
@@ -127,10 +129,10 @@ if (!app.requestSingleInstanceLock({ IS_DEV })) {
} }
async function bootstrap() { async function bootstrap() {
if (!Object.hasOwn(State.store, "firstLaunch")) { if (process.env.VESKSLOP_HEADLESS === "1" || Object.hasOwn(State.store, "firstLaunch")) {
createFirstLaunchTour();
} else {
createWindows(); createWindows();
} else {
createFirstLaunchTour();
} }
} }
+148
View File
@@ -0,0 +1,148 @@
/*
* Veskslop core bridge — the only external control surface.
*
* The bridge accepts newline-delimited JSON. It validates the request shape,
* forwards the action to the renderer, and fans events out to subscribers.
* The renderer validates the action content. See src/renderer/puppet/actions.ts.
*/
import { app, ipcMain } from "electron";
import { rmSync } from "fs";
import { rm } from "fs/promises";
import { createServer, Server, Socket } from "net";
import { join } from "path";
import { IpcCommands, IpcEvents } from "shared/IpcEvents";
import {
PUPPET_LIMITS,
PuppetAction,
PuppetErrorCode,
type PuppetEvent,
type PuppetRequest,
type PuppetResult
} from "shared/puppetProtocol";
import { sendRendererCommand } from "./ipcCommands";
const { env, platform } = process;
export const PUPPET_SOCKET_PATH =
platform === "win32"
? "\\\\?\\pipe\\vesktop-puppet.sock"
: join(env.XDG_RUNTIME_DIR || env.TMPDIR || env.TMP || env.TEMP || "/tmp", "vesktop-puppet.sock");
const KNOWN_ACTIONS = new Set<string>([
PuppetAction.Ping,
PuppetAction.EventsSubscribe,
PuppetAction.StateGet,
PuppetAction.MessageSend,
PuppetAction.MessageRead,
PuppetAction.DmSend
]);
let server: Server | undefined;
const subscribers = new Set<Socket>();
export async function initPuppetBridge() {
await rm(PUPPET_SOCKET_PATH, { force: true });
server = createServer(socket => {
socket.setEncoding("utf-8");
let buffer = "";
socket.on("data", (chunk: string) => {
buffer += chunk;
if (buffer.length > PUPPET_LIMITS.maxRequestBytes) {
respond(socket, {
ok: false,
error: { code: PuppetErrorCode.PayloadTooLarge, message: "request too large" }
});
buffer = "";
socket.destroy();
return;
}
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", () => subscribers.delete(socket));
socket.on("close", () => subscribers.delete(socket));
});
server.listen(PUPPET_SOCKET_PATH);
server.on("error", error => console.error("[puppet] socket error:", error));
ipcMain.on(IpcEvents.PUPPET_EVENT, (_event, event: PuppetEvent) => {
const line = JSON.stringify({ type: "event", event }) + "\n";
for (const subscriber of subscribers) {
try {
subscriber.write(line);
} catch {
subscribers.delete(subscriber);
}
}
});
console.log(`[puppet] bridge listening on ${PUPPET_SOCKET_PATH}`);
}
function respond(socket: Socket, response: Record<string, unknown>) {
socket.write(JSON.stringify(response) + "\n");
}
async function handleLine(socket: Socket, line: string) {
let request: PuppetRequest;
try {
request = JSON.parse(line);
} catch {
respond(socket, { ok: false, error: { code: PuppetErrorCode.InvalidRequest, message: "invalid JSON" } });
return;
}
const { id, action, params } = request;
if (typeof action !== "string" || !KNOWN_ACTIONS.has(action)) {
respond(socket, {
id,
ok: false,
error: { code: PuppetErrorCode.UnknownAction, message: `unknown action: ${action}` }
});
return;
}
if (action === PuppetAction.Ping) {
respond(socket, { id, ok: true, result: "pong" });
return;
}
if (action === PuppetAction.EventsSubscribe) {
subscribers.add(socket);
respond(socket, { id, ok: true, result: { subscribed: true } });
return;
}
try {
const result = await sendRendererCommand<PuppetResult>(IpcCommands.PUPPET_COMMAND, { action, params });
if (result?.ok === false) respond(socket, { id, ok: false, error: result.error });
else respond(socket, { id, ok: true, result: result?.result });
} catch (error) {
console.error("[puppet] action failed:", action, error);
respond(socket, {
id,
ok: false,
error: { code: PuppetErrorCode.CoreUnavailable, message: "core is not ready" }
});
}
}
app.on("will-quit", () => {
server?.close();
rmSync(PUPPET_SOCKET_PATH, { force: true });
});
+3
View File
@@ -108,5 +108,8 @@ export const VesktopNative = {
ipcRenderer.on(IpcEvents.IPC_COMMAND, (_, message) => cb(message)); ipcRenderer.on(IpcEvents.IPC_COMMAND, (_, message) => cb(message));
}, },
respond: (response: IpcResponse) => ipcRenderer.send(IpcEvents.IPC_COMMAND, response) respond: (response: IpcResponse) => ipcRenderer.send(IpcEvents.IPC_COMMAND, response)
},
puppet: {
emit: (event: unknown) => ipcRenderer.send(IpcEvents.PUPPET_EVENT, event)
} }
}; };
+14
View File
@@ -9,6 +9,20 @@ import { contextBridge, ipcRenderer, webFrame } from "electron/renderer";
import { IpcEvents } from "../shared/IpcEvents"; import { IpcEvents } from "../shared/IpcEvents";
import { VesktopNative } from "./VesktopNative"; import { VesktopNative } from "./VesktopNative";
// Veskslop: seed the Discord auth token before the client boots, unless a session exists.
try {
const seed = ipcRenderer.sendSync(IpcEvents.VESKSLOP_GET_TOKEN) as
| { token?: string; force?: boolean }
| undefined;
if (seed?.token && (seed.force || !localStorage.getItem("token"))) {
localStorage.setItem("token", JSON.stringify(seed.token));
console.log("[veskslop] seeded auth token");
}
} catch (error) {
console.warn("[veskslop] failed to seed auth token", error);
}
contextBridge.exposeInMainWorld("VesktopNative", VesktopNative); contextBridge.exposeInMainWorld("VesktopNative", VesktopNative);
// While sandboxed, Electron "polyfills" these APIs as local variables. // While sandboxed, Electron "polyfills" these APIs as local variables.
+1
View File
@@ -9,6 +9,7 @@ import "./ipcCommands";
import "./appBadge"; import "./appBadge";
import "./fixes"; import "./fixes";
import "./arrpc"; import "./arrpc";
import "./puppet";
import "__patches__"; // auto generated by the build script import "__patches__"; // auto generated by the build script
export * as Components from "./components"; export * as Components from "./components";
+3
View File
@@ -8,6 +8,7 @@ import { SettingsRouter } from "@vencord/types/webpack/common";
import { IpcCommands } from "shared/IpcEvents"; import { IpcCommands } from "shared/IpcEvents";
import { openScreenSharePicker } from "./components/ScreenSharePicker"; import { openScreenSharePicker } from "./components/ScreenSharePicker";
import { handlePuppetAction } from "./puppet";
type IpcCommandHandler = (data: any) => any; type IpcCommandHandler = (data: any) => any;
@@ -52,3 +53,5 @@ onIpcCommand(IpcCommands.NAVIGATE_SETTINGS, () => {
onIpcCommand(IpcCommands.GET_LANGUAGES, () => navigator.languages); onIpcCommand(IpcCommands.GET_LANGUAGES, () => navigator.languages);
onIpcCommand(IpcCommands.SCREEN_SHARE_PICKER, data => openScreenSharePicker(data.screens, data.skipPicker)); onIpcCommand(IpcCommands.SCREEN_SHARE_PICKER, data => openScreenSharePicker(data.screens, data.skipPicker));
onIpcCommand(IpcCommands.PUPPET_COMMAND, data => handlePuppetAction(data.action, data.params));
+55
View File
@@ -0,0 +1,55 @@
/*
* Veskslop core events — normalize client events and push them to main.
*
* The subscription starts only after Vencord registers the flux dispatcher.
* A startup crash here would break the whole renderer bundle, so every step
* is guarded.
*/
import { ChannelStore } from "@vencord/types/webpack/common";
import { filters, waitFor } from "@vencord/types/webpack";
import type { PuppetEvent } from "shared/puppetProtocol";
function normalizeMessage(event: any): PuppetEvent | undefined {
const message = event?.message;
if (!message?.author) return undefined;
return {
type: "MESSAGE_CREATE",
id: message.id,
channelId: event.channelId,
guildId: event.guildId ?? null,
channelType: ChannelStore.getChannel(event.channelId)?.type ?? null,
author: {
id: message.author.id,
username: message.author.username,
bot: !!message.author.bot
},
content: message.content ?? "",
mentions: [
...(message.mentions ?? []).map((user: any) => user.id),
...(message.mentionIds ?? [])
],
timestamp: message.timestamp ?? new Date().toISOString()
};
}
let installed = false;
export function initPuppetEvents() {
waitFor(filters.byProps("dispatch", "subscribe"), dispatcher => {
if (installed) return;
installed = true;
dispatcher.subscribe("MESSAGE_CREATE", (event: any) => {
try {
const normalized = normalizeMessage(event);
if (normalized) VesktopNative.puppet.emit(normalized);
} catch (error) {
console.error("[puppet] event normalization failed:", error);
}
});
console.log("[veskslop] puppet events active");
});
}
+9
View File
@@ -0,0 +1,9 @@
/*
* Veskslop core entry — starts the event push and exports the action handler.
*/
import { initPuppetEvents } from "./events";
export { handlePuppetAction } from "./actions";
initPuppetEvents();
+9 -2
View File
@@ -62,7 +62,11 @@ export const enum IpcEvents {
DEVTOOLS_OPENED = "VCD_DEVTOOLS_OPENED", DEVTOOLS_OPENED = "VCD_DEVTOOLS_OPENED",
DEVTOOLS_CLOSED = "VCD_DEVTOOLS_CLOSED", DEVTOOLS_CLOSED = "VCD_DEVTOOLS_CLOSED",
CHOOSE_USER_ASSET = "VCD_CHOOSE_USER_ASSET" CHOOSE_USER_ASSET = "VCD_CHOOSE_USER_ASSET",
/* Veskslop */
VESKSLOP_GET_TOKEN = "VCD_VESKSLOP_GET_TOKEN",
PUPPET_EVENT = "VCD_PUPPET_EVENT"
} }
export const enum UpdaterIpcEvents { export const enum UpdaterIpcEvents {
@@ -83,5 +87,8 @@ export const enum IpcCommands {
GET_LANGUAGES = "navigator.languages", GET_LANGUAGES = "navigator.languages",
SCREEN_SHARE_PICKER = "screenshare:picker" SCREEN_SHARE_PICKER = "screenshare:picker",
/* Veskslop */
PUPPET_COMMAND = "puppet:command"
} }