From 7d6650ce4e3a7c703e2d24b64a44c84079751d34 Mon Sep 17 00:00:00 2001 From: Fisto Date: Fri, 11 Sep 2026 14:15:03 +0200 Subject: [PATCH] Wire validated core bridge, migrate daemon to it, verify live --- AGENTS.md | 20 ++--- CURRENT.md | 30 ++++--- docs/architecture.md | 12 +-- docs/core-bridge.md | 10 ++- docs/daemon.md | 37 ++++---- docs/testing.md | 37 ++++++-- poc/README.md | 18 ++-- poc/package.json | 5 +- poc/src/cdp-dispatch.ts | 55 ++++++++++++ poc/src/control-bridge.ts | 54 ++++++------ poc/src/core-client.ts | 137 +++++++++++++++++++++++++++++ poc/src/discord.ts | 159 ---------------------------------- poc/src/mock-core.ts | 109 +++++++++++++++++++++++ poc/src/puppet.ts | 9 +- poc/src/test-dm.ts | 15 ++-- poc/src/test-trigger.ts | 42 +++++++++ src/main/constants.ts | 7 +- src/main/ipc.ts | 12 +++ src/main/main.ts | 8 +- src/main/puppetBridge.ts | 148 +++++++++++++++++++++++++++++++ src/preload/VesktopNative.ts | 3 + src/preload/index.ts | 14 +++ src/renderer/index.ts | 1 + src/renderer/ipcCommands.ts | 3 + src/renderer/puppet/events.ts | 55 ++++++++++++ src/renderer/puppet/index.ts | 9 ++ src/shared/IpcEvents.ts | 11 ++- 27 files changed, 754 insertions(+), 266 deletions(-) create mode 100644 poc/src/cdp-dispatch.ts create mode 100644 poc/src/core-client.ts delete mode 100644 poc/src/discord.ts create mode 100644 poc/src/mock-core.ts create mode 100644 poc/src/test-trigger.ts create mode 100644 src/main/puppetBridge.ts create mode 100644 src/renderer/puppet/events.ts create mode 100644 src/renderer/puppet/index.ts diff --git a/AGENTS.md b/AGENTS.md index abb0559..47e3425 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,22 +44,22 @@ next steps. Read it before you start. Update it before you stop. | Area | Status | | --- | --- | | Action protocol and renderer actions | Implemented | -| Main bridge, event push, hooks | Pending | -| Daemon on the CDP transport | Implemented and verified live | -| Daemon on the core-bridge transport | Pending | +| Main bridge, event push, hooks | Implemented | +| Daemon on the core-bridge transport | Implemented and verified live | | 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 | ## 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. -2. Re-add auth persistence: pinned `DATA_DIR`, token file, preload seeding, `VESKSLOP_HEADLESS`. -3. Move the daemon to the core-bridge transport. -4. Add a mock core for tests. -5. Later: voice actions, audio, video, fetch-based read, client lightening, web debug UI. +1. Voice join and leave actions. +2. Audio in and out with PipeWire. +3. Video streaming. +4. Fetch-based message read. +5. Client lightening and the web debug UI. ## Dev commands diff --git a/CURRENT.md b/CURRENT.md index 5cd2099..b3fc138 100644 --- a/CURRENT.md +++ b/CURRENT.md @@ -11,28 +11,31 @@ into the external daemon. Keep upstream divergence small. ## In progress -- Core bridge wiring. The protocol and the renderer actions exist. The events, the main bridge, and - the upstream hooks are not wired yet. +- None. The rework milestone is complete and verified live. ## Done - Audit of the earlier Vesktop patches. `src/` was reverted to upstream. - Action protocol and validated renderer actions: `src/shared/puppetProtocol.ts`, `src/renderer/puppet/actions.ts`. -- CDP feasibility proven. The daemon works on CDP. `get_state` and `send_dm` were verified live. -- QR login verified end-to-end. The token file works. -- Pacing policy (500 to 2800 ms), trigger log, and trust model logic live in the daemon. +- Core bridge wired: events, renderer init, main bridge, and the upstream hooks. +- Auth persistence re-added: pinned `DATA_DIR`, token file, preload seeding, `VESKSLOP_HEADLESS`. +- 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`. ## Next (in order) -1. Wire the core bridge: `events.ts`, `index.ts`, `puppetBridge.ts`, and the upstream hooks. -2. Re-add auth persistence to the core: pinned `DATA_DIR`, token file, preload seeding, - `VESKSLOP_HEADLESS`. -3. Move the daemon to the core-bridge transport. Keep CDP as a development fallback. -4. Add a mock core. Run the daemon tests without Electron. -5. Type check, build, and run a live test. The live send target is zzib only. -6. Later: voice join and leave, audio, video, fetch-based read, client lightening, web debug UI. +1. Keep `CURRENT.md`, `AGENTS.md`, and `docs/` in sync with each change. +2. Voice join and leave actions. Validated actions, then a trigger command. +3. Audio in and out with PipeWire. +4. Video streaming. +5. Fetch-based message read. The current read is cache-only. +6. Client lightening and the web debug UI. +7. Remote client authentication over LAN or Tailscale. ## Blocked / needs input @@ -43,4 +46,5 @@ into the external daemon. Keep upstream divergence small. - Design documents: `docs/INDEX.md` - Agent rules: `AGENTS.md` - 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` diff --git a/docs/architecture.md b/docs/architecture.md index 69427ea..b192356 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -46,14 +46,14 @@ when the action list changes. | --- | --- | --- | | Action protocol | `src/shared/puppetProtocol.ts` | Implemented | | Validated actions | `src/renderer/puppet/actions.ts` | Implemented | -| Event push | `src/renderer/puppet/events.ts` | Pending | -| Main bridge | `src/main/puppetBridge.ts` | Pending | -| Core hooks | `src/main/main.ts`, `src/renderer/ipcCommands.ts`, `src/shared/IpcEvents.ts`, `src/preload/VesktopNative.ts` | Pending | -| Daemon (CDP transport) | `poc/src/` | Implemented and verified live | -| Daemon (core-bridge transport) | `poc/src/core-client.ts` | Pending | +| Event push | `src/renderer/puppet/events.ts` | Implemented | +| 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` | Implemented | +| Daemon (core-bridge transport) | `poc/src/core-client.ts` | Implemented and verified live | +| Daemon (CDP transport) | `poc/src/cdp-client.ts` | Kept as a development fallback | | QR login | `poc/src/qr-login.ts` | Implemented and verified live | | 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 | ## Data flow diff --git a/docs/core-bridge.md b/docs/core-bridge.md index d116d2c..48a1e1c 100644 --- a/docs/core-bridge.md +++ b/docs/core-bridge.md @@ -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/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/index.ts` | Renderer initialization | Pending | -| `src/main/puppetBridge.ts` | Unix socket server, request forwarding, event fan-out | Pending | +| `src/renderer/puppet/events.ts` | Event normalization and push to main | Implemented | +| `src/renderer/puppet/index.ts` | Renderer initialization | Implemented | +| `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 diff --git a/docs/daemon.md b/docs/daemon.md index 897f7ae..26c977d 100644 --- a/docs/daemon.md +++ b/docs/daemon.md @@ -6,29 +6,27 @@ The daemon runs outside Vesktop. It holds all logic. It talks to the core bridge | 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/communication.ts` | Pure log and parser logic | 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/discord.ts` | Client actions over CDP | Implemented (current transport) | -| `poc/src/core-client.ts` | Client actions over the core bridge | Pending (target transport) | +| `poc/src/core-client.ts` | Client actions over the core bridge | Implemented (primary transport) | +| `poc/src/cdp-client.ts` | Chrome DevTools Protocol client | Development fallback | | `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/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 -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 - connects to the Discord page target. It evaluates the action code in the page and receives - 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. +The CDP transport is a development fallback. It needs no Vesktop patch. It evaluates raw code in +the page, so it has no core-side guardrails. Use it for debugging only. ## Pacing @@ -56,17 +54,24 @@ The daemon exposes MCP over Streamable HTTP. The default address is `http://127. ## Run commands ```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 +# 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. pnpm poc:dm # Run the pure-logic unit tests. pnpm test:comm -# Probe CDP access and events. +# Development tools for the CDP fallback. pnpm cdp:probe +CDP_EXPRESSION='...' pnpm cdp:dispatch ``` ## Health endpoint diff --git a/docs/testing.md b/docs/testing.md index c785112..40f9ce9 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -31,14 +31,35 @@ pnpm build 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 -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. ```sh cd poc 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 @@ -82,20 +103,22 @@ cd poc 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. - `send_dm` delivered a real DM to zzib. -- The client ran headless under Xvfb. -- The token was seeded from the token file. +- A synthetic owner message flowed through the real event path. The daemon log held the entry with + 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 | Gap | Effect | | --- | --- | -| Mock core is not in the tree. | The daemon tests need the real client. | -| The trigger event path needs an incoming message. | Use zzib's account for a live test. | +| Live trigger tests need an incoming message. | Use the mock core, or send from zzib's account. | | `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. | diff --git a/poc/README.md b/poc/README.md index f9dc2a8..71f1f62 100644 --- a/poc/README.md +++ b/poc/README.md @@ -16,19 +16,22 @@ pnpm install | --- | --- | | `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: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 test:comm` | Run the unit tests for the pure logic. | | `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. | ## Transport -The daemon uses Chrome DevTools Protocol at this time. Start the client with -`--remote-debugging-port=9222`. The daemon connects to the Discord page and runs the actions in the -real client. +The primary transport is the validated core bridge at `vesktop-puppet.sock`. The daemon sends +validated actions and receives normalized events. See +[../docs/core-bridge.md](../docs/core-bridge.md). -The target transport is the validated core bridge. See -[../docs/core-bridge.md](../docs/core-bridge.md). The daemon logic is the same on both transports. +The CDP transport is a development fallback. It needs no Vesktop patch, and it has no +core-side guardrails. Use it for debugging only. ## Pacing @@ -44,8 +47,9 @@ The only permitted live-send target is user zzib. See ## Verified - 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 - a real DM to zzib. +- The core bridge works live. `get_state` returned the real account and 7 guilds. `send_dm` reached + 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. ## Findings diff --git a/poc/package.json b/poc/package.json index 095efbc..2decba8 100644 --- a/poc/package.json +++ b/poc/package.json @@ -10,7 +10,10 @@ "test:comm": "tsx src/test-communication.ts", "cdp:probe": "tsx src/cdp-probe.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": { "@modelcontextprotocol/sdk": "^1.30.0", diff --git a/poc/src/cdp-dispatch.ts b/poc/src/cdp-dispatch.ts new file mode 100644 index 0000000..d302073 --- /dev/null +++ b/poc/src/cdp-dispatch.ts @@ -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((resolve, reject) => { + ws.once("open", () => resolve()); + ws.once("error", reject); +}); + +const response = await new Promise((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(); diff --git a/poc/src/control-bridge.ts b/poc/src/control-bridge.ts index e6a152a..b4f85b8 100644 --- a/poc/src/control-bridge.ts +++ b/poc/src/control-bridge.ts @@ -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 - * actions against the real client, receives events through a CDP binding, and - * runs the trigger/whitelist/log logic locally. + * The daemon holds all logic. It sends validated actions to Vesktop and + * receives normalized events. See docs/core-bridge.md. * * Usage: - * VESKSLOP_TOKEN_FILE=... pnpm control:bridge - * CDP_URL=http://127.0.0.1:9222 MCP_PORT=8788 pnpm control:bridge + * pnpm control:bridge + * VESKSLOP_SOCKET=/path/to/vesktop-puppet.sock MCP_PORT=8788 pnpm control:bridge */ 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 * as z from "zod/v4"; -import { CdpClient } from "./cdp-client.js"; -import { Discord } from "./discord.js"; +import { CoreClient, coreSocketPath } from "./core-client.js"; import { dedupe, getQueryCount, paced, sleep } from "./pacing.js"; import { Puppet } from "./puppet.js"; const HOST = process.env.MCP_HOST ?? "127.0.0.1"; const PORT = Number(process.env.MCP_PORT ?? 8788); -let discord: Discord | undefined; +let core: CoreClient | undefined; let puppet: Puppet | undefined; -let cdpConnected = false; +let coreConnected = false; async function connectLoop() { for (;;) { try { - const cdp = await CdpClient.connect(); + const client = await CoreClient.connect(); - discord = new Discord(cdp); - puppet = new Puppet(discord); + core = client; + puppet = new Puppet(client); - await discord.installEventBridge(); - cdp.onEvent(event => { + client.onEvent(event => { puppet!.handleEvent(event).catch(error => console.error("[daemon] event error:", error)); }); - cdpConnected = true; - console.log("[daemon] connected to Vesktop via CDP"); + coreConnected = true; + console.log(`[daemon] connected to the core bridge at ${coreSocketPath}`); return; } 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); } } } -function requireDiscord(): Discord { - if (!cdpConnected || !discord) throw new Error("Not connected to Vesktop (run it with --remote-debugging-port)"); - return discord; +function requireCore(): CoreClient { + if (!coreConnected || !core) throw new Error("Not connected to Vesktop (is the core bridge running?)"); + return core; } function requirePuppet(): Puppet { @@ -73,7 +70,7 @@ function buildServer(): McpServer { { 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( @@ -86,7 +83,7 @@ function buildServer(): McpServer { } }, async ({ channelId, content }) => { - await paced(() => requireDiscord().sendMessage(channelId, content)); + await paced(() => requireCore().sendMessage(channelId, content)); return jsonText({ channelId, sent: true, queriesUsed: getQueryCount() }); } ); @@ -101,7 +98,7 @@ function buildServer(): McpServer { } }, 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() }); } ); @@ -117,7 +114,7 @@ function buildServer(): McpServer { }, async ({ 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() }); } @@ -203,7 +200,7 @@ app.delete("/mcp", (_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) => { @@ -214,4 +211,7 @@ app.listen(PORT, HOST, (error?: Error) => { console.log(`[daemon] MCP listening on http://${HOST}:${PORT}/mcp`); }); -process.on("SIGINT", () => process.exit(0)); +process.on("SIGINT", () => { + core?.close(); + process.exit(0); +}); diff --git a/poc/src/core-client.ts b/poc/src/core-client.ts new file mode 100644 index 0000000..57e0281 --- /dev/null +++ b/poc/src/core-client.ts @@ -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(); + private readonly listeners = new Set(); + + 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 { + 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(action: string, params?: Record): Promise { + const id = this.nextId++; + + return new Promise((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 { + 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(); + } +} diff --git a/poc/src/discord.ts b/poc/src/discord.ts deleted file mode 100644 index 112c99b..0000000 --- a/poc/src/discord.ts +++ /dev/null @@ -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 { - 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 { - return this.cdp.evaluate(` - (() => { - 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 { - return (await this.getState())?.user.id; - } - - async sendMessage(channelId: string, content: string): Promise { - 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 { - await this.cdp.evaluate(` - localStorage.setItem("token", ${stringify(JSON.stringify(token))}); - location.reload(); - "reloading" - `); - } -} diff --git a/poc/src/mock-core.ts b/poc/src/mock-core.ts new file mode 100644 index 0000000..9096331 --- /dev/null +++ b/poc/src/mock-core.ts @@ -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(); + +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); +}); diff --git a/poc/src/puppet.ts b/poc/src/puppet.ts index 9aa42ac..1821241 100644 --- a/poc/src/puppet.ts +++ b/poc/src/puppet.ts @@ -15,7 +15,12 @@ import { type ParsedCommand, type PuppetMessage } from "./communication.js"; -import type { Discord } from "./discord.js"; + +/** The subset of the transport that the puppet logic needs. */ +export interface PuppetTransport { + getSelfId(): Promise; + sendMessage(channelId: string, content: string): Promise; +} const DM_CHANNEL = 1; const GROUP_DM_CHANNEL = 3; @@ -36,7 +41,7 @@ export class Puppet { private whitelistIds: string[] = []; private selfId?: string; - constructor(private readonly discord: Discord) { + constructor(private readonly discord: PuppetTransport) { try { const state = JSON.parse(readFileSync(STATE_FILE, "utf8")) as PersistedState; if (state.ownerId) this.ownerId = state.ownerId; diff --git a/poc/src/test-dm.ts b/poc/src/test-dm.ts index eb93c4f..d555946 100644 --- a/poc/src/test-dm.ts +++ b/poc/src/test-dm.ts @@ -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). * @@ -8,8 +8,7 @@ * TEST_DM_USER_ID=... TEST_DM_CONTENT="hello" pnpm poc:dm */ -import { CdpClient } from "./cdp-client.js"; -import { Discord } from "./discord.js"; +import { CoreClient } from "./core-client.js"; /** zzib — the only sanctioned live-send target. */ 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 content = process.env.TEST_DM_CONTENT ?? "veskslop test dm"; -const cdp = await CdpClient.connect(); -const discord = new Discord(cdp); +const core = await CoreClient.connect(); +console.log("[dm] connected to the core bridge"); +console.log("[dm]", await core.sendDm(userId, content)); -console.log("[dm] connected via CDP"); -console.log("[dm]", await discord.sendDm(userId, content)); - -cdp.close(); +core.close(); diff --git a/poc/src/test-trigger.ts b/poc/src/test-trigger.ts new file mode 100644 index 0000000..e1b4b78 --- /dev/null +++ b/poc/src/test-trigger.ts @@ -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((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); +}); diff --git a/src/main/constants.ts b/src/main/constants.ts index 458e11b..c60c7e9 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -18,10 +18,15 @@ export const PORTABLE = !existsSync(join(vesktopDir, "Uninstall Vesktop.exe")); 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 }); +/** 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"); app.setPath("sessionData", SESSION_DATA_DIR); diff --git a/src/main/ipc.ts b/src/main/ipc.ts index aee11f3..fd52136 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -26,6 +26,7 @@ import { join } from "path"; import { IpcEvents } from "../shared/IpcEvents"; import { setBadgeCount } from "./appBadge"; import { autoStart } from "./autoStart"; +import { TOKEN_FILE } from "./constants"; import { enableHardwareAcceleration } from "./main"; import { mainWin } from "./mainWindow"; import { Settings, State } from "./settings"; @@ -60,6 +61,17 @@ handleSync(IpcEvents.GET_SETTINGS, () => Settings.plain); handleSync(IpcEvents.GET_VERSION, () => app.getVersion()); 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( IpcEvents.SUPPORTS_WINDOWS_TRANSPARENCY, () => process.platform === "win32" && Number(release().split(".").pop()) >= 22621 diff --git a/src/main/main.ts b/src/main/main.ts index 079bfc2..1ca5475 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -16,6 +16,7 @@ import { DATA_DIR } from "./constants"; import { createFirstLaunchTour } from "./firstLaunch"; import { createWindows, mainWin } from "./mainWindow"; import { registerMediaPermissionsHandler } from "./mediaPermissions"; +import { initPuppetBridge } from "./puppetBridge"; import { registerScreenShareHandler } from "./screenShare"; import { Settings, State } from "./settings"; import { setAsDefaultProtocolClient } from "./utils/setAsDefaultProtocolClient"; @@ -105,6 +106,7 @@ function init() { registerScreenShareHandler(); registerMediaPermissionsHandler(); + initPuppetBridge(); bootstrap(); @@ -127,10 +129,10 @@ if (!app.requestSingleInstanceLock({ IS_DEV })) { } async function bootstrap() { - if (!Object.hasOwn(State.store, "firstLaunch")) { - createFirstLaunchTour(); - } else { + if (process.env.VESKSLOP_HEADLESS === "1" || Object.hasOwn(State.store, "firstLaunch")) { createWindows(); + } else { + createFirstLaunchTour(); } } diff --git a/src/main/puppetBridge.ts b/src/main/puppetBridge.ts new file mode 100644 index 0000000..37c3633 --- /dev/null +++ b/src/main/puppetBridge.ts @@ -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([ + PuppetAction.Ping, + PuppetAction.EventsSubscribe, + PuppetAction.StateGet, + PuppetAction.MessageSend, + PuppetAction.MessageRead, + PuppetAction.DmSend +]); + +let server: Server | undefined; +const subscribers = new Set(); + +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) { + 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(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 }); +}); diff --git a/src/preload/VesktopNative.ts b/src/preload/VesktopNative.ts index 1dd6710..41c4c31 100644 --- a/src/preload/VesktopNative.ts +++ b/src/preload/VesktopNative.ts @@ -108,5 +108,8 @@ export const VesktopNative = { ipcRenderer.on(IpcEvents.IPC_COMMAND, (_, message) => cb(message)); }, respond: (response: IpcResponse) => ipcRenderer.send(IpcEvents.IPC_COMMAND, response) + }, + puppet: { + emit: (event: unknown) => ipcRenderer.send(IpcEvents.PUPPET_EVENT, event) } }; diff --git a/src/preload/index.ts b/src/preload/index.ts index 6ed74f9..800e175 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -9,6 +9,20 @@ import { contextBridge, ipcRenderer, webFrame } from "electron/renderer"; import { IpcEvents } from "../shared/IpcEvents"; 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); // While sandboxed, Electron "polyfills" these APIs as local variables. diff --git a/src/renderer/index.ts b/src/renderer/index.ts index 1e6f6b5..668f37f 100644 --- a/src/renderer/index.ts +++ b/src/renderer/index.ts @@ -9,6 +9,7 @@ import "./ipcCommands"; import "./appBadge"; import "./fixes"; import "./arrpc"; +import "./puppet"; import "__patches__"; // auto generated by the build script export * as Components from "./components"; diff --git a/src/renderer/ipcCommands.ts b/src/renderer/ipcCommands.ts index 4d73f02..d8f9149 100644 --- a/src/renderer/ipcCommands.ts +++ b/src/renderer/ipcCommands.ts @@ -8,6 +8,7 @@ import { SettingsRouter } from "@vencord/types/webpack/common"; import { IpcCommands } from "shared/IpcEvents"; import { openScreenSharePicker } from "./components/ScreenSharePicker"; +import { handlePuppetAction } from "./puppet"; type IpcCommandHandler = (data: any) => any; @@ -52,3 +53,5 @@ onIpcCommand(IpcCommands.NAVIGATE_SETTINGS, () => { onIpcCommand(IpcCommands.GET_LANGUAGES, () => navigator.languages); onIpcCommand(IpcCommands.SCREEN_SHARE_PICKER, data => openScreenSharePicker(data.screens, data.skipPicker)); + +onIpcCommand(IpcCommands.PUPPET_COMMAND, data => handlePuppetAction(data.action, data.params)); diff --git a/src/renderer/puppet/events.ts b/src/renderer/puppet/events.ts new file mode 100644 index 0000000..23225fc --- /dev/null +++ b/src/renderer/puppet/events.ts @@ -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"); + }); +} diff --git a/src/renderer/puppet/index.ts b/src/renderer/puppet/index.ts new file mode 100644 index 0000000..8579b29 --- /dev/null +++ b/src/renderer/puppet/index.ts @@ -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(); diff --git a/src/shared/IpcEvents.ts b/src/shared/IpcEvents.ts index 55efe80..332135a 100644 --- a/src/shared/IpcEvents.ts +++ b/src/shared/IpcEvents.ts @@ -62,7 +62,11 @@ export const enum IpcEvents { DEVTOOLS_OPENED = "VCD_DEVTOOLS_OPENED", 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 { @@ -83,5 +87,8 @@ export const enum IpcCommands { GET_LANGUAGES = "navigator.languages", - SCREEN_SHARE_PICKER = "screenshare:picker" + SCREEN_SHARE_PICKER = "screenshare:picker", + + /* Veskslop */ + PUPPET_COMMAND = "puppet:command" }