Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b16d313a91 | ||
|
|
912d0bce79 | ||
|
|
b08091d55c | ||
|
|
49f466a569 | ||
|
|
797c02bdd0 | ||
|
|
02f1e71e58 | ||
|
|
e6954304f7 | ||
|
|
876fbe5fba | ||
|
|
9d7ca67606 | ||
|
|
cf3667c151 | ||
|
|
f3e01c2ba4 | ||
|
|
d179b621a7 |
@@ -1,85 +0,0 @@
|
|||||||
# AGENTS.md — Veskslop
|
|
||||||
|
|
||||||
## What this is
|
|
||||||
|
|
||||||
Veskslop is a **headless Discord client daemon** built on the Vesktop codebase. It turns one or
|
|
||||||
more **Discord user accounts** into a controllable service:
|
|
||||||
|
|
||||||
- an **MCP server** (Streamable HTTP) lets AI agents drive a "puppet" account;
|
|
||||||
- a second, read-only account can collect stats/data without acting;
|
|
||||||
- **audio is piped in and out** over PipeWire;
|
|
||||||
- **video streams** can be pushed into voice channels;
|
|
||||||
- everything is **local-first**: clients reach it over LAN/Tailscale only.
|
|
||||||
|
|
||||||
**Vesktop is a separate project with separate goals.** This repo shares history with it but not
|
|
||||||
direction. Do not carry Vesktop UI features here unless the daemon needs them.
|
|
||||||
|
|
||||||
## Decisions (locked unless the user changes them)
|
|
||||||
|
|
||||||
- **Core:** TypeScript on Node with pnpm. `discord.js` for gateway/REST, `@discordjs/voice` for voice.
|
|
||||||
- **Accounts:** multiple user tokens. Expected setup is one read-only collector account plus one
|
|
||||||
full-control puppet account; design for N identities behind one control surface.
|
|
||||||
- **MCP transport:** Streamable HTTP only (no stdio/SSE unless asked).
|
|
||||||
- **Network:** bind localhost by default. Remote access is always over LAN/Tailscale — never expose
|
|
||||||
to the public internet. A client will later authenticate to the proxy for remote control.
|
|
||||||
- **Audio:** PipeWire only.
|
|
||||||
- **GUI:** optional minimal **web debug UI** if it is cheap to build; otherwise keep the existing
|
|
||||||
Vesktop Electron build runnable for debugging. The daemon must never require Electron.
|
|
||||||
- **Pacing/safety:** actions emulate a human using the Vesktop client through a queue:
|
|
||||||
- first action is snappy (~30–80 ms delay);
|
|
||||||
- subsequent bulk actions get randomized delays (~500–2800 ms);
|
|
||||||
- a **risky fast mode** is opt-in and minimizes delays;
|
|
||||||
- default capabilities are clamped to free-account limits (upload size, stream resolution, etc.);
|
|
||||||
- individual abilities can be toggled off.
|
|
||||||
|
|
||||||
## How to proceed
|
|
||||||
|
|
||||||
**Phase 0 — PoC, do this first.** Find the smallest end-to-end thing that works, then stop and
|
|
||||||
report back so the user can choose a direction. Candidate spikes, timeboxed:
|
|
||||||
|
|
||||||
1. `discord.js` login + list guilds + send a message from a throwaway test account.
|
|
||||||
2. Minimal MCP Streamable HTTP server exposing `send_message` / `read_messages`, wired to #1.
|
|
||||||
3. Join a voice channel and play a local file via `@discordjs/voice` (PipeWire output).
|
|
||||||
|
|
||||||
Prefer whichever is quickest to prove; do not build production architecture before the user picks.
|
|
||||||
|
|
||||||
**After regrouping:** headless daemon skeleton → multi-account session manager → pacing queue →
|
|
||||||
MCP tool surface → audio pipelines → video pipeline → remote proxy/auth client → web debug UI.
|
|
||||||
|
|
||||||
## Architecture sketch
|
|
||||||
|
|
||||||
```
|
|
||||||
MCP clients (agents) ──Streamable HTTP──▶ Veskslop core ──▶ Discord (user tokens)
|
|
||||||
remote web client ─────auth over LAN─────▶ │
|
|
||||||
├─ action queue (human-emulation pacing)
|
|
||||||
├─ capability policy + limits + audit log
|
|
||||||
├─ audio pipeline (PipeWire)
|
|
||||||
└─ video pipeline
|
|
||||||
```
|
|
||||||
|
|
||||||
## Guardrails for agents working here
|
|
||||||
|
|
||||||
- **Never commit tokens, credentials, or `.env` files.** Use environment variables / a local secret store.
|
|
||||||
- Do not hardcode Discord limits from memory — verify current free-tier numbers (upload size, stream
|
|
||||||
resolution, rate limits) at implementation time, and keep them configurable.
|
|
||||||
- All outbound actions go through the pacing queue; no direct sends.
|
|
||||||
- Default to conservative limits and opt-in risky features.
|
|
||||||
- Test against throwaway accounts and private test guilds only.
|
|
||||||
- Run `pnpm testTypes` and `pnpm lint` before calling work done; `pnpm build` to compile.
|
|
||||||
|
|
||||||
## Open questions
|
|
||||||
|
|
||||||
- Which Phase 0 spike becomes the real foundation? (user decides after the spikes)
|
|
||||||
- Voice E2EE (DAVE) support status in the current voice stack — research before committing.
|
|
||||||
- Video pipeline feasibility: required codecs/containers and how to feed synthetic frames.
|
|
||||||
- Scope and auth model of the future remote proxy client and web debug UI.
|
|
||||||
- How much of the Vesktop Electron tree gets deleted once the daemon is self-sufficient.
|
|
||||||
|
|
||||||
## Dev commands
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm install
|
|
||||||
pnpm build # esbuild bundle into dist/
|
|
||||||
pnpm testTypes # tsc --noEmit
|
|
||||||
pnpm lint
|
|
||||||
```
|
|
||||||
@@ -1,237 +1,60 @@
|
|||||||
<!--
|
# Vesktop
|
||||||
Veskslop README
|
|
||||||
IMAGE SLOTS — drop the files in docs/images/ and they render automatically:
|
|
||||||
docs/images/veskslop-hero.png hero banner (~1600x500)
|
|
||||||
docs/images/veskslop-terminal.png terminal screenshot of the daemon
|
|
||||||
docs/images/veskslop-mcp.png AI control / MCP flow diagram
|
|
||||||
docs/images/veskslop-audio.png audio + video pipeline diagram
|
|
||||||
Rename or add slots as needed. Everything else here is text-only.
|
|
||||||
-->
|
|
||||||
|
|
||||||
<div align="center">
|
Vesktop is a custom Discord desktop app
|
||||||
|
|
||||||
<img src="docs/images/veskslop-hero.png" alt="Veskslop — Discord for the machines" width="100%" />
|
**Main features**:
|
||||||
|
- Vencord preinstalled
|
||||||
|
- Much more lightweight and faster than the official Discord app
|
||||||
|
- Linux Screenshare with sound & wayland
|
||||||
|
- Much better privacy, since Discord has no access to your system
|
||||||
|
|
||||||
# 💀 Veskslop
|
**Not yet supported**:
|
||||||
|
- Global Keybinds
|
||||||
|
- see the [Roadmap](https://github.com/Vencord/Vesktop/issues/324)
|
||||||
|
|
||||||
### The headless, agentic, MCP-pilled Discord client.
|

|
||||||
|

|
||||||
|
|
||||||
**No window. No mouse. No human. Just tools, pipes, and a Discord account living its best digital life.**
|
## Installing
|
||||||
|
|
||||||
[](#)
|
Visit https://vesktop.dev/install
|
||||||
[](#-why-though)
|
|
||||||
[](#-mcp-the-brain)
|
|
||||||
[](#-audio-pipes)
|
|
||||||
[](#-video-streams)
|
|
||||||
[](https://github.com/Vencord/Vesktop)
|
|
||||||
[](LICENSE)
|
|
||||||
|
|
||||||
</div>
|
## Building from Source
|
||||||
|
|
||||||
---
|
You need to have the following dependencies installed:
|
||||||
|
- [Git](https://git-scm.com/downloads)
|
||||||
|
- [Node.js](https://nodejs.org/en/download)
|
||||||
|
- pnpm: `npm install --global pnpm`
|
||||||
|
|
||||||
> ⚠️ **Veskslop is NOT Vesktop.** They are separate projects with separate goals.
|
Packaging will create builds in the dist/ folder
|
||||||
> [Vesktop](https://github.com/Vencord/Vesktop) makes Discord nicer for **humans**.
|
|
||||||
> Veskslop deletes the human entirely and installs a robot in their place.
|
|
||||||
|
|
||||||
## 🚀 What is Veskslop
|
|
||||||
|
|
||||||
Veskslop takes the beloved Vesktop base and surgically removes the part where a person has to click things. What remains is a **headless Discord client**: a daemon that speaks Discord natively, exposes itself as a **proxy**, and lets an **AI agent drive a real Discord account** over **MCP** — while you pipe **audio in**, pipe **audio out**, and shove **live video streams** down its throat.
|
|
||||||
|
|
||||||
It is Discord, but as infrastructure. It is the account you already have, plus `curl`, minus dignity.
|
|
||||||
|
|
||||||
Think of it as:
|
|
||||||
|
|
||||||
- 🖥️ **A Discord client with the client removed.** Runs on a server, in a container, on a Raspberry Pi, in your weird home lab, wherever.
|
|
||||||
- 🔀 **A proxy.** Messages, events, voice state, attachments — everything is forwarded, interceptable, scriptable, and API-shaped.
|
|
||||||
- 🧠 **An agent body.** Your MCP-capable AI gets a real account with real permissions and real consequences.
|
|
||||||
- 🔊 **An audio router.** Play anything into voice. Capture anything out. TTS in, STT out, pipes for days.
|
|
||||||
- 📹 **A streaming endpoint.** Push files, cameras, generative visuals, or pure slop into a voice channel.
|
|
||||||
- 🧪 **A science experiment that will absolutely not get your account banned** *(allegedly)*.
|
|
||||||
|
|
||||||
> **Status: pre-alpha.** The repo currently carries the Vesktop base + global shortcut work. The headless core is being carved out. Everything below is the target architecture — some of it works, some of it is a promise, all of it is the vision.
|
|
||||||
|
|
||||||
## 🗿 Why though
|
|
||||||
|
|
||||||
Because clicking is for mortals. Because you want a Discord presence that never sleeps, never typos, and never says "brb". Because "can an AI run a Discord account" is the kind of question that deserves a yes and a README.
|
|
||||||
|
|
||||||
Real reasons people actually want this:
|
|
||||||
|
|
||||||
- Automate community management, moderation, and notifications without a desktop app running.
|
|
||||||
- Bridge Discord into other systems: chatops, dashboards, MCP agents, home automation, whatever.
|
|
||||||
- Route voice audio both ways: feed a bot ears and a mouth.
|
|
||||||
- Stream synthetic video into a voice channel for demos, testing, or entertainment.
|
|
||||||
- Run entirely headless on a VPS with no GUI stack whatsoever.
|
|
||||||
|
|
||||||
## 🧠 MCP: the brain
|
|
||||||
|
|
||||||
Veskslop ships an **MCP server** so any MCP-capable agent (Claude, GPT-based agents, your local Llama abomination, a cron job with delusions of grandeur) can operate the account through structured tool calls.
|
|
||||||
|
|
||||||
**Planned tool surface** (names may change, vibes will not):
|
|
||||||
|
|
||||||
| Tool | Description |
|
|
||||||
| --- | --- |
|
|
||||||
| `discord_send_message` | Send a message to a channel, thread, or DM. |
|
|
||||||
| `discord_read_messages` | Read recent messages, with filters, pagination, and search. |
|
|
||||||
| `discord_list_guilds` | List guilds, channels, threads, and their IDs. |
|
|
||||||
| `discord_react` | Add or remove reactions. |
|
|
||||||
| `discord_edit_message` | Edit a message the account sent. |
|
|
||||||
| `discord_delete_message` | Delete a message the account sent. |
|
|
||||||
| `discord_upload_file` | Upload attachments with optional captions. |
|
|
||||||
| `discord_join_voice` | Join a voice channel and stay there like a loyal appliance. |
|
|
||||||
| `discord_leave_voice` | Leave the voice channel. Deeply emotional. |
|
|
||||||
| `discord_play_audio` | Pipe an audio source into the voice channel. |
|
|
||||||
| `discord_capture_audio` | Capture per-user audio out of the voice channel. |
|
|
||||||
| `discord_start_stream` | Start a video stream in the current voice channel. |
|
|
||||||
| `discord_stop_stream` | Stop the stream. |
|
|
||||||
| `discord_set_presence` | Status, activity, custom status. Look busy. |
|
|
||||||
| `discord_set_voice_state` | Mute, deafen, self-mute, self-deafen. |
|
|
||||||
| `discord_get_state` | Snapshot of the account, gateway, and pipelines. |
|
|
||||||
|
|
||||||
Every tool is designed to be **safe by default**: explicit opt-in for destructive actions, rate-limit awareness, and a sandbox mode that logs instead of sends.
|
|
||||||
|
|
||||||
## 🔀 Proxy mode
|
|
||||||
|
|
||||||
Veskslop is also a **proxy layer** in front of Discord:
|
|
||||||
|
|
||||||
- **REST passthrough** with authentication handled once.
|
|
||||||
- **Gateway event bus** — every event is consumable by local code, webhooks, or other processes.
|
|
||||||
- **Inbound injection** — anything that can hit the proxy can act as the account, with policy checks in between.
|
|
||||||
- **Message middleware** — transform, filter, log, reroute, or mirror traffic.
|
|
||||||
- **Multi-account ready** (eventually) — one daemon, several identities, maximum chaos.
|
|
||||||
|
|
||||||
```txt
|
|
||||||
your app / agent / script ──▶ Veskslop proxy ──▶ Discord
|
|
||||||
│
|
|
||||||
├─ policy / rate limit / audit log
|
|
||||||
└─ event bus ──▶ subscribers
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔊 Audio pipes
|
|
||||||
|
|
||||||
Voice is a first-class citizen, not an afterthought duct-taped on at 3am.
|
|
||||||
|
|
||||||
- **Audio in:** play PCM, Opus, or arbitrary encoded media into a voice channel. Wire up TTS, a soundboard, a radio stream, or an entire DAW.
|
|
||||||
- **Audio out:** capture the mixed voice channel, individual speakers, or both. Feed it to STT, a recorder, or your stream.
|
|
||||||
- **Virtual devices:** capture from a virtual sink and monitor, so routing stays clean on Linux (PipeWire / PulseAudio).
|
|
||||||
- **Format plumbing:** resampling, channel mapping, gain, and opus encoding handled by the pipeline.
|
|
||||||
- **Backpressure aware:** buffers exist. They are bounded. We learned things.
|
|
||||||
|
|
||||||
```txt
|
|
||||||
mic / TTS / file ──▶ ffmpeg ──▶ opus ──▶ Veskslop ──▶ voice channel
|
|
||||||
│
|
|
||||||
voice channel ──▶ per-user decode ──▶ mix ──▶ PCM/file/STT ──▶ anywhere
|
|
||||||
```
|
|
||||||
|
|
||||||
<br />
|
|
||||||
<img src="docs/images/veskslop-audio.png" alt="Veskslop audio pipeline" width="80%" />
|
|
||||||
<br />
|
|
||||||
|
|
||||||
## 📹 Video streams
|
|
||||||
|
|
||||||
Send video where Discord expects a camera:
|
|
||||||
|
|
||||||
- Push **files**, **test patterns**, **generated frames**, or **live sources** (camera, RTSP, screen capture) into a voice channel.
|
|
||||||
- Target Discord's expected codec/container (H.264 / VP8 in the right envelope) without babysitting it by hand.
|
|
||||||
- Manage streams as pipelines: start, stop, swap sources on the fly.
|
|
||||||
- Perfect for demos, dashboards-as-TV, synthetic presenters, and things we should not describe further.
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# planned CLI energy
|
git clone https://github.com/Vencord/Vesktop
|
||||||
veskslop stream start --guild 123 --channel 456 --source ./slop.mp4 --loop
|
cd Vesktop
|
||||||
|
|
||||||
|
# Install Dependencies
|
||||||
|
pnpm i
|
||||||
|
|
||||||
|
# Either run it without packaging
|
||||||
|
pnpm start
|
||||||
|
|
||||||
|
# Or package (will build packages for your OS)
|
||||||
|
pnpm package
|
||||||
|
|
||||||
|
# Or only build the Linux Pacman package
|
||||||
|
pnpm package --linux pacman
|
||||||
|
|
||||||
|
# Or package to a directory only
|
||||||
|
pnpm package:dir
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🏗️ Architecture
|
## Building LibVesktop from Source
|
||||||
|
|
||||||
```txt
|
This is a small C++ helper library Vesktop uses on Linux to emit D-Bus events. By default, prebuilt binaries for x64 and arm64 are used.
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
│ MCP CLIENTS │
|
|
||||||
│ Claude · GPT agents · local models · scripts │
|
|
||||||
└───────────────────────────────┬─────────────────────────────────┘
|
|
||||||
│ MCP (stdio / HTTP)
|
|
||||||
┌───────────────────────────────▼─────────────────────────────────┐
|
|
||||||
│ VESKSLOP CORE │
|
|
||||||
│ tool server · session daemon · account state · rate guard │
|
|
||||||
│ policy engine · audit log · event bus · config │
|
|
||||||
└───────┬───────────────────┬────────────────────┬────────────────┘
|
|
||||||
│ │ │
|
|
||||||
┌───────▼───────┐ ┌───────▼────────┐ ┌───────▼──────────┐
|
|
||||||
│ Discord API │ │ Audio pipeline │ │ Video pipeline │
|
|
||||||
│ REST + WSS │ │ Opus / WebRTC │ │ encode / stream │
|
|
||||||
└───────────────┘ └────────────────┘ └──────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## ⚡ Quickstart
|
If you want to build it from source:
|
||||||
|
1. Install build dependencies:
|
||||||
> These commands describe the intended developer flow. The headless daemon is a work in progress; today the repo builds the Vesktop base.
|
- Debian/Ubuntu: `apt install build-essential python3 curl pkg-config libglib2.0-dev`
|
||||||
|
- Fedora: `dnf install @c-development @development-tools python3 curl pkgconf-pkg-config glib2-devel`
|
||||||
```sh
|
2. Run `pnpm buildLibVesktop`
|
||||||
git clone https://git.0xfeedc0de.com/zib/Veskslop.git
|
3. From now on, building Vesktop will use your own build
|
||||||
cd Veskslop
|
|
||||||
|
|
||||||
# dependencies
|
|
||||||
pnpm install
|
|
||||||
|
|
||||||
# build
|
|
||||||
pnpm build
|
|
||||||
|
|
||||||
# run the daemon (planned)
|
|
||||||
DISCORD_TOKEN="your-token-here" pnpm vesk slop
|
|
||||||
```
|
|
||||||
|
|
||||||
Connect an MCP client (Claude Desktop style):
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"veskslop": {
|
|
||||||
"command": "pnpm",
|
|
||||||
"args": ["vesk", "mcp"],
|
|
||||||
"env": {
|
|
||||||
"DISCORD_TOKEN": "your-token-here"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🗺️ Roadmap
|
|
||||||
|
|
||||||
- [x] Fork Vesktop and keep the good parts
|
|
||||||
- [x] Global shortcuts / keybinds base
|
|
||||||
- [ ] Extract a true headless core (no Electron window)
|
|
||||||
- [ ] Gateway + REST session daemon
|
|
||||||
- [ ] MCP server with the tool surface above
|
|
||||||
- [ ] Outbound audio pipeline
|
|
||||||
- [ ] Inbound audio capture pipeline
|
|
||||||
- [ ] Video stream pipeline
|
|
||||||
- [ ] Proxy mode + event bus
|
|
||||||
- [ ] Policy engine, audit log, rate limiting
|
|
||||||
- [ ] Container image (`veskslop:slop-latest`)
|
|
||||||
- [ ] Documentation that is not a README
|
|
||||||
- [ ] Achieve AGI (stretch goal)
|
|
||||||
|
|
||||||
## 🧨 Disclaimer
|
|
||||||
|
|
||||||
Veskslop automates a **user account**, which is the kind of thing Discord's Terms of Service has opinions about. Run it on an account you can afford to lose, respect rate limits, do not use it to spam or harass, and generally do not be the reason we cannot have nice things. This project is unaffiliated with Discord Inc. and with the Vesktop project. You are the captain of this ship, and the ship is on fire by design.
|
|
||||||
|
|
||||||
## 🙏 Credits
|
|
||||||
|
|
||||||
Veskslop stands on the shoulders of legends:
|
|
||||||
|
|
||||||
- [Vesktop](https://github.com/Vencord/Vesktop) — the base, the vibe, the head start.
|
|
||||||
- [Vencord](https://github.com/Vencord/Vencord) — the plugin ecosystem that made Vesktop what it is.
|
|
||||||
- [Discord](https://discord.com) — the API we lovingly abuse.
|
|
||||||
- Every protocol implementation that let us shove Opus through a websocket at 3am.
|
|
||||||
|
|
||||||
## 📜 License
|
|
||||||
|
|
||||||
GPL-3.0-or-later. See [LICENSE](LICENSE). Fork responsibly, ship slop ethically.
|
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
|
|
||||||
<br />
|
|
||||||
|
|
||||||
**Veskslop** — *because your Discord account deserves a mind of its own.*
|
|
||||||
|
|
||||||
</div>
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.7 MiB |
+7
-1
@@ -43,6 +43,12 @@ const options = {
|
|||||||
description: "Set User-Agent to a specific operating system. May trigger anti-spam or break voice chat",
|
description: "Set User-Agent to a specific operating system. May trigger anti-spam or break voice chat",
|
||||||
options: ["windows", "linux", "darwin"]
|
options: ["windows", "linux", "darwin"]
|
||||||
},
|
},
|
||||||
|
"run-shortcut": {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"Run a predefined shortcut action (for custom key binds). Vesktop has to be open for this to have any effect",
|
||||||
|
argumentName: "action"
|
||||||
|
},
|
||||||
repair: {
|
repair: {
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
description: "Repair the application by re-downloading the latest Vencord files"
|
description: "Repair the application by re-downloading the latest Vencord files"
|
||||||
@@ -139,7 +145,7 @@ export function checkCommandLineForHelpOrVersion() {
|
|||||||
app.exit(1);
|
app.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ("options" in def && !def.options?.includes(value as string)) {
|
if ("options" in def && !def.options?.includes(value as any)) {
|
||||||
console.error(`Invalid value for --${name}: ${value}\nExpected one of: ${def.options.join(", ")}`);
|
console.error(`Invalid value for --${name}: ${value}\nExpected one of: ${def.options.join(", ")}`);
|
||||||
app.exit(1);
|
app.exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,3 +13,5 @@ export const AppEvents = new EventEmitter<{
|
|||||||
userAssetChanged: [UserAssetType];
|
userAssetChanged: [UserAssetType];
|
||||||
setTrayVariant: ["tray" | "trayUnread"];
|
setTrayVariant: ["tray" | "trayUnread"];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
export const appReady = new Promise<void>(resolve => AppEvents.once("appLoaded", () => resolve()));
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { randomUUID } from "crypto";
|
|||||||
import { ipcMain } from "electron";
|
import { ipcMain } from "electron";
|
||||||
import { IpcEvents } from "shared/IpcEvents";
|
import { IpcEvents } from "shared/IpcEvents";
|
||||||
|
|
||||||
|
import { appReady } from "./events";
|
||||||
import { mainWin } from "./mainWindow";
|
import { mainWin } from "./mainWindow";
|
||||||
|
|
||||||
const resolvers = new Map<string, Record<"resolve" | "reject", (data: any) => void>>();
|
const resolvers = new Map<string, Record<"resolve" | "reject", (data: any) => void>>();
|
||||||
@@ -30,7 +31,9 @@ export interface IpcResponse {
|
|||||||
*
|
*
|
||||||
* You must add a handler for the message in the renderer process.
|
* You must add a handler for the message in the renderer process.
|
||||||
*/
|
*/
|
||||||
export function sendRendererCommand<T = any>(message: string, data?: any) {
|
export async function sendRendererCommand<T = any>(message: string, data?: any) {
|
||||||
|
await appReady;
|
||||||
|
|
||||||
if (mainWin.isDestroyed()) {
|
if (mainWin.isDestroyed()) {
|
||||||
console.warn("Main window is destroyed, cannot send IPC command:", message);
|
console.warn("Main window is destroyed, cannot send IPC command:", message);
|
||||||
return Promise.reject(new Error("Main window is destroyed"));
|
return Promise.reject(new Error("Main window is destroyed"));
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/*
|
||||||
|
* Vesktop, a desktop app aiming to give you a snappier Discord Experience
|
||||||
|
* Copyright (c) 2026 Vendicated and Vesktop contributors
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { app } from "electron";
|
||||||
|
import { rmSync } from "fs";
|
||||||
|
import { rm } from "fs/promises";
|
||||||
|
import { createServer, Server } from "net";
|
||||||
|
import { join } from "path";
|
||||||
|
import { IpcCommands } from "shared/IpcEvents";
|
||||||
|
|
||||||
|
import { sendRendererCommand } from "./ipcCommands";
|
||||||
|
|
||||||
|
const { env, platform } = process;
|
||||||
|
|
||||||
|
let server: Server;
|
||||||
|
|
||||||
|
const SOCKET_PATH =
|
||||||
|
platform === "win32"
|
||||||
|
? "\\\\?\\pipe\\vesktop.sock"
|
||||||
|
: join(env.XDG_RUNTIME_DIR || env.TMPDIR || env.TMP || env.TEMP || "/tmp", "vesktop.sock");
|
||||||
|
|
||||||
|
export async function initSocket() {
|
||||||
|
await rm(SOCKET_PATH, { force: true });
|
||||||
|
|
||||||
|
server = createServer(socket => {
|
||||||
|
socket.setEncoding("utf-8");
|
||||||
|
|
||||||
|
const reply = (message: string) => {
|
||||||
|
socket.write(message);
|
||||||
|
socket.end();
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.on("data", async (data: string) => {
|
||||||
|
try {
|
||||||
|
const [command, ...args] = data.trim().split(":");
|
||||||
|
|
||||||
|
if (command === "run-shortcut") {
|
||||||
|
await sendRendererCommand(IpcCommands.KEY_BINDS_HANDLE, args[0]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reply("Unknown Command");
|
||||||
|
} catch (e) {
|
||||||
|
reply("Unknown Error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(SOCKET_PATH);
|
||||||
|
server.on("error", err => {
|
||||||
|
console.error("Vesktop IPC Socket Error:", err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
app.on("will-quit", () => {
|
||||||
|
server?.close();
|
||||||
|
rmSync(SOCKET_PATH, { force: true });
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/*
|
||||||
|
* Vesktop, a desktop app aiming to give you a snappier Discord Experience
|
||||||
|
* Copyright (c) 2026 Vendicated and Vesktop contributors
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { app, globalShortcut } from "electron";
|
||||||
|
import { IpcCommands } from "shared/IpcEvents";
|
||||||
|
|
||||||
|
import { sendRendererCommand } from "./ipcCommands";
|
||||||
|
import { Settings } from "./settings";
|
||||||
|
|
||||||
|
function unregisterKeyBinds() {
|
||||||
|
if (!app.isReady()) return;
|
||||||
|
globalShortcut.unregisterAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerKeyBinds() {
|
||||||
|
await app.whenReady();
|
||||||
|
|
||||||
|
unregisterKeyBinds();
|
||||||
|
|
||||||
|
let success = true;
|
||||||
|
|
||||||
|
for (const { action, enabled, key } of Settings.store.keyBinds || []) {
|
||||||
|
if (!enabled || !key || action === "unassigned") continue;
|
||||||
|
|
||||||
|
const registeredSuccessfully = globalShortcut.register(key, () =>
|
||||||
|
sendRendererCommand(IpcCommands.KEY_BINDS_HANDLE, action)
|
||||||
|
);
|
||||||
|
success &&= registeredSuccessfully;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendRendererCommand(IpcCommands.KEY_BINDS_SET_STATUS, success);
|
||||||
|
}
|
||||||
|
|
||||||
|
Settings.addChangeListener("keyBinds", registerKeyBinds);
|
||||||
|
|
||||||
|
app.on("will-quit", unregisterKeyBinds);
|
||||||
+19
-1
@@ -11,9 +11,15 @@ import "./userAssets";
|
|||||||
import "./vesktopProtocol";
|
import "./vesktopProtocol";
|
||||||
|
|
||||||
import { app, BrowserWindow, nativeTheme } from "electron";
|
import { app, BrowserWindow, nativeTheme } from "electron";
|
||||||
|
import { IpcCommands } from "shared/IpcEvents";
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-duplicate-imports
|
||||||
|
import { CommandLine } from "./cli";
|
||||||
import { DATA_DIR } from "./constants";
|
import { DATA_DIR } from "./constants";
|
||||||
import { createFirstLaunchTour } from "./firstLaunch";
|
import { createFirstLaunchTour } from "./firstLaunch";
|
||||||
|
import { sendRendererCommand } from "./ipcCommands";
|
||||||
|
import { initSocket } from "./ipcSocket";
|
||||||
|
import { registerKeyBinds } from "./keyBinds";
|
||||||
import { createWindows, mainWin } from "./mainWindow";
|
import { createWindows, mainWin } from "./mainWindow";
|
||||||
import { registerMediaPermissionsHandler } from "./mediaPermissions";
|
import { registerMediaPermissionsHandler } from "./mediaPermissions";
|
||||||
import { registerScreenShareHandler } from "./screenShare";
|
import { registerScreenShareHandler } from "./screenShare";
|
||||||
@@ -71,6 +77,9 @@ function init() {
|
|||||||
// Supposed to be fixed already according to comments there, but it's just not lol, I can repro on Electron 43.0.0
|
// Supposed to be fixed already according to comments there, but it's just not lol, I can repro on Electron 43.0.0
|
||||||
// when moving the window from my main monitor (HDR - not sure if this is relevant lol) to second monitor (SDR) and back
|
// when moving the window from my main monitor (HDR - not sure if this is relevant lol) to second monitor (SDR) and back
|
||||||
disabledFeatures.add("WaylandWpColorManagerV1");
|
disabledFeatures.add("WaylandWpColorManagerV1");
|
||||||
|
|
||||||
|
// Enable Wayland Portal for global shortcuts
|
||||||
|
enabledFeatures.add("GlobalShortcutsPortal");
|
||||||
}
|
}
|
||||||
|
|
||||||
disabledFeatures.forEach(feat => enabledFeatures.delete(feat));
|
disabledFeatures.forEach(feat => enabledFeatures.delete(feat));
|
||||||
@@ -105,6 +114,8 @@ function init() {
|
|||||||
|
|
||||||
registerScreenShareHandler();
|
registerScreenShareHandler();
|
||||||
registerMediaPermissionsHandler();
|
registerMediaPermissionsHandler();
|
||||||
|
registerKeyBinds();
|
||||||
|
initSocket();
|
||||||
|
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|
||||||
@@ -114,7 +125,7 @@ function init() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!app.requestSingleInstanceLock({ IS_DEV })) {
|
if (!app.requestSingleInstanceLock({ IS_DEV, args: CommandLine })) {
|
||||||
if (IS_DEV) {
|
if (IS_DEV) {
|
||||||
console.log("Vesktop is already running. Quitting previous instance...");
|
console.log("Vesktop is already running. Quitting previous instance...");
|
||||||
init();
|
init();
|
||||||
@@ -144,6 +155,13 @@ app.on("window-all-closed", () => {
|
|||||||
if (process.platform !== "darwin") app.quit();
|
if (process.platform !== "darwin") app.quit();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.on("second-instance", (_e, _argv, _cwd, data) => {
|
||||||
|
const { args } = data as { args: typeof CommandLine; IS_DEV: boolean };
|
||||||
|
const { "run-shortcut": shortcut } = args.values;
|
||||||
|
|
||||||
|
if (shortcut) sendRendererCommand(IpcCommands.KEY_BINDS_HANDLE, shortcut);
|
||||||
|
});
|
||||||
|
|
||||||
// Sets the WebRTC IP handling policy for all current and future windows.
|
// Sets the WebRTC IP handling policy for all current and future windows.
|
||||||
// Switching to "default_public_and_private_interfaces" may fix calls stuck at "DTLS Connecting" when using VPNs, Tailscale, etc.
|
// Switching to "default_public_and_private_interfaces" may fix calls stuck at "DTLS Connecting" when using VPNs, Tailscale, etc.
|
||||||
// https://github.com/Vencord/Vesktop/issues/876
|
// https://github.com/Vencord/Vesktop/issues/876
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { classNameFactory } from "@vencord/types/api/Styles";
|
|||||||
import { BaseText, Divider, ErrorBoundary } from "@vencord/types/components";
|
import { BaseText, Divider, ErrorBoundary } from "@vencord/types/components";
|
||||||
import { ComponentType } from "react";
|
import { ComponentType } from "react";
|
||||||
import { WebRTCIPHandlingPolicyPicker } from "renderer/components/settings/WebRTCIPHandlingPolicyPicker";
|
import { WebRTCIPHandlingPolicyPicker } from "renderer/components/settings/WebRTCIPHandlingPolicyPicker";
|
||||||
|
import { KeybindsButton } from "renderer/globalShortcuts/ShortcutSettings";
|
||||||
import { getValueAndOnChange, Settings, useSettings } from "renderer/settings";
|
import { getValueAndOnChange, Settings, useSettings } from "renderer/settings";
|
||||||
import { isMac } from "renderer/utils";
|
import { isMac } from "renderer/utils";
|
||||||
|
|
||||||
@@ -122,7 +123,8 @@ const SettingsOptions: Record<string, Array<BooleanSetting | SettingsComponent>>
|
|||||||
key: "disableSmoothScroll",
|
key: "disableSmoothScroll",
|
||||||
title: "Disable smooth scrolling",
|
title: "Disable smooth scrolling",
|
||||||
description: "Disables smooth scrolling"
|
description: "Disables smooth scrolling"
|
||||||
}
|
},
|
||||||
|
KeybindsButton
|
||||||
],
|
],
|
||||||
Notifications: [
|
Notifications: [
|
||||||
NotificationBadgeToggle,
|
NotificationBadgeToggle,
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
.vcd-shortcuts-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1em;
|
||||||
|
padding-block: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vcd-shortcuts-group {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25em 0.5em;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto auto;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vcd-shortcuts-label:first-child {
|
||||||
|
grid-column: 1 / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vcd-shortcuts-label:nth-child(2) {
|
||||||
|
grid-column: 2 / 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vcd-shortcuts-recorder {
|
||||||
|
display: flex;
|
||||||
|
background-color: var(--input-background-default);
|
||||||
|
border: 1px solid;
|
||||||
|
border-color: var(--input-border-default);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
width: 100%;
|
||||||
|
min-height: calc(var(--select-option-height) - 2px);
|
||||||
|
padding: 3px 4px 3px 12px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vcd-shortcuts-record-input {
|
||||||
|
background-color: transparent;
|
||||||
|
border: none;
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vcd-shortcuts-record-button {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: max-content;
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
/*
|
||||||
|
* Vesktop, a desktop app aiming to give you a snappier Discord Experience
|
||||||
|
* Copyright (c) 2026 Vendicated and Vesktop contributors
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
import "./ShortcutSettings.css";
|
||||||
|
|
||||||
|
import { BaseText, Button, Card, DeleteIcon, Switch } from "@vencord/types/components";
|
||||||
|
import { classNameFactory, identity, openModal } from "@vencord/types/utils";
|
||||||
|
import { Modal, React, Select, useState } from "@vencord/types/webpack/common";
|
||||||
|
import { SettingsComponent } from "renderer/components/settings/Settings";
|
||||||
|
import { onIpcCommand } from "renderer/ipcCommands";
|
||||||
|
import { reactiveValue } from "renderer/reactiveState";
|
||||||
|
import { Settings } from "renderer/settings";
|
||||||
|
import { IpcCommands } from "shared/IpcEvents";
|
||||||
|
|
||||||
|
import { recordKeybind } from "./recordKeybind";
|
||||||
|
|
||||||
|
const cl = classNameFactory("vcd-shortcuts-");
|
||||||
|
|
||||||
|
const ShortcutActions = {
|
||||||
|
unassigned: "Unassigned",
|
||||||
|
mute: "Mute",
|
||||||
|
unmute: "Unmute",
|
||||||
|
toggleMute: "Toggle Mute",
|
||||||
|
toggleDeafen: "Toggle Deafen",
|
||||||
|
toggleStreamerMode: "Toggle Streamer Mode",
|
||||||
|
pushToTalkNormalToggle: "Push-to-Talk (Normal) Toggle",
|
||||||
|
pushToTalkNormalStart: "Push-to-Talk (Normal) Start",
|
||||||
|
pushToTalkNormalStop: "Push-to-Talk (Normal) Stop",
|
||||||
|
pushToTalkPriorityToggle: "Push-to-Talk (Priority) Toggle",
|
||||||
|
pushToTalkPriorityStart: "Push-to-Talk (Priority) Start",
|
||||||
|
pushToTalkPriorityStop: "Push-to-Talk (Priority) Stop",
|
||||||
|
disconnectFromVoiceChannel: "Disconnect from VC"
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type ShortcutAction = keyof typeof ShortcutActions;
|
||||||
|
|
||||||
|
const shortcutStatus = reactiveValue(true);
|
||||||
|
onIpcCommand(IpcCommands.KEY_BINDS_SET_STATUS, status => (shortcutStatus.value = status));
|
||||||
|
|
||||||
|
export interface KeyBind {
|
||||||
|
id: string;
|
||||||
|
enabled: boolean;
|
||||||
|
action: ShortcutAction;
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function KeyBindGroup({
|
||||||
|
keyBind,
|
||||||
|
setKeyBind,
|
||||||
|
deleteKeyBind
|
||||||
|
}: {
|
||||||
|
keyBind: KeyBind;
|
||||||
|
setKeyBind(keyBind: KeyBind): void;
|
||||||
|
deleteKeyBind(): void;
|
||||||
|
}) {
|
||||||
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
|
const [abortController, setAbortController] = useState<AbortController | null>(null);
|
||||||
|
|
||||||
|
const actionId = React.useId();
|
||||||
|
const keyId = React.useId();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cl("group")}>
|
||||||
|
<BaseText size="md" weight="medium" className={cl("label")} id={actionId}>
|
||||||
|
Action
|
||||||
|
</BaseText>
|
||||||
|
<BaseText size="md" weight="medium" className={cl("label")} id={keyId}>
|
||||||
|
Keybind
|
||||||
|
</BaseText>
|
||||||
|
|
||||||
|
{/* @ts-expect-error incorrect type for aria-labelledby */}
|
||||||
|
<Select
|
||||||
|
aria-labelledby={actionId}
|
||||||
|
options={Object.entries(ShortcutActions).map(([action, label]) => ({ label, value: action }))}
|
||||||
|
isSelected={o => o === keyBind.action}
|
||||||
|
select={o => setKeyBind({ ...keyBind, action: o })}
|
||||||
|
serialize={identity}
|
||||||
|
/>
|
||||||
|
<div className={cl("recorder")} aria-labelledby={keyId}>
|
||||||
|
<input
|
||||||
|
className={cl("record-input")}
|
||||||
|
type="text"
|
||||||
|
value={keyBind.key || "No Keybind Set"}
|
||||||
|
readOnly
|
||||||
|
disabled={!keyBind.key}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
className="record-button"
|
||||||
|
variant="secondary"
|
||||||
|
size="small"
|
||||||
|
onClick={async () => {
|
||||||
|
if (isRecording) {
|
||||||
|
abortController?.abort();
|
||||||
|
setIsRecording(false);
|
||||||
|
setAbortController(null);
|
||||||
|
} else {
|
||||||
|
const controller = new AbortController();
|
||||||
|
setAbortController(controller);
|
||||||
|
setIsRecording(true);
|
||||||
|
const key = await recordKeybind(controller.signal).catch(() => null);
|
||||||
|
setIsRecording(false);
|
||||||
|
setAbortController(null);
|
||||||
|
if (key) setKeyBind({ ...keyBind, key });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isRecording ? "Cancel Recording" : "Record"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button size="iconOnly" variant="secondary" aria-label="Delete Keybind" onClick={deleteKeyBind}>
|
||||||
|
<DeleteIcon />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Switch checked={keyBind.enabled} onChange={v => setKeyBind({ ...keyBind, enabled: v })} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KeyBindSettings() {
|
||||||
|
const [keyBinds, setKeyBinds] = useState<KeyBind[]>(Settings.store.keyBinds ?? []);
|
||||||
|
const shortcutsStatus = shortcutStatus.use();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cl("container")}>
|
||||||
|
{shortcutsStatus === false && (
|
||||||
|
<Card variant="danger">
|
||||||
|
<BaseText size="md" weight="medium">
|
||||||
|
Some of your keybinds failed to register.
|
||||||
|
</BaseText>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{keyBinds.map((keyBind, index) => (
|
||||||
|
<KeyBindGroup
|
||||||
|
key={keyBind.id}
|
||||||
|
keyBind={keyBind}
|
||||||
|
setKeyBind={newKeyBind => {
|
||||||
|
const newKeyBinds = [...keyBinds];
|
||||||
|
newKeyBinds[index] = newKeyBind;
|
||||||
|
setKeyBinds(newKeyBinds);
|
||||||
|
Settings.store.keyBinds = newKeyBinds;
|
||||||
|
}}
|
||||||
|
deleteKeyBind={() => {
|
||||||
|
const newKeyBinds = keyBinds.filter((_, i) => i !== index);
|
||||||
|
setKeyBinds(newKeyBinds);
|
||||||
|
Settings.store.keyBinds = newKeyBinds;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setKeyBinds(binds => [
|
||||||
|
...binds,
|
||||||
|
{
|
||||||
|
action: "unassigned",
|
||||||
|
key: "",
|
||||||
|
enabled: true,
|
||||||
|
id: crypto.randomUUID()
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add a Keybind
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openKeybindsModal() {
|
||||||
|
openModal(props => (
|
||||||
|
<Modal {...props} size="lg" title="Keybinds">
|
||||||
|
<KeyBindSettings />
|
||||||
|
</Modal>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const KeybindsButton: SettingsComponent = () => {
|
||||||
|
return <Button onClick={() => openKeybindsModal()}>Configure Keybinds</Button>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/*
|
||||||
|
* Vesktop, a desktop app aiming to give you a snappier Discord Experience
|
||||||
|
* Copyright (c) 2026 Vendicated and Vesktop contributors
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Logger } from "@vencord/types/utils";
|
||||||
|
import { waitFor } from "@vencord/types/webpack";
|
||||||
|
import { FluxDispatcher } from "@vencord/types/webpack/common";
|
||||||
|
|
||||||
|
import { ShortcutAction } from "./ShortcutSettings";
|
||||||
|
|
||||||
|
const logger = new Logger("VesktopKeyBindHandler");
|
||||||
|
|
||||||
|
interface KeyBindAction {
|
||||||
|
onTrigger(...args: any[]): void;
|
||||||
|
isPressed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let KeyBindActions: Record<string, KeyBindAction>;
|
||||||
|
|
||||||
|
waitFor(["dispatch", "subscribe"], FluxDispatcher => {
|
||||||
|
FluxDispatcher.subscribe("KEYBINDS_REGISTER_GLOBAL_KEYBIND_ACTIONS", event => {
|
||||||
|
KeyBindActions = event.keybinds;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export function handleKeyBind(keyBind: ShortcutAction) {
|
||||||
|
logger.debug(`Handling key bind action: ${keyBind}`);
|
||||||
|
|
||||||
|
if (!KeyBindActions) {
|
||||||
|
logger.error("Failed to retrieve KeyBindActions, cannot handle key bind");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (keyBind) {
|
||||||
|
case "unassigned":
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "mute":
|
||||||
|
case "unmute":
|
||||||
|
FluxDispatcher.dispatch({
|
||||||
|
type: "AUDIO_SET_SELF_MUTE",
|
||||||
|
context: "default",
|
||||||
|
syncRemote: true,
|
||||||
|
playSoundEffect: true,
|
||||||
|
mute: keyBind === "mute"
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "toggleMute":
|
||||||
|
KeyBindActions.TOGGLE_MUTE.onTrigger();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "toggleDeafen":
|
||||||
|
KeyBindActions.TOGGLE_DEAFEN.onTrigger();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "toggleStreamerMode":
|
||||||
|
KeyBindActions.TOGGLE_STREAMER_MODE.onTrigger();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "disconnectFromVoiceChannel":
|
||||||
|
KeyBindActions.DISCONNECT_FROM_VOICE_CHANNEL.onTrigger();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "pushToTalkNormalStart":
|
||||||
|
case "pushToTalkNormalStop":
|
||||||
|
case "pushToTalkNormalToggle": {
|
||||||
|
const isPressed = keyBind.includes("Toggle")
|
||||||
|
? !KeyBindActions.PUSH_TO_TALK.isPressed
|
||||||
|
: keyBind.includes("Start");
|
||||||
|
|
||||||
|
KeyBindActions.PUSH_TO_TALK.onTrigger(isPressed, "default");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "pushToTalkPriorityStart":
|
||||||
|
case "pushToTalkPriorityStop":
|
||||||
|
case "pushToTalkPriorityToggle": {
|
||||||
|
const isPressed = keyBind.includes("Toggle")
|
||||||
|
? !KeyBindActions.PUSH_TO_TALK_PRIORITY.isPressed
|
||||||
|
: keyBind.includes("Start");
|
||||||
|
|
||||||
|
KeyBindActions.PUSH_TO_TALK_PRIORITY.onTrigger(isPressed, "default");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
logger.warn(`Unknown key bind action: ${keyBind}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
/*
|
||||||
|
* Vesktop, a desktop app aiming to give you a snappier Discord Experience
|
||||||
|
* Copyright (c) 2026 Vendicated and Vesktop contributors
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MODIFIER_KEYS = new Set(["Control", "Shift", "Alt", "Meta", "Dead"]);
|
||||||
|
|
||||||
|
const VALID_PUNCTUATION = new Set(')!@#$%^&*(;:=<,_-.>?/~`{][|\\}"');
|
||||||
|
|
||||||
|
// Codes whose accelerator name matches the code value exactly
|
||||||
|
const PASSTHROUGH_CODES = new Set([
|
||||||
|
"Space",
|
||||||
|
"Tab",
|
||||||
|
"Backspace",
|
||||||
|
"Delete",
|
||||||
|
"Insert",
|
||||||
|
"Home",
|
||||||
|
"End",
|
||||||
|
"PageUp",
|
||||||
|
"PageDown",
|
||||||
|
"PrintScreen",
|
||||||
|
"Escape"
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Codes that need an explicit rename
|
||||||
|
const CODE_MAP: Record<string, string> = {
|
||||||
|
Enter: "Return",
|
||||||
|
NumpadEnter: "Return",
|
||||||
|
NumpadDecimal: "numdec",
|
||||||
|
NumpadAdd: "numadd",
|
||||||
|
NumpadSubtract: "numsub",
|
||||||
|
NumpadMultiply: "nummult",
|
||||||
|
NumpadDivide: "numdiv",
|
||||||
|
CapsLock: "Capslock",
|
||||||
|
NumLock: "Numlock",
|
||||||
|
ScrollLock: "Scrolllock",
|
||||||
|
ArrowUp: "Up",
|
||||||
|
ArrowDown: "Down",
|
||||||
|
ArrowLeft: "Left",
|
||||||
|
ArrowRight: "Right"
|
||||||
|
};
|
||||||
|
|
||||||
|
const MEDIA_KEY_MAP: Record<string, string> = {
|
||||||
|
AudioVolumeUp: "VolumeUp",
|
||||||
|
AudioVolumeDown: "VolumeDown",
|
||||||
|
AudioVolumeMute: "VolumeMute",
|
||||||
|
MediaTrackNext: "MediaNextTrack",
|
||||||
|
MediaTrackPrevious: "MediaPreviousTrack",
|
||||||
|
MediaStop: "MediaStop",
|
||||||
|
MediaPlayPause: "MediaPlayPause"
|
||||||
|
};
|
||||||
|
|
||||||
|
function codeToAcceleratorKey(event: KeyboardEvent): string | null {
|
||||||
|
const { code, key } = event;
|
||||||
|
|
||||||
|
// Letters: KeyA -> A
|
||||||
|
const letterMatch = /^Key([A-Z])$/.exec(code);
|
||||||
|
if (letterMatch) return letterMatch[1];
|
||||||
|
|
||||||
|
// Digits: Digit0 -> 0
|
||||||
|
const digitMatch = /^Digit(\d)$/.exec(code);
|
||||||
|
if (digitMatch) return digitMatch[1];
|
||||||
|
|
||||||
|
// Function keys: F1-F24
|
||||||
|
if (/^F([1-9]|1\d|2[0-4])$/.test(code)) return code;
|
||||||
|
|
||||||
|
// Numpad digits: Numpad0 -> num0
|
||||||
|
const numpadDigitMatch = /^Numpad(\d)$/.exec(code);
|
||||||
|
if (numpadDigitMatch) return "num" + numpadDigitMatch[1];
|
||||||
|
|
||||||
|
if (PASSTHROUGH_CODES.has(code)) return code;
|
||||||
|
if (code in CODE_MAP) return CODE_MAP[code];
|
||||||
|
|
||||||
|
// Media / audio keys (identified by event.key)
|
||||||
|
if (key in MEDIA_KEY_MAP) return MEDIA_KEY_MAP[key];
|
||||||
|
|
||||||
|
// Punctuation keys
|
||||||
|
if (key === "+") return "Plus";
|
||||||
|
if (VALID_PUNCTUATION.has(key)) return key;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recordKeybind(signal?: AbortSignal): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
reject(new DOMException("Recording aborted", "AbortError"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
document.removeEventListener("keydown", onKeyDown, { capture: true });
|
||||||
|
signal?.removeEventListener("abort", onAbort);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAbort() {
|
||||||
|
cleanup();
|
||||||
|
reject(new DOMException("Recording aborted", "AbortError"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyDown(event: KeyboardEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
|
||||||
|
if (MODIFIER_KEYS.has(event.key)) return;
|
||||||
|
|
||||||
|
const key = codeToAcceleratorKey(event);
|
||||||
|
if (key === null) return;
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (event.ctrlKey) parts.push("Ctrl");
|
||||||
|
if (event.shiftKey) parts.push("Shift");
|
||||||
|
if (event.altKey) parts.push("Alt");
|
||||||
|
if (event.metaKey) parts.push("Super");
|
||||||
|
parts.push(key);
|
||||||
|
|
||||||
|
resolve(parts.join("+"));
|
||||||
|
}
|
||||||
|
|
||||||
|
signal?.addEventListener("abort", onAbort, { once: true });
|
||||||
|
document.addEventListener("keydown", onKeyDown, { capture: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 { handleKeyBind } from "./globalShortcuts/keybindHandler";
|
||||||
|
|
||||||
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.KEY_BINDS_HANDLE, handleKeyBind);
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/*
|
||||||
|
* Vesktop, a desktop app aiming to give you a snappier Discord Experience
|
||||||
|
* Copyright (c) 2026 Vendicated and Vesktop contributors
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Button, ErrorBoundary } from "@vencord/types/components";
|
||||||
|
import { openKeybindsModal } from "renderer/globalShortcuts/ShortcutSettings";
|
||||||
|
|
||||||
|
import { addPatch } from "./shared";
|
||||||
|
|
||||||
|
addPatch({
|
||||||
|
patches: [
|
||||||
|
{
|
||||||
|
find: "#{intl::KEYBIND_IN_BROSWER_NOTICE}",
|
||||||
|
replacement: {
|
||||||
|
match: /:(.{0,10}jsx.{0,50}messageType:.{0,100}#{intl::KEYBIND_IN_BROSWER_NOTICE})/,
|
||||||
|
replace: ":true?$self.renderKeybindsButton():$1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
renderKeybindsButton: ErrorBoundary.wrap(
|
||||||
|
() => <Button onClick={() => openKeybindsModal()}>Configure Vesktop Keybinds</Button>,
|
||||||
|
{ noop: true }
|
||||||
|
)
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
* Vesktop, a desktop app aiming to give you a snappier Discord Experience
|
||||||
|
* Copyright (c) 2026 Vendicated and Vesktop contributors
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useForceUpdater } from "@vencord/types/utils";
|
||||||
|
import { useEffect } from "@vencord/types/webpack/common";
|
||||||
|
|
||||||
|
interface ReactiveValue<T> {
|
||||||
|
value: T;
|
||||||
|
use(): T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reactiveValue<T>(initialValue: T): ReactiveValue<T>;
|
||||||
|
export function reactiveValue<T>(initialValue?: T): ReactiveValue<T | undefined>;
|
||||||
|
export function reactiveValue<T>(initialValue?: T) {
|
||||||
|
const changeListeners = new Set<() => void>();
|
||||||
|
let value = initialValue;
|
||||||
|
|
||||||
|
return {
|
||||||
|
get value() {
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
set value(newValue) {
|
||||||
|
value = newValue;
|
||||||
|
changeListeners.forEach(cb => cb());
|
||||||
|
},
|
||||||
|
use() {
|
||||||
|
const update = useForceUpdater();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
changeListeners.add(update);
|
||||||
|
return () => void changeListeners.delete(update);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -83,5 +83,8 @@ export const enum IpcCommands {
|
|||||||
|
|
||||||
GET_LANGUAGES = "navigator.languages",
|
GET_LANGUAGES = "navigator.languages",
|
||||||
|
|
||||||
SCREEN_SHARE_PICKER = "screenshare:picker"
|
SCREEN_SHARE_PICKER = "screenshare:picker",
|
||||||
|
|
||||||
|
KEY_BINDS_HANDLE = "keyBinds:handle",
|
||||||
|
KEY_BINDS_SET_STATUS = "keyBinds:setStatus"
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+3
@@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Rectangle } from "electron";
|
import type { Rectangle } from "electron";
|
||||||
|
import { KeyBind } from "renderer/globalShortcuts/ShortcutSettings";
|
||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
discordBranch: "stable" | "canary" | "ptb";
|
discordBranch: "stable" | "canary" | "ptb";
|
||||||
@@ -54,6 +55,8 @@ export interface Settings {
|
|||||||
onlySpeakers?: boolean;
|
onlySpeakers?: boolean;
|
||||||
onlyDefaultSpeakers?: boolean;
|
onlyDefaultSpeakers?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
keyBinds?: KeyBind[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface State {
|
export interface State {
|
||||||
|
|||||||
Reference in New Issue
Block a user