190 lines
9.4 KiB
Markdown
190 lines
9.4 KiB
Markdown
# Core bridge
|
|
|
|
The core bridge is the only surface between the Vesktop core and the external daemon. This document
|
|
describes the protocol, the validation rules, and the hooks into upstream files.
|
|
|
|
## Files
|
|
|
|
| File | Role | Status |
|
|
| --- | --- | --- |
|
|
| `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 | 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
|
|
|
|
The core bridge uses a Unix socket. On Linux the path is `$XDG_RUNTIME_DIR/vesktop-puppet.sock`.
|
|
On Linux without `XDG_RUNTIME_DIR`, the path is `/tmp/vesktop-puppet.sock`. On Windows the path is
|
|
the named pipe `\\.\pipe\vesktop-puppet.sock`.
|
|
|
|
The protocol is newline-delimited JSON. Each request gets one response line. An event subscriber
|
|
gets event lines until the connection closes.
|
|
|
|
Request:
|
|
|
|
```json
|
|
{ "id": 1, "action": "message.send", "params": { "channelId": "123", "content": "hello" } }
|
|
```
|
|
|
|
Success response:
|
|
|
|
```json
|
|
{ "id": 1, "ok": true, "result": { "channelId": "123", "sent": true } }
|
|
```
|
|
|
|
Error response:
|
|
|
|
```json
|
|
{ "id": 1, "ok": false, "error": { "code": "CONTENT_EMPTY", "message": "content is empty" } }
|
|
```
|
|
|
|
Event line:
|
|
|
|
```json
|
|
{ "type": "event", "event": { "type": "MESSAGE_CREATE", "channelId": "123" } }
|
|
```
|
|
|
|
## Actions
|
|
|
|
| Action | Parameters | Result |
|
|
| --- | --- | --- |
|
|
| `ping` | none | `"pong"` |
|
|
| `events.subscribe` | none | `{ "subscribed": true }`. The socket becomes an event subscriber. |
|
|
| `state.get` | none | The current user, the guilds, and the channels. Cache-only. Includes user avatars and guild icons. |
|
|
| `voice.get` | optional `userId`, optional `guildId` | The voice channel and its members, or `channelId: null`. Without `guildId` it searches all guilds. |
|
|
| `voice.overview` | `guildId` | The occupied voice channels in the guild and their members |
|
|
| `voice.join` | `channelId`, optional `mute` (default true) | Joins the voice channel. Sets self-mute first. |
|
|
| `voice.leave` | none | Leaves the current voice channel. |
|
|
| `voice.move` | `channelId` | Moves the client to another voice channel. |
|
|
| `voice.devices` | none | The client's input and output audio devices. |
|
|
| `voice.setDevices` | optional `inputDeviceId`, optional `outputDeviceId` | Sets the client devices. |
|
|
| `user.search` | `query`, optional `guildId`, optional `limit` | Users from the client cache. Guild members when `guildId` is set. |
|
|
| `dm.list` | optional `limit` | Cached DM and group DM channels with recipients, icons, and avatars. Cache-only. |
|
|
| `message.send` | `channelId`, `content` | `{ channelId, messageId, sent: true }` |
|
|
| `message.read` | `channelId`, `limit` | `{ cached, messages }`. Cache-only. Messages include the author avatar, attachment CDN links, `editedAt`, and `replyTo`. |
|
|
| `message.fetch` | `channelId`, `limit`, optional `before` or `after` | `{ fetched, messages }`. Fetches from Discord. Same message shape. |
|
|
| `reaction.add` | `channelId`, `messageId`, `emoji` | `{ added: true }` |
|
|
| `dm.send` | `userId`, `content` | `{ userId, channelId, sent: true }` |
|
|
|
|
## Events
|
|
|
|
The renderer normalizes client events and pushes them to subscribers.
|
|
|
|
| Event | Payload |
|
|
| --- | --- |
|
|
| `MESSAGE_CREATE` | Message ID, channel, guild, channel type, author, content, mentions, `replyTo`, `editedAt`, timestamp |
|
|
| `MESSAGE_UPDATE` | Message ID, channel, guild, author, content, `editedAt` |
|
|
| `MESSAGE_DELETE` | Message ID, channel, guild |
|
|
| `MESSAGE_DELETE_BULK` | Message IDs, channel, guild |
|
|
| `MESSAGE_REACTION_ADD` | Message ID, channel, guild, user, emoji |
|
|
| `MESSAGE_REACTION_REMOVE` | Same fields as add |
|
|
| `VOICE_STATE_UPDATE` | User, channel, guild, session, mute, deaf, self-mute, self-deaf |
|
|
| `VOICE_SPEAKING` | User, speaking flag, channel |
|
|
|
|
Discord's dispatcher only sends reliable speaking-start events. The daemon polls the core's
|
|
`voice.speaking` action (which reads `ChannelRTCStore.getSpeakingParticipants`) once a second and
|
|
synthesizes the missing start/stop events, feeding dispatcher hints into the same set. Discord
|
|
sends `speaking` as a bitfield number, so the core treats `0` as false and a missing flag as a
|
|
start pulse. A user who mutes or leaves also emits a state update that ends the speaking state in
|
|
the UI.
|
|
|
|
## Validation rules
|
|
|
|
The core validates all input. The daemon cannot bypass these rules.
|
|
|
|
- A channel ID and a user ID must match the snowflake format. The format is 17 to 20 digits.
|
|
- `content` must be a string. The content must not be empty after a trim.- `content` must not contain control characters.
|
|
- `content` must not be longer than 2000 characters.
|
|
- The target channel must exist. The channel type must accept text. The allowed types are
|
|
`GUILD_TEXT`, `DM`, `GROUP_DM`, `GUILD_ANNOUNCEMENT`, `ANNOUNCEMENT_THREAD`, `PUBLIC_THREAD`,
|
|
and `PRIVATE_THREAD`.
|
|
- `limit` must be an integer from 1 to 100.
|
|
- `before` and `after` must be snowflakes. They are mutually exclusive. `message.fetch` uses them
|
|
to walk history.
|
|
- `voice.get` accepts snowflakes only. It reads the client cache. It never joins a call.
|
|
- `voice.overview` requires a guild snowflake. It reads the client cache.
|
|
- `voice.join` and `voice.move` require a channel snowflake and a voice channel type (`2` or `13`).
|
|
The join sets self-mute when the action is available. The internal lookups
|
|
(`selectVoiceChannel`, `setSelfMute`) were verified live on 2026-09-13.
|
|
- `voice.leave` takes no parameters. It disconnects the client.
|
|
- `voice.setDevices` takes short device ID strings. The device list comes from
|
|
`navigator.mediaDevices.enumerateDevices()`.
|
|
- `user.search` takes a query of 1 to 100 characters and a limit of 1 to 25. It reads the client
|
|
cache only. An exact snowflake query returns that user when cached.
|
|
- `dm.list` takes a limit of 1 to 50. It reads `ChannelStore.getSortedPrivateChannels()` only.
|
|
- `reaction.add` accepts a short emoji string without line breaks. It counts as an outbound action
|
|
for the rate limit.
|
|
- A request line must not be larger than 64 KiB.
|
|
|
|
## Core rate limit
|
|
|
|
The core rate limit is a safety net. It does not replace the daemon pacing.
|
|
|
|
- The minimum interval between two outbound actions is 500 ms.
|
|
- The maximum is 30 outbound actions per minute.
|
|
- The limit applies to `message.send` and `dm.send`.
|
|
|
|
## Error codes
|
|
|
|
| Code | Meaning |
|
|
| --- | --- |
|
|
| `INVALID_REQUEST` | The line is not valid JSON. |
|
|
| `PAYLOAD_TOO_LARGE` | The request line is larger than 64 KiB. |
|
|
| `UNKNOWN_ACTION` | The action name is not in the action list. |
|
|
| `CORE_UNAVAILABLE` | The bridge cannot reach the renderer. |
|
|
| `NOT_LOGGED_IN` | No user is logged in. |
|
|
| `INVALID_SNOWFLAKE` | The ID is not a snowflake. |
|
|
| `INVALID_PARAM` | A parameter has the wrong type or value. |
|
|
| `CONTENT_EMPTY` | The content is empty. |
|
|
| `CONTENT_TOO_LONG` | The content is longer than 2000 characters. |
|
|
| `CHANNEL_NOT_FOUND` | The channel is not in the client cache. |
|
|
| `NOT_SENDABLE` | The channel type cannot receive messages. |
|
|
| `SEND_FAILED` | Discord rejected the message. |
|
|
| `REACTION_FAILED` | Discord rejected the reaction. |
|
|
| `VOICE_FAILED` | Discord rejected the voice action. |
|
|
| `DM_UNAVAILABLE` | The client cannot create a DM channel. |
|
|
| `NOT_SUPPORTED` | The client does not offer the internal action. |
|
|
| `RATE_LIMITED` | The core rate limit rejected the action. |
|
|
| `INTERNAL` | An unexpected error occurred. The error message is included (for example a lazy module lookup before the renderer finished loading). |
|
|
|
|
A renderer command that is sent while the window is destroyed or crashed is rejected at once with
|
|
`CORE_UNAVAILABLE`, and a command that gets no answer times out after 15 seconds. The bridge socket
|
|
survives a renderer restart; the daemon retries background work such as device auto-connect. While
|
|
the renderer is gone, the bridge logs one warning instead of one stack per command, then stays
|
|
quiet until a command succeeds again.
|
|
|
|
## Hooks into upstream files
|
|
|
|
The bridge uses a small number of hooks. Keep these changes minimal.
|
|
|
|
| File | Change |
|
|
| --- | --- |
|
|
| `src/main/main.ts` | Import the bridge. Call `initPuppetBridge()` after `app.whenReady()`. |
|
|
| `src/shared/IpcEvents.ts` | Add `PUPPET_EVENT` and `IpcCommands.PUPPET_COMMAND`. |
|
|
| `src/renderer/index.ts` | Import `./puppet`. |
|
|
| `src/renderer/ipcCommands.ts` | Register `PUPPET_COMMAND` and call `handlePuppetAction`. |
|
|
| `src/preload/VesktopNative.ts` | Add `puppet.emit(event)` for event push. |
|
|
|
|
## How to add an action
|
|
|
|
1. Add the action name to `PuppetAction` in `src/shared/puppetProtocol.ts`.
|
|
2. Add a validation case in `src/renderer/puppet/actions.ts`.
|
|
3. Add the action to the known-action set in `src/main/puppetBridge.ts`.
|
|
4. Add a tool in the daemon (`poc/src/control-bridge.ts`).
|
|
5. Add a unit test for the validation path.
|
|
6. Run `pnpm testTypes`, `pnpm lint`, and `pnpm build`.
|
|
|
|
## Why this shape
|
|
|
|
The shape gives three properties:
|
|
|
|
- The daemon cannot run arbitrary code in the client.
|
|
- A daemon bug cannot crash the client or send malformed data to Discord.
|
|
- The core patch stays small, so an upstream rebase is easy.
|