Initial commit: mumh5, a modern Mumble client

Electron desktop app with a Svelte 5 interface for any Mumble server.

- Mumble protocol core: TLS, handshake, channels, users, text, plugin data,
  client-side pacing of Murmur's rate limits
- Voice: WebCodecs Opus over the TCP tunnel, voice activity, push to talk,
  always-on, devices, per-user volume and local mute
- Several servers at once, voice on one; server rail with icons and ordering
- Chat: channels, direct messages, side chat, file sharing through f0ckm,
  inline images without it, click-to-play YouTube
- Profiles with rich descriptions, registration, rename, nicknames,
  connection information and moderation menus
- Identity wizard, multiple identities, PKCS#12 import/export, desktop
  Mumble certificate import, certificate pinning and viewer
- Tray icon with voice state
- Unit, server and end-to-end tests against a real Murmur

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 23:28:43 +02:00
co-authored by Claude Opus 5.5
commit 6d29ee1485
85 changed files with 22997 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
dist-web/
dist-electron/
release/
*.log
+78
View File
@@ -0,0 +1,78 @@
# CLAUDE.md
## Style rules
- No rounded corners anywhere. No `border-radius` (a global `border-radius: 0 !important` backs this up in `src/app.css`).
- No emojis in code, UI text, comments, commit messages or docs. Emoji inside chat content from users is fine.
- UI copy is short, plain and honest. Don't claim something works if it is not tested. Explanations go in tooltips or help text, not in big callouts.
- Colors come from the CSS variables in `src/app.css`; icons from `src/lib/icons.ts` via `<Icon>`.
## Project overview
mumh5 is a Mumble client: Electron desktop app (Windows, macOS, Linux), Svelte 5 + TypeScript + Vite UI, Discord-like layout, responsive down to phone width. It must stay compatible with stock Murmur servers and with people on the desktop Mumble client. A browser build (through a self-hosted WebSocket proxy with a server allowlist) is planned.
File sharing uploads to a f0ckm instance (`../f0ckm`, `src/chat_upload_handler.mjs` there) with an upload-only key; links are posted in chat.
## Commands
```bash
npm run dev # Vite + Electron, restarts Electron when main/preload change
npm run check # svelte-check; must report 0 errors and 0 warnings
npm test # unit tests; server tests too when MUMBLE_TEST_HOST is set
npm run build # renderer to dist/, main+preload to dist-electron/ (esbuild)
npm run test:e2e # drives the built app (needs npm run build first)
npm run screenshots # regenerates docs/screenshots (see test/e2e/screenshots.ts)
npm run proto # regenerate src/core/mumble-pb.js and mumble-udp-pb.js from proto/
```
Test server (autoban off because tests reconnect a lot; SuperUser for channel setup):
```bash
docker run -d --name mumh5-test-murmur -p 64739:64738 -p 64739:64738/udp \
-e MUMBLE_CONFIG_AUTOBANATTEMPTS=0 -e MUMBLE_SUPERUSER_PASSWORD=testsuper mumblevoip/mumble-server
MUMBLE_TEST_HOST=localhost:64739 MUMBLE_SUPERUSER_PASSWORD=testsuper npm run test:e2e
```
A reused test server keeps registrations and channels from earlier runs; tests must use unique names or clean up (the E2E removes its channels first).
## Architecture
- `electron/` (Node, main process): window, TLS sockets to Mumble servers (`tls-transport.ts`), identities and PKCS#12 (`identity.ts`, `identity-store.ts`), certificate parsing (`certs.ts`), tray (`tray.ts`). The renderer only gets the narrow `window.mumh5Native` API from `preload.ts` (context isolation, sandbox).
- `src/core/` (browser-safe TypeScript, also runs in Node for tests): framing and codec (`proto.ts`), the Mumble client state machine (`client.ts`), voice packet formats (`voice-packet.ts`). No DOM, no Electron, no Node imports here.
- `src/lib/`: app state. `session.svelte.ts` has one `Session` per server plus the `sessions` manager; `session` is a Proxy to the active one. `audio/voice.svelte.ts` is the voice engine (WebCodecs Opus, capture and playback AudioWorklets). `html.ts` sanitizes incoming HTML and serializes outgoing rich text.
- `src/ui/`: Svelte components. `App.svelte` owns layout and global dialogs (`ui.svelte.ts` store).
Voice runs on one server at a time (where you last joined a channel); background servers are auto self-deafened and restored when voice returns.
## Conventions
- Svelte 5 runes only. Comment density: short comments that explain why, not what.
- The protobuf codec is generated static code (no eval) because the CSP forbids `unsafe-eval`. Field names keep snake_case from the .proto.
- TypeScript files imported by Node tests use `.ts` extensions and erasable syntax only (no parameter properties, no enums).
- New features get an E2E step in `test/e2e/app.e2e.ts`; protocol logic gets unit or server tests.
- Privacy rule: opening a chat must never contact hosts the user did not choose. Remote media only from the upload host and user-listed hosts; YouTube is click-to-play (`youtube-nocookie.com`).
## Svelte reactivity pitfalls (all hit before)
- `$state` wraps assigned objects in proxies: `this.server !== server` compares proxy to raw object and is always true. Use ids or counters.
- `(obj[key] ??= []).push(x)` pushes to the raw array; assign first, then push through `obj[key]`.
- The Mumble client mutates its objects in place. Session getters return shallow copies and read `tick`, otherwise views don't update.
## Mumble protocol pitfalls (all hit before)
- Messages or comments longer than the server's text limit (5000) are parsed by Murmur as XML; anything not well-formed is denied as TextTooLong. Always emit XHTML (`<br/>`, `<img .../>`, `toMumbleHtml`).
- Murmur silently drops own UserState, TextMessage, ChannelState, ACL and Version past a leaky bucket (burst 5, 1/s). The client paces these; don't bypass `send()`.
- 1.5 servers use the protobuf UDP voice format (type byte 0 + MumbleUDP.Audio) with clients announcing 1.5; older ones the legacy format. The UDPTunnel TCP body is the raw voice packet, not a protobuf message. Sequence numbers count 10 ms frames.
- Long comments and descriptions arrive as a hash only; a new hash invalidates the old text; fetch with RequestBlob.
- Renaming while connected is not possible; mumh5 reconnects with the new name.
## Running the app in this environment
- The shell has `ELECTRON_RUN_AS_NODE=1` set; strip it when launching Electron (the dev script and tests do).
- The desktop is Wayland and Electron picks it automatically, so windows appear on the user's screen even with `DISPLAY=:99`. For hidden runs: `Xvfb :99`, `--ozone-platform=x11`, and unset `WAYLAND_DISPLAY` and `XDG_SESSION_TYPE`.
- The user often has their own mumh5 (`npm run dev`) running. Only stop processes started with a `mumh5-e2e` profile; never kill Electron processes in general.
- `pkill -f pattern` matches the invoking shell; use `pkill -f "[p]attern"`.
## Commits
End commit messages with `Co-Authored-By: Claude <noreply@anthropic.com>` style attribution (the exact line comes from the session). No emojis in commit messages.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Kibi Kelburton
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+198
View File
@@ -0,0 +1,198 @@
# mumh5
**Mumble, the way it should feel in 2026.**
mumh5 is a modern client for [Mumble](https://www.mumble.info/), the free, open source, low-latency voice chat. It gives Mumble the kind of interface people know from Discord: servers on the left, channels and people in the middle, chat that can do more than text. Underneath, it's plain Mumble, so it connects to **any existing Mumble server** without changing anything on the server side.
Your community keeps its own server, its own rules and its own data. You get an app that's nice to use.
![mumh5 main window: channel tree, chat with a YouTube card, member list](docs/screenshots/main.png)
---
## Why mumh5
Mumble has the best foundation of any voice chat: tiny latency, strong encryption, self-hosting, no accounts, no ads, no tracking. What held it back was the desktop client, which still looks and behaves like 2010.
mumh5 keeps the foundation and replaces the experience.
- **Works with the servers you already use.** No plugins, no server changes, no migration. Friends on the regular Mumble client see you and hear you like anyone else.
- **Feels familiar.** A server rail, a channel tree, a real chat with message grouping, profiles, right-click menus everywhere. Anyone who has used Discord knows where things are.
- **Chat that is actually useful.** Drop in files, paste screenshots, share videos and YouTube links. Formatting works, links work, and everything stays compatible with the desktop client.
- **Several servers at once.** Stay in the text chat of every server you like, with unread badges, while voice runs where you actually are.
- **Private by default.** Opening a chat never contacts servers you didn't choose. No remote images or trackers, and YouTube stays click-to-play.
- **Runs everywhere.** Windows, macOS and Linux, with a layout that also works on narrow windows and small screens.
### mumh5 compared to the Mumble desktop client
| | Mumble desktop | mumh5 |
|---|---|---|
| Interface | Qt widgets, dense dialogs | Modern layout: server rail, channel tree, chat, side panels |
| Several servers at the same time | One connection | Many; voice on one, text on all, unread badges |
| Sharing files | Images only, pasted inline and heavily limited | Any allowed file through your own [f0ckm](#file-sharing-with-f0ckm) host, inline players for images, video and audio. Images still work without one |
| YouTube links | Plain links | Click-to-play player (privacy mode), optional thumbnails |
| Chat per channel | One shared log for everything | Separate chats per channel and person, plus a side chat for any channel next to your own |
| Descriptions | Rich text editor | Rich text editor with pictures, and a live size counter against the server limit |
| Identities | One certificate per installation | Several identities, a different one per server if you like; imports your desktop certificate |
| Server certificate trust | Remembered after a warning dialog | Pinned on first use, green header for CA-signed servers, full chain viewer |
| Fast actions in a row | The server silently drops them past its rate limit | Paced to the server's limit, nothing gets lost |
| Right-click on mute or deafen | Nothing | Quick device and volume options |
**Where the desktop client is still ahead, today:** UDP voice transport (mumh5 sends voice through the encrypted TCP connection for now, which adds a little delay on bad networks), system-wide push-to-talk, whisper and shout, positional audio, the in-game overlay, recording and the ACL editor. These are on the roadmap below.
---
## Features
### Voice
- Opus voice with voice activity detection, push to talk or always-on
- Live level meter with an adjustable threshold, so you set it once and forget it
- Noise suppression, echo cancellation and automatic gain control
- Microphone and output device selection, input and output volume
- Per-person volume (0 to 300%) and "mute for me"
- Speaking indicators in the channel tree, member list and your own panel
- Bitrate automatically limited to what the server allows
- Right-click the mute or deafen button for quick device and volume options
- Hear-yourself microphone test
![Voice settings](docs/screenshots/voice-settings.png)
### Channels and people
- Channel tree that starts collapsed; channels with people in them open up
- Single click previews a channel's description, double click joins
- Side chat: write in any channel without leaving yours
- Profiles with rich descriptions (formatting, lists, links, pictures), local nicknames, registration status
- Change your name (mumh5 reconnects for you) and register on a server with one click
- Connection information: ping, packet loss, client version and, for admins, the user's certificate chain
- Moderation from the right-click menu: move, server mute and deafen, priority speaker, kick, ban, register
![Profile with a formatted description](docs/screenshots/profile.png)
### Chat
- Messages grouped by author, direct messages, unread badges
- File sharing: drag and drop, paste, or the attach button, with upload progress
- Inline images, video and audio players for shared media
- YouTube links as click-to-play players
- Incoming HTML is sanitized; remote images from unknown hosts are never loaded
- Messages stay compatible with the desktop client, including picture previews
### Servers and identity
- Server rail with custom icons, drag-and-drop ordering, connection status and unread counts
- Several servers connected at once; voice follows the channel you join
- Identity setup wizard: create a new certificate, import a `.p12`, or reuse your desktop Mumble identity
- Password-protected backups of your identity
- Trust on first use for server certificates, a green header for CA-signed ones, and a certificate viewer
- Tray icon that shows whether you are talking, muted or deafened
<p align="center"><img src="docs/screenshots/mobile.png" alt="mumh5 in a narrow window" width="300"></p>
---
## Getting started
There are no release builds yet. For now, build it yourself; it takes a few minutes.
You need [Node.js](https://nodejs.org/) 22 or newer and git.
```bash
git clone gitea@git.lat:kibi/mumh5.git
cd mumh5
npm install
npm run dev
```
On first start, mumh5 asks you to set up your identity. If you already use Mumble on this computer, pick "Use my Mumble desktop identity" to keep your registrations. Then add a server with the plus button.
### Building installers
```bash
npm run dist:linux # AppImage and .deb
npm run dist:win # NSIS installer
npm run dist:mac # .dmg
```
Installers are written to `release/`. Build each platform on that platform for the best results.
---
## File sharing with f0ckm
Mumble can only carry small inline images. mumh5 can share any file by uploading it to a f0ckm instance (a self-hosted imageboard by the same author) and posting the link. Other mumh5 users get inline players; desktop Mumble users get a normal link plus a small preview.
f0ckm keeps these uploads separate from its imageboard, respects its own list of allowed file types, and deletes them after 30 days by default.
1. On your f0ckm server, create an upload-only key:
```bash
node scripts/chat-upload-key.mjs create mumh5
```
2. In mumh5, open **Settings, Chat and files**, enter the f0ckm address and the key, and press **Test key**.
Without an upload host, mumh5 still sends images, scaled to fit the server's limit, the same way the desktop client does.
---
## Privacy and security
- **Your identity is a certificate** that stays on your computer, stored with owner-only permissions. mumh5 reminds you to back it up.
- **Server certificates are pinned** the first time you connect. If one changes, mumh5 stops before sending your password and asks you.
- **Chats don't phone home.** Media is only loaded from your upload host and hosts you add yourself. YouTube is click-to-play in privacy mode.
- **Strict content security policy** in the app, and all message HTML is sanitized.
---
## Roadmap
- UDP voice with Mumble's OCB2-AES128 encryption, for the lowest latency
- System-wide push to talk
- Whisper and shout
- Rich chat between mumh5 users: replies, reactions, edits, typing indicators
- Video and screen sharing between mumh5 users
- A browser version, through a small self-hosted WebSocket proxy
- Release builds for all platforms
---
## Development
```bash
npm run dev # app with hot reload
npm run check # type check
npm test # unit tests
```
Tests against a real Mumble server, including an end-to-end test that drives the app:
```bash
docker run -d --name mumh5-test-murmur -p 64739:64738 -p 64739:64738/udp \
-e MUMBLE_CONFIG_AUTOBANATTEMPTS=0 -e MUMBLE_SUPERUSER_PASSWORD=testsuper mumblevoip/mumble-server
MUMBLE_TEST_HOST=localhost:64739 npm test
npm run build
MUMBLE_TEST_HOST=localhost:64739 MUMBLE_SUPERUSER_PASSWORD=testsuper npm run test:e2e
```
On Linux the end-to-end test forces X11, so it can run on a virtual display: `Xvfb :99 & DISPLAY=:99 npm run test:e2e`. See [CLAUDE.md](CLAUDE.md) for the architecture and project conventions.
```
electron/ main process: window, TLS sockets, identities, tray
src/core/ Mumble protocol: framing, codec, client state, voice packets
src/lib/ app state, voice engine, sanitizing, uploads
src/ui/ Svelte components
proto/ Mumble.proto and MumbleUDP.proto from upstream
test/ unit, server and end-to-end tests
```
---
## AI disclosure and credits
mumh5 is built by **Kibi** together with **Claude**, Anthropic's AI model, working in Claude Code.
Kibi sets the direction: what mumh5 should be, how it should look and feel, which trade-offs to make, and whether a result is good enough. Claude writes most of the code, the tests and this documentation, and checks each feature against a real Mumble server before calling it done. Commits written with Claude carry a `Co-Authored-By: Claude` line.
We say this openly because you should know how the software you run was made. Features are covered by automated tests, including end-to-end tests against a real Mumble server. If something is wrong, please open an issue.
## License
[MIT](LICENSE). The Mumble protocol definitions in `proto/`, and the code generated from them, are licensed by The Mumble Developers under a BSD license; see [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
mumh5 is not affiliated with the Mumble project. Mumble is a trademark of its respective owners.
+43
View File
@@ -0,0 +1,43 @@
# Third-party notices
## Mumble protocol definitions
`proto/Mumble.proto` and `proto/MumbleUDP.proto` are copied from the Mumble project
(https://github.com/mumble-voip/mumble). `src/core/mumble-pb.js` and `src/core/mumble-udp-pb.js`
are generated from them. They are covered by the following license:
```
Copyright (C) The Mumble Developers
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
- Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
- Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
- Neither the name of The Mumble Developers nor the names of its
contributors may be used to endorse or promote products derived from this
software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION OR
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
```
## npm dependencies
Runtime and build dependencies (Electron, Svelte, protobufjs, node-forge, DOMPurify and
others) are distributed under their own licenses, found in each package under `node_modules/`.
Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

+39
View File
@@ -0,0 +1,39 @@
import { X509Certificate } from 'node:crypto';
export interface CertDetails {
subject: string;
issuer: string;
validFrom: string;
validTo: string;
serialNumber: string;
fingerprint: string; // SHA-1, the hash Mumble uses for users
fingerprint256: string;
subjectAltName: string;
keyType: string;
keyBits: number | null;
ca: boolean;
selfSigned: boolean;
pem: string;
}
// Plain, IPC-safe summary of a certificate for the viewer
export function describeCert(input: string | Buffer | X509Certificate): CertDetails {
const c = input instanceof X509Certificate ? input : new X509Certificate(input);
const key = c.publicKey;
const d = key.asymmetricKeyDetails;
return {
subject: c.subject,
issuer: c.issuer,
validFrom: new Date(c.validFrom).toISOString(),
validTo: new Date(c.validTo).toISOString(),
serialNumber: c.serialNumber,
fingerprint: c.fingerprint,
fingerprint256: c.fingerprint256,
subjectAltName: c.subjectAltName ?? '',
keyType: key.asymmetricKeyType ?? 'unknown',
keyBits: d?.modulusLength ?? null,
ca: c.ca,
selfSigned: c.checkIssued(c),
pem: c.toString()
};
}
+161
View File
@@ -0,0 +1,161 @@
import { promises as fs } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import { generateIdentity, identityFromP12, identityToP12, certCommonName, type Identity } from './identity.ts';
import { describeCert, type CertDetails } from './certs.ts';
export interface StoredIdentity extends Identity {
id: string;
name: string;
createdAt: string;
}
interface StoreFile {
identities: StoredIdentity[];
defaultId: string | null;
setupDone: boolean;
}
export interface IdentitySummary {
id: string;
name: string;
fingerprint: string;
isDefault: boolean;
cert: CertDetails;
}
// Where the desktop Mumble client (1.5+) keeps its settings, including its certificate
export function mumbleSettingsPaths(): string[] {
const home = os.homedir();
return [
path.join(process.env.APPDATA ?? path.join(home, 'AppData', 'Roaming'), 'Mumble', 'Mumble', 'mumble_settings.json'),
path.join(home, 'Library', 'Application Support', 'Mumble', 'Mumble', 'mumble_settings.json'),
path.join(process.env.XDG_CONFIG_HOME ?? path.join(home, '.config'), 'Mumble', 'Mumble', 'mumble_settings.json')
];
}
// Client identities (certificates), stored with owner-only permissions in the app's data folder
export class IdentityStore {
private data: StoreFile | null = null;
private file: string;
private legacyFile: string;
constructor(dir: string) {
this.file = path.join(dir, 'identities.json');
this.legacyFile = path.join(dir, 'identity.json');
}
private async load(): Promise<StoreFile> {
if (this.data) return this.data;
try {
this.data = JSON.parse(await fs.readFile(this.file, 'utf8')) as StoreFile;
} catch {
this.data = { identities: [], defaultId: null, setupDone: false };
// Earlier versions generated one identity silently; keep it, but still run the setup wizard
try {
const legacy = JSON.parse(await fs.readFile(this.legacyFile, 'utf8')) as Identity;
const id = { ...legacy, id: randomUUID(), name: certCommonName(legacy.certPem) || 'My identity', createdAt: new Date().toISOString() };
this.data.identities.push(id);
this.data.defaultId = id.id;
await this.save();
} catch { /* no legacy identity */ }
}
return this.data;
}
private async save(): Promise<void> {
await fs.writeFile(this.file, JSON.stringify(this.data), { mode: 0o600 });
}
private summary(d: StoreFile, i: StoredIdentity): IdentitySummary {
return { id: i.id, name: i.name, fingerprint: i.fingerprint, isDefault: i.id === d.defaultId, cert: describeCert(i.certPem) };
}
async list(): Promise<{ identities: IdentitySummary[]; setupDone: boolean }> {
const d = await this.load();
return { identities: d.identities.map(i => this.summary(d, i)), setupDone: d.setupDone };
}
// The identity to connect with: the requested one, else the default, else a new one
async get(id?: string | null): Promise<StoredIdentity> {
const d = await this.load();
const found = d.identities.find(i => i.id === id) ?? d.identities.find(i => i.id === d.defaultId) ?? d.identities[0];
if (found) return found;
const created = await this.add(generateIdentity(os.userInfo().username), os.userInfo().username);
return d.identities.find(i => i.id === created.id)!;
}
private async add(identity: Identity, name: string): Promise<IdentitySummary> {
const d = await this.load();
const existing = d.identities.find(i => i.fingerprint === identity.fingerprint);
if (existing) throw new Error(`This certificate is already stored as "${existing.name}".`);
const stored: StoredIdentity = { ...identity, id: randomUUID(), name: name.trim() || 'Identity', createdAt: new Date().toISOString() };
d.identities.push(stored);
d.defaultId ??= stored.id;
await this.save();
return this.summary(d, stored);
}
create(name: string, email: string): Promise<IdentitySummary> {
return this.add(generateIdentity(name, email), name);
}
importP12(bytes: Uint8Array, password: string, name: string): Promise<IdentitySummary> {
const identity = identityFromP12(bytes, password);
return this.add(identity, name || certCommonName(identity.certPem));
}
// Finds the desktop client's certificate; returns its name if one exists
async findMumbleCertificate(): Promise<{ path: string; name: string } | null> {
for (const p of mumbleSettingsPaths()) {
try {
const settings = JSON.parse(await fs.readFile(p, 'utf8'));
if (typeof settings.certificate !== 'string' || !settings.certificate) continue;
const identity = identityFromP12(new Uint8Array(Buffer.from(settings.certificate, 'base64')), '');
return { path: p, name: certCommonName(identity.certPem) || 'Mumble certificate' };
} catch { /* not there or unreadable */ }
}
return null;
}
async importFromMumble(name: string): Promise<IdentitySummary> {
const found = await this.findMumbleCertificate();
if (!found) throw new Error('No certificate from the desktop Mumble client was found.');
const settings = JSON.parse(await fs.readFile(found.path, 'utf8'));
return this.importP12(new Uint8Array(Buffer.from(settings.certificate, 'base64')), '', name || found.name);
}
async exportP12(id: string, password: string): Promise<{ bytes: Uint8Array; name: string }> {
const d = await this.load();
const i = d.identities.find(x => x.id === id);
if (!i) throw new Error('Identity not found.');
return { bytes: identityToP12(i, password, i.name), name: i.name };
}
async setDefault(id: string): Promise<void> {
const d = await this.load();
if (d.identities.some(i => i.id === id)) d.defaultId = id;
await this.save();
}
async rename(id: string, name: string): Promise<void> {
const d = await this.load();
const i = d.identities.find(x => x.id === id);
if (i && name.trim()) i.name = name.trim();
await this.save();
}
async remove(id: string): Promise<void> {
const d = await this.load();
d.identities = d.identities.filter(i => i.id !== id);
if (d.defaultId === id) d.defaultId = d.identities[0]?.id ?? null;
await this.save();
}
async finishSetup(): Promise<void> {
const d = await this.load();
d.setupDone = true;
await this.save();
}
}
+73
View File
@@ -0,0 +1,73 @@
import forge from 'node-forge';
import { createHash } from 'node:crypto';
export interface Identity {
certPem: string;
keyPem: string;
fingerprint: string;
}
// Self-signed client certificate, the same kind the desktop Mumble client generates.
// Mumble servers identify registered users by the hash of this certificate.
export function generateIdentity(name: string, email = ''): Identity {
const keys = forge.pki.rsa.generateKeyPair({ bits: 2048 });
const cert = forge.pki.createCertificate();
cert.publicKey = keys.publicKey;
cert.serialNumber = '01' + forge.util.bytesToHex(forge.random.getBytesSync(8));
cert.validity.notBefore = new Date();
cert.validity.notAfter = new Date();
cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 20);
const attrs: { name: string; value: string }[] = [{ name: 'commonName', value: name || 'mumh5 user' }];
if (email) attrs.push({ name: 'emailAddress', value: email });
cert.setSubject(attrs);
cert.setIssuer(attrs);
cert.setExtensions([
{ name: 'basicConstraints', cA: false },
{ name: 'keyUsage', digitalSignature: true, keyEncipherment: true },
{ name: 'extKeyUsage', clientAuth: true }
]);
cert.sign(keys.privateKey, forge.md.sha256.create());
const certPem = forge.pki.certificateToPem(cert);
return { certPem, keyPem: forge.pki.privateKeyToPem(keys.privateKey), fingerprint: certFingerprint(certPem) };
}
// SHA-1 of the DER certificate, the hash Mumble shows and stores for users.
export function certFingerprint(certPem: string): string {
const der = forge.asn1.toDer(forge.pki.certificateToAsn1(forge.pki.certificateFromPem(certPem))).getBytes();
return createHash('sha1').update(Buffer.from(der, 'binary')).digest('hex');
}
// Read a PKCS#12 file (.p12/.pfx, as exported by the desktop Mumble client).
export function identityFromP12(bytes: Uint8Array, password: string): Identity {
let p12: forge.pkcs12.Pkcs12Pfx;
try {
const der = forge.util.binary.raw.encode(bytes);
p12 = forge.pkcs12.pkcs12FromAsn1(forge.asn1.fromDer(der), password);
} catch {
throw new Error('Could not open the file. Wrong password, or not a PKCS#12 certificate.');
}
const bags = (type: string) => p12.getBags({ bagType: type })[type] ?? [];
const cert = bags(forge.pki.oids.certBag).find(b => b.cert)?.cert;
const key = [...bags(forge.pki.oids.pkcs8ShroudedKeyBag), ...bags(forge.pki.oids.keyBag)].find(b => b.key)?.key;
if (!cert || !key) throw new Error('The file does not contain both a certificate and its private key.');
const certPem = forge.pki.certificateToPem(cert);
return { certPem, keyPem: forge.pki.privateKeyToPem(key), fingerprint: certFingerprint(certPem) };
}
// PKCS#12 with 3DES, which the desktop Mumble client and other tools can import.
export function identityToP12(id: Identity, password: string, friendlyName: string): Uint8Array {
const asn1 = forge.pkcs12.toPkcs12Asn1(
forge.pki.privateKeyFromPem(id.keyPem),
[forge.pki.certificateFromPem(id.certPem)],
password,
{ algorithm: '3des', friendlyName }
);
return new Uint8Array(Buffer.from(forge.asn1.toDer(asn1).getBytes(), 'binary'));
}
// Common name of a certificate, used as the default identity name
export function certCommonName(certPem: string): string {
const cn = forge.pki.certificateFromPem(certPem).subject.getField('CN');
return cn ? String(cn.value) : '';
}
+152
View File
@@ -0,0 +1,152 @@
import { app, BrowserWindow, dialog, ipcMain, shell, session, systemPreferences, type WebContents } from 'electron';
import { promises as fs } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { IdentityStore } from './identity-store.ts';
import { describeCert } from './certs.ts';
import * as tray from './tray.ts';
import { openTls } from './tls-transport.ts';
const devUrl = process.env.VITE_DEV_SERVER_URL;
let identityStore: IdentityStore | null = null;
const identities = () => (identityStore ??= new IdentityStore(app.getPath('userData')));
// ─── Mumble TLS connections, one per renderer request ─────────────────────────
type Conn = ReturnType<typeof openTls> & { owner: WebContents };
const conns = new Map<string, Conn>();
// ─── Identities (client certificates) ─────────────────────────────────────────
ipcMain.handle('identities:list', () => identities().list());
ipcMain.handle('identities:create', (_e, name: string, email: string) => identities().create(String(name), String(email ?? '')));
ipcMain.handle('identities:import', (_e, bytes: Uint8Array, password: string, name: string) =>
identities().importP12(new Uint8Array(bytes), String(password ?? ''), String(name ?? '')));
ipcMain.handle('identities:findMumble', () => identities().findMumbleCertificate().then(f => f && { name: f.name }));
ipcMain.handle('identities:importMumble', (_e, name: string) => identities().importFromMumble(String(name ?? '')));
ipcMain.handle('identities:setDefault', (_e, id: string) => identities().setDefault(String(id)));
ipcMain.handle('identities:rename', (_e, id: string, name: string) => identities().rename(String(id), String(name)));
ipcMain.handle('identities:remove', (_e, id: string) => identities().remove(String(id)));
ipcMain.handle('identities:finishSetup', () => identities().finishSetup());
// Saves a password-protected .p12 backup where the user chooses; returns the path or null
ipcMain.handle('identities:export', async (e, id: string, password: string) => {
const { bytes, name } = await identities().exportP12(String(id), String(password ?? ''));
const win = BrowserWindow.fromWebContents(e.sender);
const opts = {
title: 'Save identity backup',
defaultPath: path.join(app.getPath('documents'), `${name.replace(/[^\w.-]+/g, '_') || 'identity'}.p12`),
filters: [{ name: 'PKCS#12 certificate', extensions: ['p12', 'pfx'] }]
};
const res = win ? await dialog.showSaveDialog(win, opts) : await dialog.showSaveDialog(opts);
if (res.canceled || !res.filePath) return null;
await fs.writeFile(res.filePath, bytes, { mode: 0o600 });
return res.filePath;
});
// The renderer picks the connection id and subscribes before calling open, so no event can be missed
ipcMain.handle('mumble:open', async (e, connId: string, host: string, port: number, identityId?: string) => {
if (typeof connId !== 'string' || conns.has(connId)) throw new Error('Invalid connection id');
const id = await identities().get(identityId);
const owner = e.sender;
const emit = (channel: string, ...args: unknown[]) => { if (!owner.isDestroyed()) owner.send(channel, connId, ...args); };
const conn = openTls(String(host), Number(port) || 64738, id.certPem, id.keyPem, {
onSecure: info => emit('mumble:secure', info),
onData: chunk => emit('mumble:data', chunk),
onClose: reason => { conns.delete(connId); emit('mumble:close', reason); }
});
conns.set(connId, Object.assign(conn, { owner }));
owner.once('destroyed', () => conn.close());
});
ipcMain.on('mumble:send', (e, connId: string, bytes: Uint8Array) => {
const conn = conns.get(connId);
if (conn && conn.owner === e.sender) conn.send(bytes);
});
ipcMain.on('mumble:close', (e, connId: string) => {
const conn = conns.get(connId);
if (conn && conn.owner === e.sender) conn.close();
});
// Parses DER certificates (e.g. a user's chain from UserStats) for the viewer
ipcMain.handle('certs:describe', (_e, ders: Uint8Array[]) =>
(Array.isArray(ders) ? ders.slice(0, 8) : []).flatMap(d => { try { return [describeCert(Buffer.from(d))]; } catch { return []; } }));
ipcMain.on('tray:update', (_e, state: tray.TrayState) => tray.update(state));
ipcMain.handle('platform:info', () => ({ os: process.platform, osVersion: os.release() }));
// ─── Window ────────────────────────────────────────────────────────────────────
let mainWindow: BrowserWindow | null = null;
function createWindow() {
const win = new BrowserWindow({
width: 1280,
height: 800,
minWidth: 360,
minHeight: 480,
backgroundColor: '#111214',
title: 'mumh5',
autoHideMenuBar: true,
webPreferences: {
preload: path.join(__dirname, 'preload.cjs'),
contextIsolation: true,
sandbox: true,
nodeIntegration: false,
// Voice must play without a click first
autoplayPolicy: 'no-user-gesture-required',
// Keep audio and timers running at full speed when the window is in the background
backgroundThrottling: false
}
});
// Links from chat open in the system browser, never inside the app
win.webContents.setWindowOpenHandler(({ url }) => {
if (/^https?:\/\//i.test(url)) shell.openExternal(url);
return { action: 'deny' };
});
win.webContents.on('will-navigate', (e, url) => {
if (devUrl && url.startsWith(devUrl)) return;
e.preventDefault();
if (/^https?:\/\//i.test(url)) shell.openExternal(url);
});
mainWindow = win;
win.on('close', e => {
if (tray.shouldHideOnClose()) {
e.preventDefault();
win.hide();
}
});
win.on('show', () => tray.onWindowVisibility());
win.on('hide', () => tray.onWindowVisibility());
win.on('closed', () => { if (mainWindow === win) mainWindow = null; });
if (devUrl) win.loadURL(devUrl);
else win.loadFile(path.join(__dirname, '../dist/index.html'));
}
app.whenReady().then(async () => {
// macOS asks once per app for microphone access
if (process.platform === 'darwin') await systemPreferences.askForMediaAccess('microphone').catch(() => false);
// YouTube refuses embeds without a referrer (error 153); a file:// app sends none
session.defaultSession.webRequest.onBeforeSendHeaders({ urls: ['https://www.youtube-nocookie.com/*'] }, (details, cb) => {
if (!details.requestHeaders.Referer) details.requestHeaders.Referer = 'https://mumh5.app/';
cb({ requestHeaders: details.requestHeaders });
});
// Microphone, camera and screen capture for voice and video; nothing else
session.defaultSession.setPermissionRequestHandler((_wc, permission, cb) => {
cb(['media', 'display-capture', 'clipboard-sanitized-write', 'notifications'].includes(permission));
});
createWindow();
await tray.setupTray(() => mainWindow, action => mainWindow?.webContents.send('tray:action', action));
app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); });
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
+36
View File
@@ -0,0 +1,36 @@
import { contextBridge, ipcRenderer } from 'electron';
type Listener = (connId: string, ...args: any[]) => void;
const listeners = { secure: new Set<Listener>(), data: new Set<Listener>(), close: new Set<Listener>() };
for (const key of Object.keys(listeners) as (keyof typeof listeners)[]) {
ipcRenderer.on(`mumble:${key}`, (_e, connId: string, ...args: any[]) => {
for (const fn of listeners[key]) fn(connId, ...args);
});
}
// The only native surface the renderer gets
contextBridge.exposeInMainWorld('mumh5Native', {
platformInfo: () => ipcRenderer.invoke('platform:info'),
updateTray: (state: unknown) => ipcRenderer.send('tray:update', state),
onTrayAction: (fn: (action: string) => void) => { ipcRenderer.on('tray:action', (_e, a: string) => fn(a)); },
describeCerts: (ders: Uint8Array[]) => ipcRenderer.invoke('certs:describe', ders),
identities: {
list: () => ipcRenderer.invoke('identities:list'),
create: (name: string, email: string) => ipcRenderer.invoke('identities:create', name, email),
importP12: (bytes: Uint8Array, password: string, name: string) => ipcRenderer.invoke('identities:import', bytes, password, name),
findMumble: () => ipcRenderer.invoke('identities:findMumble'),
importMumble: (name: string) => ipcRenderer.invoke('identities:importMumble', name),
exportP12: (id: string, password: string) => ipcRenderer.invoke('identities:export', id, password),
setDefault: (id: string) => ipcRenderer.invoke('identities:setDefault', id),
rename: (id: string, name: string) => ipcRenderer.invoke('identities:rename', id, name),
remove: (id: string) => ipcRenderer.invoke('identities:remove', id),
finishSetup: () => ipcRenderer.invoke('identities:finishSetup')
},
open: (connId: string, host: string, port: number, identityId?: string) => ipcRenderer.invoke('mumble:open', connId, host, port, identityId),
send: (connId: string, bytes: Uint8Array) => ipcRenderer.send('mumble:send', connId, bytes),
close: (connId: string) => ipcRenderer.send('mumble:close', connId),
on: (event: keyof typeof listeners, fn: Listener) => {
listeners[event].add(fn);
return () => listeners[event].delete(fn);
}
});
+50
View File
@@ -0,0 +1,50 @@
import tls from 'node:tls';
import { describeCert, type CertDetails } from './certs.ts';
export interface TlsHandlers {
onSecure(info: { fingerprint: string; authorized: boolean; authError: string | null; chain: CertDetails[] }): void;
onData(chunk: Uint8Array): void;
onClose(reason: string): void;
}
// Opens a TLS connection to a Mumble server. Most servers use self-signed certificates,
// so verification is left to the caller (trust on first use via the sha256 fingerprint).
export function openTls(host: string, port: number, cert: string, key: string, h: TlsHandlers) {
let closeReason = 'Connection closed';
const socket = tls.connect({
host, port, cert, key,
rejectUnauthorized: false,
servername: /^[\d.:]+$/.test(host) ? undefined : host
});
socket.setNoDelay(true);
socket.setKeepAlive(true, 30000);
socket.setTimeout(20000, () => {
closeReason = 'Connection timed out';
socket.destroy();
});
socket.on('secureConnect', () => {
socket.setTimeout(0);
// Leaf first, then the issuers the server sent (the root links to itself)
const chain: CertDetails[] = [];
let cur = socket.getPeerCertificate(true) as tls.DetailedPeerCertificate | undefined;
const seen = new Set<string>();
while (cur?.raw && !seen.has(cur.fingerprint256) && chain.length < 8) {
seen.add(cur.fingerprint256);
chain.push(describeCert(cur.raw));
cur = cur.issuerCertificate;
}
h.onSecure({
chain,
fingerprint: chain[0]?.fingerprint256 ?? '',
authorized: socket.authorized,
authError: socket.authorizationError ? String(socket.authorizationError) : null
});
});
socket.on('data', (chunk: Buffer) => h.onData(new Uint8Array(chunk)));
socket.on('error', (e: Error) => { closeReason = e.message; });
socket.on('close', () => h.onClose(closeReason));
return {
send(bytes: Uint8Array) { if (!socket.destroyed) socket.write(bytes); },
close() { closeReason = 'Disconnected'; socket.destroy(); }
};
}
+97
View File
@@ -0,0 +1,97 @@
import { app, BrowserWindow, Menu, Tray, nativeImage } from 'electron';
import { promises as fs } from 'node:fs';
import path from 'node:path';
// State the renderer reports; the tray mirrors it
export interface TrayState {
icon: string; // PNG data URL drawn by the renderer
tooltip: string;
connected: boolean;
server: string;
muted: boolean;
deafened: boolean;
mode: 'vad' | 'ptt' | 'continuous';
}
let tray: Tray | null = null;
let state: TrayState | null = null;
let closeToTray = true;
let quitting = false;
const prefsFile = () => path.join(app.getPath('userData'), 'tray.json');
export async function setupTray(getWindow: () => BrowserWindow | null, send: (action: string) => void): Promise<void> {
try { closeToTray = JSON.parse(await fs.readFile(prefsFile(), 'utf8')).closeToTray !== false; } catch { /* defaults */ }
const show = () => {
const win = getWindow();
if (!win) return;
if (win.isMinimized()) win.restore();
win.show();
win.focus();
};
const rebuild = () => {
if (!tray || !state) return;
const win = getWindow();
const s = state;
const menu = Menu.buildFromTemplate([
{ label: win?.isVisible() ? 'Hide mumh5' : 'Show mumh5', click: () => (win?.isVisible() ? win.hide() : show()) },
{ type: 'separator' },
{ label: s.connected ? `Connected: ${s.server}` : 'Not connected', enabled: false },
{ label: 'Mute', type: 'checkbox', checked: s.muted, enabled: s.connected, click: () => send('toggle-mute') },
{ label: 'Deafen', type: 'checkbox', checked: s.deafened, enabled: s.connected, click: () => send('toggle-deafen') },
{
label: 'Transmit', submenu: [
{ label: 'Voice activity', type: 'radio', checked: s.mode === 'vad', click: () => send('mode:vad') },
{ label: 'Push to talk', type: 'radio', checked: s.mode === 'ptt', click: () => send('mode:ptt') },
{ label: 'Always on', type: 'radio', checked: s.mode === 'continuous', click: () => send('mode:continuous') }
]
},
{ label: 'Voice settings...', click: () => { show(); send('settings'); } },
{ label: 'Disconnect', enabled: s.connected, click: () => send('disconnect') },
{ type: 'separator' },
{
label: 'Close to tray', type: 'checkbox', checked: closeToTray, click: item => {
closeToTray = item.checked;
fs.writeFile(prefsFile(), JSON.stringify({ closeToTray })).catch(() => {});
}
},
{ label: 'Quit mumh5', click: () => { quitting = true; app.quit(); } }
]);
tray.setContextMenu(menu);
// Inspection hook for the end-to-end tests (tray menus cannot be clicked by automation)
(globalThis as any).__mumh5Tray = {
state: { ...s, icon: s.icon.slice(0, 22) },
tooltip: s.tooltip,
items: menu.items.map(i => ({ label: i.label, checked: i.checked, enabled: i.enabled })),
click: (label: string) => { const item = menu.items.find(i => i.label === label); item?.click(undefined, getWindow() ?? undefined, getWindow()?.webContents); }
};
};
app.on('before-quit', () => { quitting = true; });
// Called for every state change from the renderer
update = (next: TrayState) => {
state = next;
const image = nativeImage.createFromDataURL(next.icon);
if (!tray) {
tray = new Tray(image);
tray.on('click', show);
} else {
tray.setImage(image);
}
tray.setToolTip(next.tooltip);
rebuild();
};
// Keep the Show/Hide label right
onWindowVisibility = rebuild;
}
export let update: (s: TrayState) => void = () => {};
export let onWindowVisibility: () => void = () => {};
// Closing the window hides it while the tray is there, unless the user quits
export function shouldHideOnClose(): boolean {
return !!tray && closeToTray && !quitting && process.platform !== 'darwin';
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; media-src 'self' blob: https: http:; connect-src 'self' https: http: ws: wss:; object-src 'none'; base-uri 'none'; frame-src https://www.youtube-nocookie.com" />
<title>mumh5</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+5823
View File
File diff suppressed because it is too large Load Diff
+89
View File
@@ -0,0 +1,89 @@
{
"name": "mumh5",
"productName": "mumh5",
"version": "0.1.0",
"description": "Modern Mumble client with rich chat, file sharing and video",
"license": "MIT",
"type": "module",
"main": "dist-electron/main.cjs",
"scripts": {
"dev": "node scripts/dev.mjs",
"build": "vite build && node scripts/build-electron.mjs",
"build:web": "vite build --mode web --outDir dist-web",
"check": "svelte-check --tsconfig ./tsconfig.json",
"test": "node --test test/*.test.ts",
"dist": "npm run build && electron-builder",
"dist:linux": "npm run build && electron-builder --linux",
"dist:win": "npm run build && electron-builder --win",
"dist:mac": "npm run build && electron-builder --mac",
"test:e2e": "node test/e2e/app.e2e.ts",
"proto": "pbjs -t static-module -w es6 --keep-case --no-delimited --no-service --no-comments --force-number proto/Mumble.proto -o src/core/mumble-pb.js && pbjs -t static-module -w es6 --keep-case --no-delimited --no-service --no-comments --force-number proto/MumbleUDP.proto -o src/core/mumble-udp-pb.js",
"screenshots": "node test/e2e/screenshots.ts"
},
"build": {
"appId": "dev.f0ck.mumh5",
"productName": "mumh5",
"files": [
"dist/**",
"dist-electron/**",
"package.json",
"LICENSE",
"THIRD_PARTY_NOTICES.md"
],
"directories": {
"output": "release"
},
"linux": {
"target": [
"AppImage",
"deb"
],
"category": "Network"
},
"win": {
"target": [
"nsis"
]
},
"mac": {
"target": [
"dmg"
],
"category": "public.app-category.social-networking",
"extendInfo": {
"NSMicrophoneUsageDescription": "mumh5 uses the microphone for voice chat.",
"NSCameraUsageDescription": "mumh5 uses the camera for video chat."
}
}
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.3.1",
"@tsconfig/svelte": "^5.0.8",
"@types/node": "^26.6.3",
"@types/node-forge": "^1.3.14",
"electron": "^44.5.1",
"electron-builder": "^26.15.3",
"esbuild": "^0.28.2",
"playwright-core": "^1.63.0",
"protobufjs-cli": "^2.7.0",
"svelte": "^5.57.1",
"svelte-check": "^4.7.6",
"typescript": "^6.0.3",
"vite": "^8.3.1"
},
"dependencies": {
"dompurify": "^3.4.16",
"node-forge": "^1.4.0",
"protobufjs": "^8.8.0"
},
"allowScripts": {
"electron@44.5.1": true,
"esbuild@0.28.2": true,
"electron-winstaller": false
},
"author": "Kibi Kelburton",
"repository": {
"type": "git",
"url": "gitea@git.lat:kibi/mumh5.git"
}
}
+639
View File
@@ -0,0 +1,639 @@
// Copyright The Mumble Developers. All rights reserved.
// Use of this source code is governed by a BSD-style license
// that can be found in the LICENSE file at the root of the
// Mumble source tree or at <https://www.mumble.info/LICENSE>.
syntax = "proto2";
package MumbleProto;
option optimize_for = SPEED;
message Version {
// Legacy version number format.
optional uint32 version_v1 = 1;
// New version number format.
// Necessary since patch level may exceed 255. (See https://github.com/mumble-voip/mumble/issues/5827)
optional uint64 version_v2 = 5;
// Client release name.
optional string release = 2;
// Client OS name.
optional string os = 3;
// Client OS version.
optional string os_version = 4;
}
// Not used. Not even for tunneling UDP through TCP.
message UDPTunnel {
// Not used.
required bytes packet = 1;
}
// Used by the client to send the authentication credentials to the server.
message Authenticate {
// UTF-8 encoded username.
optional string username = 1;
// Server or user password.
optional string password = 2;
// Additional access tokens for server ACL groups.
repeated string tokens = 3;
// A list of CELT bitstream version constants supported by the client.
repeated int32 celt_versions = 4;
optional bool opus = 5 [default = false];
// 0 = REGULAR, 1 = BOT
optional int32 client_type = 6 [default = 0];
}
// Sent by the client to notify the server that the client is still alive.
// Server must reply to the packet with the same timestamp and its own
// good/late/lost/resync numbers. None of the fields is strictly required.
message Ping {
// Client timestamp. Server should not attempt to decode.
optional uint64 timestamp = 1;
// The amount of good packets received.
optional uint32 good = 2;
// The amount of late packets received.
optional uint32 late = 3;
// The amount of packets never received.
optional uint32 lost = 4;
// The amount of nonce resyncs.
optional uint32 resync = 5;
// The total amount of UDP packets received.
optional uint32 udp_packets = 6;
// The total amount of TCP packets received.
optional uint32 tcp_packets = 7;
// UDP ping average.
optional float udp_ping_avg = 8;
// UDP ping variance.
optional float udp_ping_var = 9;
// TCP ping average.
optional float tcp_ping_avg = 10;
// TCP ping variance.
optional float tcp_ping_var = 11;
}
// Sent by the server when it rejects the user connection.
message Reject {
enum RejectType {
// The rejection reason is unknown (details should be available
// in Reject.reason).
None = 0;
// The client attempted to connect with an incompatible version.
WrongVersion = 1;
// The user name supplied by the client was invalid.
InvalidUsername = 2;
// The client attempted to authenticate as a user with a password but it
// was wrong.
WrongUserPW = 3;
// The client attempted to connect to a passworded server but the password
// was wrong.
WrongServerPW = 4;
// Supplied username is already in use.
UsernameInUse = 5;
// Server is currently full and cannot accept more users.
ServerFull = 6;
// The user did not provide a certificate but one is required.
NoCertificate = 7;
AuthenticatorFail = 8;
// The server is currently not accepting new connections
NoNewConnections = 9;
}
// Rejection type.
optional RejectType type = 1;
// Human readable rejection reason.
optional string reason = 2;
}
// ServerSync message is sent by the server when it has authenticated the user
// and finished synchronizing the server state.
message ServerSync {
// The session of the current user.
optional uint32 session = 1;
// Maximum bandwidth that the user should use.
optional uint32 max_bandwidth = 2;
// Server welcome text.
optional string welcome_text = 3;
// Current user permissions in the root channel.
// Note: The permissions data type usually is uin32 (e.g. in PermissionQuery and PermissionDenied messages). Here
// it is uint64 because of an oversight in the past. Nonetheless it should never exceed the uin32 range.
// See also: https://github.com/mumble-voip/mumble/issues/5139
optional uint64 permissions = 4;
}
// Sent by the client when it wants a channel removed. Sent by the server when
// a channel has been removed and clients should be notified.
message ChannelRemove {
required uint32 channel_id = 1;
}
// Used to communicate channel properties between the client and the server.
// Sent by the server during the login process or when channel properties are
// updated. Client may use this message to update said channel properties.
message ChannelState {
// Unique ID for the channel within the server.
optional uint32 channel_id = 1;
// channel_id of the parent channel.
optional uint32 parent = 2;
// UTF-8 encoded channel name.
optional string name = 3;
// A collection of channel id values of the linked channels. Absent during
// the first channel listing.
repeated uint32 links = 4;
// UTF-8 encoded channel description. Only if the description is less than
// 128 bytes
optional string description = 5;
// A collection of channel_id values that should be added to links.
repeated uint32 links_add = 6;
// A collection of channel_id values that should be removed from links.
repeated uint32 links_remove = 7;
// True if the channel is temporary.
optional bool temporary = 8 [default = false];
// Position weight to tweak the channel position in the channel list.
optional int32 position = 9 [default = 0];
// SHA1 hash of the description if the description is 128 bytes or more.
optional bytes description_hash = 10;
// Maximum number of users allowed in the channel. If this value is zero,
// the maximum number of users allowed in the channel is given by the
// server's "usersperchannel" setting.
optional uint32 max_users = 11;
// Whether this channel has enter restrictions (ACL denying ENTER) set
optional bool is_enter_restricted = 12;
// Whether the receiver of this msg is considered to be able to enter this channel
optional bool can_enter = 13;
}
// Used to communicate user leaving or being kicked. May be sent by the client
// when it attempts to kick a user. Sent by the server when it informs the
// clients that a user is not present anymore.
message UserRemove {
// The user who is being kicked, identified by their session, not present
// when no one is being kicked.
required uint32 session = 1;
// The user who initiated the removal. Either the user who performs the kick
// or the user who is currently leaving.
optional uint32 actor = 2;
// Reason for the kick, stored as the ban reason if the user is banned.
optional string reason = 3;
// True if the user shall be banned, false if the user shall be kicked
optional bool ban = 4;
// True if the user shall be banned by certificate.
optional bool ban_certificate = 5;
// True if the user shall be banned by IP.
optional bool ban_ip = 6;
}
// Sent by the server when it communicates new and changed users to client.
// First seen during login procedure. May be sent by the client when it wishes
// to alter its state.
message UserState {
message VolumeAdjustment {
optional uint32 listening_channel = 1;
optional float volume_adjustment = 2;
}
// Unique user session ID of the user whose state this is, may change on
// reconnect.
optional uint32 session = 1;
// The session of the user who is updating this user.
optional uint32 actor = 2;
// User name, UTF-8 encoded.
optional string name = 3;
// Registered user ID if the user is registered.
optional uint32 user_id = 4;
// Channel on which the user is.
optional uint32 channel_id = 5;
// True if the user is muted by admin.
optional bool mute = 6;
// True if the user is deafened by admin.
optional bool deaf = 7;
// True if the user has been suppressed from talking by a reason other than
// being muted.
optional bool suppress = 8;
// True if the user has muted self.
optional bool self_mute = 9;
// True if the user has deafened self.
optional bool self_deaf = 10;
// User image if it is less than 128 bytes.
optional bytes texture = 11;
// The positional audio plugin identifier.
// Positional audio information is only sent to users who share
// identical plugin contexts.
//
// This value is not transmitted to clients.
optional bytes plugin_context = 12;
// The user's plugin-specific identity.
// This value is not transmitted to clients.
optional string plugin_identity = 13;
// User comment if it is less than 128 bytes.
optional string comment = 14;
// The hash of the user certificate.
optional string hash = 15;
// SHA1 hash of the user comment if it 128 bytes or more.
optional bytes comment_hash = 16;
// SHA1 hash of the user picture if it 128 bytes or more.
optional bytes texture_hash = 17;
// True if the user is a priority speaker.
optional bool priority_speaker = 18;
// True if the user is currently recording.
optional bool recording = 19;
// A list of temporary access tokens to be respected when processing this request.
repeated string temporary_access_tokens = 20;
// A list of channels the user wants to start listening to.
repeated uint32 listening_channel_add = 21;
// a list of channels the user does no longer want to listen to.
repeated uint32 listening_channel_remove = 22;
// A list of volume adjustments the user has applied to listeners
repeated VolumeAdjustment listening_volume_adjustment = 23;
}
// Relays information on the bans. The client may send the BanList message to
// either modify the list of bans or query them from the server. The server
// sends this list only after a client queries for it.
message BanList {
message BanEntry {
// Banned IP address.
required bytes address = 1;
// The length of the subnet mask for the ban.
required uint32 mask = 2;
// User name for identification purposes (does not affect the ban).
optional string name = 3;
// The certificate hash of the banned user.
optional string hash = 4;
// Reason for the ban (does not affect the ban).
optional string reason = 5;
// Ban start time.
optional string start = 6;
// Ban duration in seconds.
optional uint32 duration = 7;
}
// List of ban entries currently in place.
repeated BanEntry bans = 1;
// True if the server should return the list, false if it should replace old
// ban list with the one provided.
optional bool query = 2 [default = false];
}
// Used to send and broadcast text messages.
message TextMessage {
// The message sender, identified by its session.
optional uint32 actor = 1;
// Target users for the message, identified by their session.
repeated uint32 session = 2;
// The channels to which the message is sent, identified by their
// channel_ids.
repeated uint32 channel_id = 3;
// The root channels when sending message recursively to several channels,
// identified by their channel_ids.
repeated uint32 tree_id = 4;
// The UTF-8 encoded message. May be HTML if the server allows.
required string message = 5;
}
message PermissionDenied {
enum DenyType {
// Operation denied for other reason, see reason field.
Text = 0;
// Permissions were denied.
Permission = 1;
// Cannot modify SuperUser.
SuperUser = 2;
// Invalid channel name.
ChannelName = 3;
// Text message too long.
TextTooLong = 4;
// The flux capacitor was spelled wrong.
H9K = 5;
// Operation not permitted in temporary channel.
TemporaryChannel = 6;
// Operation requires certificate.
MissingCertificate = 7;
// Invalid username.
UserName = 8;
// Channel is full.
ChannelFull = 9;
// Channels are nested too deeply.
NestingLimit = 10;
// Maximum channel count reached.
ChannelCountLimit = 11;
// Amount of listener objects for this channel has been reached
ChannelListenerLimit = 12;
// Amount of listener proxies for the user has been reached
UserListenerLimit = 13;
}
// The denied permission when type is Permission.
optional uint32 permission = 1;
// channel_id for the channel where the permission was denied when type is
// Permission.
optional uint32 channel_id = 2;
// The user who was denied permissions, identified by session.
optional uint32 session = 3;
// Textual reason for the denial.
optional string reason = 4;
// Type of the denial.
optional DenyType type = 5;
// The name that is invalid when type is UserName.
optional string name = 6;
}
message ACL {
message ChanGroup {
// Name of the channel group, UTF-8 encoded.
required string name = 1;
// True if the group has been inherited from the parent (Read only).
optional bool inherited = 2 [default = true];
// True if the group members are inherited.
optional bool inherit = 3 [default = true];
// True if the group can be inherited by sub channels.
optional bool inheritable = 4 [default = true];
// Users explicitly included in this group, identified by user_id.
repeated uint32 add = 5;
// Users explicitly removed from this group in this channel if the group
// has been inherited, identified by user_id.
repeated uint32 remove = 6;
// Users inherited, identified by user_id.
repeated uint32 inherited_members = 7;
}
message ChanACL {
// True if this ACL applies to the current channel.
optional bool apply_here = 1 [default = true];
// True if this ACL applies to the sub channels.
optional bool apply_subs = 2 [default = true];
// True if the ACL has been inherited from the parent.
optional bool inherited = 3 [default = true];
// ID of the user that is affected by this ACL.
optional uint32 user_id = 4;
// ID of the group that is affected by this ACL.
optional string group = 5;
// Bit flag field of the permissions granted by this ACL.
optional uint32 grant = 6;
// Bit flag field of the permissions denied by this ACL.
optional uint32 deny = 7;
}
// Channel ID of the channel this message affects.
required uint32 channel_id = 1;
// True if the channel inherits its parent's ACLs.
optional bool inherit_acls = 2 [default = true];
// User group specifications.
repeated ChanGroup groups = 3;
// ACL specifications.
repeated ChanACL acls = 4;
// True if the message is a query for ACLs instead of setting them.
optional bool query = 5 [default = false];
}
// Client may use this message to refresh its registered user information. The
// client should fill the IDs or Names of the users it wants to refresh. The
// server fills the missing parts and sends the message back.
message QueryUsers {
// user_ids.
repeated uint32 ids = 1;
// User names in the same order as ids.
repeated string names = 2;
}
// Used to initialize and resync the UDP encryption. Either side may request a
// resync by sending the message without any values filled. The resync is
// performed by sending the message with only the client or server nonce
// filled.
message CryptSetup {
// Encryption key.
optional bytes key = 1;
// Client nonce.
optional bytes client_nonce = 2;
// Server nonce.
optional bytes server_nonce = 3;
}
// Used to add or remove custom context menu item on client-side.
message ContextActionModify {
enum Context {
// Action is applicable to the server.
Server = 0x01;
// Action can target a Channel.
Channel = 0x02;
// Action can target a User.
User = 0x04;
}
enum Operation {
Add = 0;
Remove = 1;
}
// The action identifier. Used later to initiate an action.
required string action = 1;
// The display name of the action.
optional string text = 2;
// Context bit flags defining where the action should be displayed.
// Flags can be OR-ed to combine different types.
optional uint32 context = 3;
// Choose either to add or to remove the context action.
// Note: This field only exists after Mumble 1.2.4-beta1 release.
// The message will be recognized as Add regardless of this field
// before said release.
optional Operation operation = 4;
}
// Sent by the client when it wants to initiate a Context action.
message ContextAction {
// The target User for the action, identified by session.
optional uint32 session = 1;
// The target Channel for the action, identified by channel_id.
optional uint32 channel_id = 2;
// The action that should be executed.
required string action = 3;
}
// Lists the registered users.
message UserList {
message User {
// Registered user ID.
required uint32 user_id = 1;
// Registered user name.
optional string name = 2;
optional string last_seen = 3;
optional uint32 last_channel = 4;
}
// A list of registered users.
repeated User users = 1;
}
// Sent by the client when it wants to register or clear whisper targets.
//
// Note: The first available target ID is 1 as 0 is reserved for normal
// talking. Maximum target ID is 30.
message VoiceTarget {
message Target {
// Users that are included as targets.
repeated uint32 session = 1;
// Channel that is included as a target.
optional uint32 channel_id = 2;
// ACL group that is included as a target.
optional string group = 3;
// True if the voice should follow links from the specified channel.
optional bool links = 4 [default = false];
// True if the voice should also be sent to children of the specific
// channel.
optional bool children = 5 [default = false];
}
// Voice target ID.
optional uint32 id = 1;
// The receivers that this voice target includes.
repeated Target targets = 2;
}
// Sent by the client when it wants permissions for a certain channel. Sent by
// the server when it replies to the query or wants the user to resync all
// channel permissions.
message PermissionQuery {
// channel_id of the channel for which the permissions are queried.
optional uint32 channel_id = 1;
// Channel permissions.
optional uint32 permissions = 2;
// True if the client should drop its current permission information for all
// channels.
optional bool flush = 3 [default = false];
}
// Sent by the server to notify the users of the version of the CELT codec they
// should use. This may change during the connection when new users join.
message CodecVersion {
// The version of the CELT Alpha codec.
required int32 alpha = 1;
// The version of the CELT Beta codec.
required int32 beta = 2;
// True if the user should prefer Alpha over Beta.
required bool prefer_alpha = 3 [default = true];
optional bool opus = 4 [default = false];
}
// Used to communicate user stats between the server and clients.
message UserStats {
message Stats {
// The amount of good packets received.
optional uint32 good = 1;
// The amount of late packets received.
optional uint32 late = 2;
// The amount of packets never received.
optional uint32 lost = 3;
// The amount of nonce resyncs.
optional uint32 resync = 4;
}
message RollingStats {
// Rolling packet statistics time window as defined on the server.
optional uint32 time_window = 1;
// Rolling packet statistics for packets received from the client.
optional Stats from_client = 2;
// Rolling packet statistics for packets sent by the server.
optional Stats from_server = 3;
}
// User whose stats these are.
optional uint32 session = 1;
// True if the message contains only mutable stats (packets, ping).
optional bool stats_only = 2 [default = false];
// Full user certificate chain of the user certificate in DER format.
repeated bytes certificates = 3;
// Packet statistics for packets received from the client.
optional Stats from_client = 4;
// Packet statistics for packets sent by the server.
optional Stats from_server = 5;
// Amount of UDP packets sent.
optional uint32 udp_packets = 6;
// Amount of TCP packets sent.
optional uint32 tcp_packets = 7;
// UDP ping average.
optional float udp_ping_avg = 8;
// UDP ping variance.
optional float udp_ping_var = 9;
// TCP ping average.
optional float tcp_ping_avg = 10;
// TCP ping variance.
optional float tcp_ping_var = 11;
// Client version.
optional Version version = 12;
// A list of CELT bitstream version constants supported by the client of this
// user.
repeated int32 celt_versions = 13;
// Client IP address.
optional bytes address = 14;
// Bandwidth used by this client.
optional uint32 bandwidth = 15;
// Connection duration.
optional uint32 onlinesecs = 16;
// Duration since last activity.
optional uint32 idlesecs = 17;
// True if the user has a strong certificate.
optional bool strong_certificate = 18 [default = false];
optional bool opus = 19 [default = false];
// Rolling packet statistics
optional RollingStats rolling_stats = 20;
}
// Used by the client to request binary data from the server. By default large
// comments or textures are not sent within standard messages but instead the
// hash is. If the client does not recognize the hash it may request the
// resource when it needs it. The client does so by sending a RequestBlob
// message with the correct fields filled with the user sessions or channel_ids
// it wants to receive. The server replies to this by sending a new
// UserState/ChannelState message with the resources filled even if they would
// normally be transmitted as hashes.
message RequestBlob {
// sessions of the requested UserState textures.
repeated uint32 session_texture = 1;
// sessions of the requested UserState comments.
repeated uint32 session_comment = 2;
// channel_ids of the requested ChannelState descriptions.
repeated uint32 channel_description = 3;
}
// Sent by the server when it informs the clients on server configuration
// details.
message ServerConfig {
// The maximum bandwidth the clients should use.
optional uint32 max_bandwidth = 1;
// Server welcome text.
optional string welcome_text = 2;
// True if the server allows HTML.
optional bool allow_html = 3;
// Maximum text message length.
optional uint32 message_length = 4;
// Maximum image message length.
optional uint32 image_message_length = 5;
// The maximum number of users allowed on the server.
optional uint32 max_users = 6;
// Whether using Mumble's recording feature is allowed on the server
optional bool recording_allowed = 7;
}
// Sent by the server to inform the clients of suggested client configuration
// specified by the server administrator.
message SuggestConfig {
// Suggested client version in the legacy format.
optional uint32 version_v1 = 1;
// Suggested client version in the new format.
// Necessary since patch level may exceed 255. (See https://github.com/mumble-voip/mumble/issues/5827)
optional uint64 version_v2 = 4;
// True if the administrator suggests positional audio to be used on this
// server.
optional bool positional = 2;
// True if the administrator suggests push to talk to be used on this server.
optional bool push_to_talk = 3;
}
// Used to send plugin messages between clients
message PluginDataTransmission {
// The session ID of the client this message was sent from
optional uint32 senderSession = 1;
// The session IDs of the clients that should receive this message
repeated uint32 receiverSessions = 2 [packed = true];
// The data that is sent
optional bytes data = 3;
// The ID of the sent data. This will be used by plugins to check whether they will
// process it or not
optional string dataID = 4;
}
+85
View File
@@ -0,0 +1,85 @@
// Copyright The Mumble Developers. All rights reserved.
// Use of this source code is governed by a BSD-style license
// that can be found in the LICENSE file at the root of the
// Mumble source tree or at <https://www.mumble.info/LICENSE>.
syntax = "proto3";
package MumbleUDP;
option optimize_for = SPEED;
message Audio {
oneof Header {
// When this audio is sent by the client to the server, this is set to the target of the audio data. This target
// is a number in the range [0, 2^{32} - 1], where 0 means "normal talking", 2^{5} - 1 means "server loopback"
// and all other targets are understood as shout/whisper targets that have previously been registered via a
// VoiceTarget message (via TCP).
uint32 target = 1;
// When this audio is sent by the server to the client, this indicates the context in which the audio has been sent.
// 0: Normal speech
// 1: Shout to channel
// 2: Whisper to user
// 3: Received via channel listener
uint32 context = 2;
};
// The session of the client (sender) this audio was originally sent from. This field is not required when sending
// audio to the server, but will always be set when receiving audio from the server.
uint32 sender_session = 3;
// The number of the first contained audio frame (indicating the position of that frame in the overall audio stream)
uint64 frame_number = 4;
// The actual voice data payload in the Opus format.
bytes opus_data = 5;
// Optional positional data indicating the speaker's position in a virtual world (in meters). This "list" is really
// expected to be an array of size 3 containing the X, Y and Z coordinates of the position (in that order).
repeated float positional_data = 6;
// A volume adjustment determined by the server for this audio packet. It is up to the client to apply this adjustment to
// the resulting audio (or not). Note: A value of 0 means that this field is unset.
float volume_adjustment = 7;
// Note that we skip the field indices up to (including) 15 in order to have them available for future extensions of the
// protocol with fields that are encountered very often. The reason is that all field indices <= 15 require only a single
// byte of encoding overhead, whereas the once > 15 require (at least) two bytes. The reason lies in the Protobuf encoding
// scheme that uses 1 bit for a varint continuation flag, 3 bit to encode a field's type and the remaining 4 bit of the
// first byte are thus available for the field index. Therefore the first 2^4 = 16 field indices (aka values 0 to 15) can
// be encoded using only a single byte. For details see https://developers.google.com/protocol-buffers/docs/encoding
// A flag indicating whether this audio packet represents the end of transmission for the current audio stream
bool is_terminator = 16;
}
/**
* Ping message for checking UDP connectivity (and roundtrip ping) and potentially obtaining further server
* details (e.g. version).
*/
message Ping {
// Timestamp as encoded by the client. A server is not supposed to attempt to decode or modify this field. Therefore,
// clients may choose an arbitrary format for this timestamp (as long as it fits into a uint64 field).
uint64 timestamp = 1;
// A flag set by the sending client, if it wants to obtain additional information about the server.
bool request_extended_information = 2;
// Below are the fields for the "additional information" that are filled out by the server on request.
// The version of the server in the new version format.
// The new protobuf Ping packet introduced with 1.5 drops support for the legacy version format
// since both server and client have to support this new format.
// (See https://github.com/mumble-voip/mumble/issues/5827)
uint64 server_version_v2 = 3;
// The amount of users currently connected to the server
uint32 user_count = 4;
// The maximum amount of users permitted on this server
uint32 max_user_count = 5;
// The maximum bandwidth each user is allowed to use for sending audio to the server
uint32 max_bandwidth_per_user = 6;
}
+17
View File
@@ -0,0 +1,17 @@
import * as esbuild from 'esbuild';
// Bundles the Electron main and preload scripts to CommonJS in dist-electron/.
export const options = {
entryPoints: { main: 'electron/main.ts', preload: 'electron/preload.ts' },
outdir: 'dist-electron',
outExtension: { '.js': '.cjs' },
bundle: true,
platform: 'node',
format: 'cjs',
target: 'node22',
external: ['electron'],
sourcemap: true,
logLevel: 'info'
};
if (import.meta.url === `file://${process.argv[1]}`) await esbuild.build(options);
+28
View File
@@ -0,0 +1,28 @@
import { spawn } from 'node:child_process';
import * as esbuild from 'esbuild';
import { createServer } from 'vite';
import electronPath from 'electron';
import { options } from './build-electron.mjs';
// Vite dev server for the renderer, esbuild watch for main/preload, Electron restarted on main changes.
const server = await createServer();
await server.listen();
const url = server.resolvedUrls.local[0];
let child = null;
const startElectron = () => {
if (child) {
child.removeAllListeners('exit');
child.kill();
}
// ELECTRON_RUN_AS_NODE (set by some editor terminals) would start Electron as plain Node
const { ELECTRON_RUN_AS_NODE, ...env } = process.env;
child = spawn(electronPath, ['.'], { stdio: 'inherit', env: { ...env, VITE_DEV_SERVER_URL: url } });
child.on('exit', () => { server.close(); process.exit(0); });
};
const ctx = await esbuild.context({
...options,
plugins: [{ name: 'restart', setup(b) { b.onEnd(r => { if (!r.errors.length) startElectron(); }); } }]
});
await ctx.watch();
+95
View File
@@ -0,0 +1,95 @@
<script lang="ts">
import ServerRail from './ui/ServerRail.svelte';
import Sidebar from './ui/Sidebar.svelte';
import Chat from './ui/Chat.svelte';
import RightPanel from './ui/RightPanel.svelte';
import ConnectDialog from './ui/ConnectDialog.svelte';
import SettingsDialog from './ui/SettingsDialog.svelte';
import CertDialog from './ui/CertDialog.svelte';
import CertViewer from './ui/CertViewer.svelte';
import PromptDialog from './ui/PromptDialog.svelte';
import ContextMenu from './ui/ContextMenu.svelte';
import IdentityWizard from './ui/IdentityWizard.svelte';
import QuickAudio from './ui/QuickAudio.svelte';
import UserInfoDialog from './ui/UserInfoDialog.svelte';
import { identities } from './lib/identities.svelte.ts';
import { session } from './lib/session.svelte.ts';
import { ui } from './lib/ui.svelte.ts';
import type { SavedServer } from './lib/settings.svelte.ts';
let width = $state(window.innerWidth);
// wide: rail + sidebar + chat + panel; medium: panel as an overlay; narrow: everything but chat in drawers
const layout = $derived(width >= 1100 ? 'wide' : width >= 760 ? 'medium' : 'narrow');
let navOpen = $state(false);
let editing = $state<{ server: SavedServer | null } | null>(null);
const showPanel = $derived(ui.panelOpen && session.status === 'connected');
// The panel starts open on wide layouts only
let lastLayout = '';
$effect(() => {
if (layout === lastLayout) return;
ui.panelOpen = layout === 'wide';
lastLayout = layout;
});
// On phones one drawer at a time
$effect(() => { if (layout === 'narrow' && ui.panelOpen) navOpen = false; });
function togglePanel() {
if (!ui.panelOpen) ui.panelOpen = true;
else if (session.panel.kind !== 'members') session.closePanel();
else ui.panelOpen = false;
}
const closeOverlays = () => {
navOpen = false;
if (layout !== 'wide') ui.panelOpen = false;
};
</script>
<svelte:window bind:innerWidth={width} />
<div class="app {layout}">
{#if layout !== 'narrow' || navOpen}
<div class="nav" class:drawer={layout === 'narrow'}>
<ServerRail onadd={() => (editing = { server: null })} onedit={s => (editing = { server: s })}
onsettings={() => (ui.settingsOpen = true)} />
<Sidebar onnavigate={() => { if (layout === 'narrow') navOpen = false; }} />
</div>
{#if layout === 'narrow'}<button class="scrim" aria-label="Close" onclick={() => (navOpen = false)}></button>{/if}
{/if}
<Chat showMenu={layout === 'narrow'} onmenu={() => (navOpen = true)} onpanel={togglePanel} />
{#if showPanel}
{#if layout === 'wide'}
<RightPanel onnavigate={() => {}} onclose={() => (ui.panelOpen = false)} />
{:else}
<button class="scrim" aria-label="Close" onclick={() => (ui.panelOpen = false)}></button>
<div class="panel-drawer"><RightPanel onnavigate={closeOverlays} onclose={() => (ui.panelOpen = false)} /></div>
{/if}
{/if}
</div>
{#if editing}<ConnectDialog server={editing.server} onclose={() => (editing = null)} />{/if}
{#if ui.settingsOpen}<SettingsDialog tab={ui.settingsTab} onclose={() => { ui.settingsOpen = false; ui.settingsTab = 'voice'; }} />{/if}
{#if ui.quickAudio}<QuickAudio />{/if}
{#if ui.userInfo != null && session.status === 'connected'}
{#key ui.userInfo}<UserInfoDialog target={ui.userInfo} onclose={() => (ui.userInfo = null)} />{/key}
{/if}
{#if session.certPrompt}<CertDialog />{/if}
{#if ui.cert}<CertViewer {...ui.cert} onclose={() => (ui.cert = null)} />{/if}
{#if identities.loaded && !identities.setupDone}<IdentityWizard mode="setup" onclose={() => {}} />
{:else if ui.wizard}<IdentityWizard mode={ui.wizard} onclose={() => (ui.wizard = null)} />{/if}
{#if ui.prompt}<PromptDialog {...ui.prompt} onclose={() => (ui.prompt = null)} />{/if}
<ContextMenu />
<style>
.app { position: relative; height: 100%; display: flex; overflow: hidden; }
.nav { display: flex; flex: none; min-height: 0; }
.nav.drawer { position: absolute; z-index: 20; top: 0; bottom: 0; left: 0; max-width: calc(100% - 48px); box-shadow: 8px 0 32px rgb(0 0 0 / 0.4); }
.nav.drawer :global(.sidebar) { width: min(260px, calc(100vw - 68px - 48px)); }
.scrim { position: absolute; inset: 0; z-index: 15; background: rgb(0 0 0 / 0.5); }
.panel-drawer { position: absolute; z-index: 20; top: 0; right: 0; bottom: 0; max-width: calc(100% - 48px); display: flex; box-shadow: -8px 0 32px rgb(0 0 0 / 0.4); }
</style>
+81
View File
@@ -0,0 +1,81 @@
:root {
--bg-0: #0d0e10;
--bg-1: #151619;
--bg-2: #1b1c20;
--bg-3: #24262b;
--bg-hover: #2a2c32;
--bg-active: #33363d;
--line: #2c2e34;
--text: #e3e4e8;
--text-dim: #9a9ca5;
--text-faint: #686a73;
--accent: #7c6cff;
--accent-hover: #8f81ff;
--accent-text: #ffffff;
--speaking: #3bd16f;
--danger: #f0504a;
--warn: #e8b23c;
--link: #6fb3ff;
--font: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell, sans-serif;
--mono: ui-monospace, "Cascadia Code", "JetBrains Mono", Menlo, Consolas, monospace;
--fs: 15px;
color-scheme: dark;
}
*, *::before, *::after { box-sizing: border-box; border-radius: 0 !important; }
html, body, #app { height: 100%; margin: 0; }
body {
background: var(--bg-2);
color: var(--text);
font: var(--fs)/1.45 var(--font);
overflow: hidden;
-webkit-font-smoothing: antialiased;
}
a { color: var(--link); text-decoration: none; }
a:hover { text-decoration: underline; }
button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
button:disabled { cursor: default; opacity: 0.5; }
input, textarea, select {
font: inherit;
color: var(--text);
background: var(--bg-0);
border: 1px solid var(--line);
padding: 8px 10px;
outline: none;
width: 100%;
}
input:focus, textarea:focus, select:focus { border-color: var(--accent); }
label { display: block; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-dim); margin: 14px 0 6px; }
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
padding: 8px 16px; font-weight: 600; background: var(--bg-3); border: 1px solid var(--line);
}
.btn:hover:not(:disabled) { background: var(--bg-hover); }
.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
.btn.primary:hover:not(:disabled) { background: var(--accent-hover); }
.btn.danger { background: transparent; border-color: var(--danger); color: var(--danger); }
.icon-btn {
display: inline-flex; align-items: center; justify-content: center;
width: 32px; height: 32px; color: var(--text-dim);
}
.icon-btn:hover { background: var(--bg-hover); color: var(--text); }
.icon-btn.on { color: var(--danger); }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-thumb { background: var(--bg-3); border: 2px solid transparent; background-clip: padding-box; }
::-webkit-scrollbar-thumb:hover { background-color: var(--bg-active); }
::-webkit-scrollbar-track { background: transparent; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
+482
View File
@@ -0,0 +1,482 @@
import { Emitter } from './emitter.ts';
import { FrameReader, frame, type Codec, type MessageName } from './proto.ts';
import type { Transport } from './transport.ts';
export const CLIENT_RELEASE = 'mumh5 0.1.0';
const VERSION = { major: 1, minor: 5, patch: 0 };
const PING_INTERVAL_MS = 15000;
// Murmur drops these messages without telling the client when they come too fast
// (leaky bucket, defaults messageburst=5 and messagelimit=1/s). Mirroring the bucket and
// queueing keeps quick actions from getting lost. Slightly slower refill for safety.
const RATE_BURST = 5;
const RATE_REFILL_MS = 1100;
const RATE_LIMITED = new Set<MessageName>(['TextMessage', 'ChannelState', 'ACL', 'Version']);
export interface Channel {
id: number;
parent: number | null;
name: string;
description: string;
descriptionHash: Uint8Array | null;
position: number;
temporary: boolean;
links: Set<number>;
maxUsers: number;
canEnter: boolean;
}
export interface User {
session: number;
name: string;
userId: number | null;
channelId: number;
mute: boolean;
deaf: boolean;
suppress: boolean;
selfMute: boolean;
selfDeaf: boolean;
prioritySpeaker: boolean;
recording: boolean;
comment: string;
commentHash: Uint8Array | null;
texture: Uint8Array | null;
textureHash: Uint8Array | null;
hash: string;
}
export interface TextMessage {
actor: number | null;
sessions: number[];
channels: number[];
trees: number[];
html: string;
time: number;
}
export interface ServerConfig {
allowHtml: boolean;
messageLength: number;
imageMessageLength: number;
maxUsers: number;
recordingAllowed: boolean;
}
export interface Denial {
type: number;
permission: number;
reason: string;
channelId: number | null;
session: number | null;
name: string;
}
// ACL permission bits, as used in PermissionDenied.permission
export const PERMISSIONS: Record<number, string> = {
0x1: 'Write ACL', 0x2: 'Traverse', 0x4: 'Enter', 0x8: 'Speak', 0x10: 'Mute/Deafen', 0x20: 'Move',
0x40: 'Make channel', 0x80: 'Link channel', 0x100: 'Whisper', 0x200: 'Text message',
0x400: 'Make temporary channel', 0x800: 'Listen', 0x10000: 'Kick', 0x20000: 'Ban',
0x40000: 'Register', 0x80000: 'Register self', 0x100000: 'Reset user content'
};
// Human-readable text for a PermissionDenied message
export function describeDenial(d: Denial, channelName?: string): string {
switch (d.type) {
case 0: return d.reason || 'Denied by the server';
case 1: {
const what = PERMISSIONS[d.permission] ?? 'this action';
return `You do not have permission for ${what}${channelName ? ` in ${channelName}` : ''}`;
}
case 2: return 'The SuperUser cannot be modified';
case 3: return 'Invalid channel name';
case 4: return 'Message too long for this server';
case 6: return 'Not allowed in a temporary channel';
case 7: return 'This needs a registered certificate';
case 8: return `Invalid user name${d.name ? `: ${d.name}` : ''}`;
case 9: return 'The channel is full';
case 10: return 'Channels are nested too deeply';
case 11: return 'The server has reached its channel limit';
default: return d.reason || 'Denied by the server';
}
}
export interface ConnectOptions {
username: string;
password?: string;
tokens?: string[];
os?: string;
osVersion?: string;
}
type ClientEvents = {
synced: () => void;
channel: (ch: Channel) => void;
channelRemove: (id: number) => void;
user: (user: User, changed: string[], actor: number | null, isNew: boolean) => void;
userRemove: (user: User, actor: number | null, reason: string, ban: boolean) => void;
text: (msg: TextMessage) => void;
pluginData: (sender: number, dataId: string, data: Uint8Array) => void;
permissionDenied: (msg: Denial) => void;
reject: (type: number, reason: string) => void;
voice: (packet: Uint8Array) => void;
userStats: (stats: any) => void;
ping: (rttMs: number) => void;
close: (reason: string) => void;
message: (name: MessageName, msg: any) => void;
};
export class MumbleClient extends Emitter<ClientEvents> {
readonly channels = new Map<number, Channel>();
readonly users = new Map<number, User>();
session: number | null = null;
synced = false;
welcomeText = '';
maxBandwidth = 0;
serverVersion = '';
// Numeric server version (major << 16 | minor << 8 | patch), 0 until known
serverVersionNum = 0;
rtt = 0;
config: ServerConfig = { allowHtml: true, messageLength: 5000, imageMessageLength: 131072, maxUsers: 0, recordingAllowed: true };
private transport: Transport | null = null;
private reader = new FrameReader();
private pingTimer: ReturnType<typeof setInterval> | null = null;
private closed = false;
private tokens = RATE_BURST;
private lastRefill = Date.now();
private queue: Uint8Array[] = [];
private queueTimer: ReturnType<typeof setTimeout> | null = null;
private codec: Codec;
constructor(codec: Codec) {
super();
this.codec = codec;
}
get self(): User | null {
return this.session == null ? null : this.users.get(this.session) ?? null;
}
connect(transport: Transport, opts: ConnectOptions): void {
this.transport = transport;
transport.onData = chunk => {
try {
this.reader.push(chunk, (id, body) => this.handleFrame(id, body));
} catch (e) {
this.disconnect(`Protocol error: ${(e as Error).message}`);
}
};
transport.onClose = reason => this.handleClose(reason);
const { major, minor, patch } = VERSION;
this.send('Version', {
version_v1: (major << 16) | (minor << 8) | patch,
version_v2: major * 2 ** 48 + minor * 2 ** 32 + patch * 2 ** 16,
release: CLIENT_RELEASE,
os: opts.os ?? 'unknown',
os_version: opts.osVersion ?? ''
});
this.send('Authenticate', {
username: opts.username,
password: opts.password ?? '',
tokens: opts.tokens ?? [],
opus: true,
client_type: 0
});
this.pingTimer = setInterval(() => this.ping(), PING_INTERVAL_MS);
}
disconnect(reason = 'Disconnected'): void {
this.transport?.close();
this.handleClose(reason);
}
send(name: MessageName, payload: Record<string, unknown>): void {
if (!this.transport || this.closed) return;
const bytes = this.codec.encode(name, payload);
// Only changes to our own user state count against the bucket
const ownState = name === 'UserState' && (payload.session == null || payload.session === this.session);
if (RATE_LIMITED.has(name) || ownState) this.sendPaced(bytes);
else this.transport.send(bytes);
}
// Messages still waiting for the rate limit
get queued(): number {
return this.queue.length;
}
private refill(): void {
const now = Date.now();
const gained = Math.floor((now - this.lastRefill) / RATE_REFILL_MS);
if (gained > 0) {
this.tokens = Math.min(RATE_BURST, this.tokens + gained);
this.lastRefill += gained * RATE_REFILL_MS;
}
if (this.tokens === RATE_BURST) this.lastRefill = now;
}
private sendPaced(bytes: Uint8Array): void {
this.queue.push(bytes);
this.drain();
}
private drain(): void {
if (this.queueTimer || this.closed) return;
this.refill();
while (this.queue.length && this.tokens > 0) {
this.tokens--;
this.transport?.send(this.queue.shift()!);
}
if (this.queue.length) {
this.queueTimer = setTimeout(() => { this.queueTimer = null; this.drain(); }, RATE_REFILL_MS - (Date.now() - this.lastRefill) + 5);
}
}
// ─── Actions ───────────────────────────────────────────────────────────────
sendText(target: { channels?: number[]; users?: number[]; trees?: number[] }, html: string): void {
this.send('TextMessage', {
session: target.users ?? [],
channel_id: target.channels ?? [],
tree_id: target.trees ?? [],
message: html
});
}
joinChannel(channelId: number): void {
if (this.session == null) return;
this.send('UserState', { session: this.session, channel_id: channelId });
}
setSelfMute(mute: boolean): void {
// Unmuting also undeafens, matching the desktop client
this.send('UserState', mute ? { self_mute: true } : { self_mute: false, self_deaf: false });
}
setSelfDeaf(deaf: boolean): void {
// Deafening implies muting
this.send('UserState', deaf ? { self_mute: true, self_deaf: true } : { self_deaf: false });
}
setComment(comment: string): void {
if (this.session == null) return;
this.send('UserState', { session: this.session, comment });
}
kick(session: number, reason: string, ban = false): void {
this.send('UserRemove', { session, reason, ban });
}
// Server-side (admin) state of another user
setUserState(session: number, state: { mute?: boolean; deaf?: boolean; priority_speaker?: boolean; channel_id?: number }): void {
this.send('UserState', { session, ...state });
}
// Connection details of a user; the server decides how much we may see
requestUserStats(session: number, statsOnly = false): void {
this.send('UserStats', { session, stats_only: statsOnly });
}
// Fetch comments/descriptions/textures that the server only announced by hash.
requestBlob(req: { textures?: number[]; comments?: number[]; descriptions?: number[] }): void {
this.send('RequestBlob', {
session_texture: req.textures ?? [],
session_comment: req.comments ?? [],
channel_description: req.descriptions ?? []
});
}
sendPluginData(receivers: number[], dataId: string, data: Uint8Array): void {
if (this.session == null || !receivers.length) return;
this.send('PluginDataTransmission', {
senderSession: this.session,
receiverSessions: receivers,
data,
dataID: dataId
});
}
// Voice over TCP: the UDPTunnel body is the raw voice packet, not a protobuf message
sendVoiceTunnel(packet: Uint8Array): void {
if (!this.transport || this.closed) return;
this.transport.send(frame(1, packet));
}
// 1.5+ servers talk to 1.5+ clients in the protobuf voice format
get protobufVoice(): boolean {
return this.serverVersionNum >= 0x010500;
}
// Registers a user (ourselves or, with the Register permission, someone else) on the server
register(session: number): void {
this.send('UserState', { session, user_id: 0 });
}
// ─── Incoming ──────────────────────────────────────────────────────────────
private ping(): void {
this.send('Ping', { timestamp: Date.now() });
}
private handleClose(reason: string): void {
if (this.closed) return;
this.closed = true;
if (this.pingTimer) clearInterval(this.pingTimer);
if (this.queueTimer) clearTimeout(this.queueTimer);
this.pingTimer = null;
this.queueTimer = null;
this.queue = [];
this.emit('close', reason);
}
private handleFrame(typeId: number, body: Uint8Array): void {
// UDPTunnel carries a raw voice packet, not a protobuf message
if (typeId === 1) {
this.emit('voice', body.slice());
return;
}
const decoded = this.codec.decode(typeId, body);
if (!decoded) return;
const { name, msg } = decoded;
switch (name) {
case 'Version':
this.serverVersion = msg.release ?? '';
if (msg.version_v2) {
const v = Number(msg.version_v2);
this.serverVersionNum = (Math.floor(v / 2 ** 48) << 16) | ((Math.floor(v / 2 ** 32) & 0xffff) << 8) | (Math.floor(v / 2 ** 16) & 0xffff);
} else if (msg.version_v1) {
this.serverVersionNum = msg.version_v1;
}
break;
case 'Ping':
if (msg.timestamp) {
this.rtt = Date.now() - Number(msg.timestamp);
this.emit('ping', this.rtt);
}
break;
case 'Reject':
this.emit('reject', msg.type ?? 0, msg.reason ?? 'Rejected');
this.disconnect(msg.reason || 'Connection rejected');
break;
case 'ServerSync':
this.session = msg.session;
this.maxBandwidth = msg.max_bandwidth ?? 0;
this.welcomeText = msg.welcome_text ?? '';
this.synced = true;
this.ping();
this.emit('synced');
break;
case 'ServerConfig':
if (msg.allow_html != null) this.config.allowHtml = msg.allow_html;
if (msg.message_length != null) this.config.messageLength = msg.message_length;
if (msg.image_message_length != null) this.config.imageMessageLength = msg.image_message_length;
if (msg.max_users != null) this.config.maxUsers = msg.max_users;
if (msg.recording_allowed != null) this.config.recordingAllowed = msg.recording_allowed;
break;
case 'ChannelState':
this.applyChannelState(msg);
break;
case 'ChannelRemove':
this.channels.delete(msg.channel_id);
this.emit('channelRemove', msg.channel_id);
break;
case 'UserState':
this.applyUserState(msg);
break;
case 'UserRemove': {
const user = this.users.get(msg.session);
if (user) {
this.users.delete(msg.session);
this.emit('userRemove', user, msg.actor ?? null, msg.reason ?? '', !!msg.ban);
}
break;
}
case 'TextMessage':
this.emit('text', {
actor: msg.actor ?? null,
sessions: msg.session ?? [],
channels: msg.channel_id ?? [],
trees: msg.tree_id ?? [],
html: msg.message ?? '',
time: Date.now()
});
break;
case 'PermissionDenied':
this.emit('permissionDenied', {
type: msg.type ?? 0,
permission: msg.permission ?? 0,
reason: msg.reason ?? '',
channelId: msg.channel_id ?? null,
session: msg.session ?? null,
name: msg.name ?? ''
});
break;
case 'UserStats':
this.emit('userStats', msg);
break;
case 'PluginDataTransmission':
if (msg.senderSession != null && msg.dataID) {
this.emit('pluginData', msg.senderSession, msg.dataID, msg.data ?? new Uint8Array(0));
}
break;
}
this.emit('message', name, msg);
}
private applyChannelState(msg: any): void {
const id: number = msg.channel_id;
let ch = this.channels.get(id);
if (!ch) {
ch = {
id, parent: null, name: '', description: '', descriptionHash: null, position: 0,
temporary: false, links: new Set(), maxUsers: 0, canEnter: true
};
this.channels.set(id, ch);
}
if (msg.parent != null) ch.parent = msg.parent;
if (msg.name != null) ch.name = msg.name;
if (msg.description_hash != null && msg.description == null) ch.description = '';
if (msg.description != null) ch.description = msg.description;
if (msg.description_hash != null) ch.descriptionHash = msg.description_hash;
if (msg.position != null) ch.position = msg.position;
if (msg.temporary != null) ch.temporary = msg.temporary;
if (msg.max_users != null) ch.maxUsers = msg.max_users;
if (msg.can_enter != null) ch.canEnter = msg.can_enter;
if (msg.links?.length) ch.links = new Set(msg.links);
for (const l of msg.links_add ?? []) ch.links.add(l);
for (const l of msg.links_remove ?? []) ch.links.delete(l);
this.emit('channel', ch);
}
private applyUserState(msg: any): void {
const session: number = msg.session ?? this.session;
let user = this.users.get(session);
const isNew = !user;
if (!user) {
user = {
session, name: '', userId: null, channelId: 0, mute: false, deaf: false, suppress: false,
selfMute: false, selfDeaf: false, prioritySpeaker: false, recording: false,
comment: '', commentHash: null, texture: null, textureHash: null, hash: ''
};
this.users.set(session, user);
}
const fields: [string, keyof User][] = [
['name', 'name'], ['user_id', 'userId'], ['channel_id', 'channelId'], ['mute', 'mute'],
['deaf', 'deaf'], ['suppress', 'suppress'], ['self_mute', 'selfMute'], ['self_deaf', 'selfDeaf'],
['priority_speaker', 'prioritySpeaker'], ['recording', 'recording'], ['comment', 'comment'],
['comment_hash', 'commentHash'], ['texture', 'texture'], ['texture_hash', 'textureHash'], ['hash', 'hash']
];
// A new hash without text means the text changed and must be fetched (RequestBlob)
if (msg.comment_hash != null && msg.comment == null) user.comment = '';
const changed: string[] = [];
for (const [src, dst] of fields) {
if (msg[src] != null) {
(user as any)[dst] = msg[src];
changed.push(dst);
}
}
this.emit('user', user, changed, msg.actor ?? null, isNew);
}
}
+15
View File
@@ -0,0 +1,15 @@
// Minimal typed event emitter that works in both browser and Node.
export class Emitter<Events extends Record<string, (...args: any[]) => void>> {
private handlers = new Map<keyof Events, Set<Function>>();
on<K extends keyof Events>(event: K, fn: Events[K]): () => void {
let set = this.handlers.get(event);
if (!set) this.handlers.set(event, (set = new Set()));
set.add(fn);
return () => set!.delete(fn);
}
emit<K extends keyof Events>(event: K, ...args: Parameters<Events[K]>): void {
for (const fn of this.handlers.get(event) ?? []) fn(...args);
}
}
+2
View File
@@ -0,0 +1,2 @@
// Types for the generated static module; the codec in proto.ts adds the real shape.
export const MumbleProto: Record<string, unknown>;
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
export const MumbleUDP: Record<string, any>;
+639
View File
@@ -0,0 +1,639 @@
/*eslint-disable block-scoped-var, id-length, no-control-regex, no-magic-numbers, no-mixed-operators, no-prototype-builtins, no-redeclare, no-shadow, no-var, sort-vars, default-case, jsdoc/require-param*/
import $protobuf from "protobufjs/minimal.js";
const $Reader = $protobuf.Reader, $Writer = $protobuf.Writer, $util = $protobuf.util;
const $Object = $util.global.Object, $undefined = $util.global.undefined, $Error = $util.global.Error, $RangeError = $util.global.RangeError, $Array = $util.global.Array, $TypeError = $util.global.TypeError, $Number = $util.global.Number, $parseInt = $util.global.parseInt, $Boolean = $util.global.Boolean, $String = $util.global.String, $BigInt = $util.global.BigInt, $isFinite = $util.global.isFinite;
const $root = $protobuf.roots["default"] || ($protobuf.roots["default"] = {});
export const MumbleUDP = $root.MumbleUDP = (() => {
const MumbleUDP = {};
MumbleUDP.Audio = (function() {
const Audio = function (properties) {
this.positional_data = [];
if (properties)
for (let keys = $Object.keys(properties), i = 0; i < keys.length; ++i)
if (properties[keys[i]] != null && keys[i] !== "__proto__")
this[keys[i]] = properties[keys[i]];
};
Audio.prototype.target = null;
Audio.prototype.context = null;
Audio.prototype.sender_session = 0;
Audio.prototype.frame_number = $util.Long ? $util.Long.fromBits(0,0,true) : 0;
Audio.prototype.opus_data = $util.newBuffer([]);
Audio.prototype.positional_data = $util.emptyArray;
Audio.prototype.volume_adjustment = 0;
Audio.prototype.is_terminator = false;
let $oneOfFields;
$Object.defineProperty(Audio.prototype, "Header", {
get: $util.oneOfGetter($oneOfFields = ["target", "context"]),
set: $util.oneOfSetter($oneOfFields)
});
Audio.create = function(properties) {
return new Audio(properties);
};
Audio.encode = function (message, writer, _depth) {
if (!writer)
writer = $Writer.create();
if (_depth === $undefined)
_depth = 0;
if (_depth > $util.recursionLimit)
throw $Error("max depth exceeded");
if (message.target != null && $Object.hasOwnProperty.call(message, "target"))
writer.uint32(8).uint32(message.target);
if (message.context != null && $Object.hasOwnProperty.call(message, "context"))
writer.uint32(16).uint32(message.context);
if (message.sender_session != null && $Object.hasOwnProperty.call(message, "sender_session") && message.sender_session !== 0)
writer.uint32(24).uint32(message.sender_session);
if (message.frame_number != null && $Object.hasOwnProperty.call(message, "frame_number") && (typeof message.frame_number === "object" ? message.frame_number.low || message.frame_number.high : message.frame_number !== 0))
writer.uint32(32).uint64(message.frame_number);
if (message.opus_data != null && $Object.hasOwnProperty.call(message, "opus_data") && message.opus_data.length)
writer.uint32(42).bytes(message.opus_data);
if (message.positional_data != null && message.positional_data.length)
writer.uint32(50).floats(message.positional_data);
if (message.volume_adjustment != null && $Object.hasOwnProperty.call(message, "volume_adjustment") && !$Object.is(message.volume_adjustment, 0))
writer.uint32(61).float(message.volume_adjustment);
if (message.is_terminator != null && $Object.hasOwnProperty.call(message, "is_terminator") && message.is_terminator !== false)
writer.uint32(128).bool(message.is_terminator);
if (message.$unknowns != null && $Object.hasOwnProperty.call(message, "$unknowns"))
for (let i = 0; i < message.$unknowns.length; ++i)
writer.raw(message.$unknowns[i]);
return writer;
};
Audio.decode = function (reader, length, _end, _depth, _target) {
if (!(reader instanceof $Reader))
reader = $Reader.create(reader);
if (_depth === $undefined)
_depth = 0;
if (_depth > $Reader.recursionLimit)
throw $Error("max depth exceeded");
let end, message, value;
if (length === $undefined)
end = reader.len;
else {
end = reader.pos + length;
if (end > reader.len)
throw $RangeError("index out of range");
length = reader.len;
reader.len = end;
}
message = _target || new $root.MumbleUDP.Audio();
while (reader.pos < end) {
let start = reader.pos;
let tag = reader.tag();
if (tag === _end) {
_end = $undefined;
break;
}
let wireType = tag & 7;
switch (tag >>>= 3) {
case 1: {
if (wireType !== 0)
break;
message.target = reader.uint32();
message.Header = "target";
continue;
}
case 2: {
if (wireType !== 0)
break;
message.context = reader.uint32();
message.Header = "context";
continue;
}
case 3: {
if (wireType !== 0)
break;
if (value = reader.uint32())
message.sender_session = value;
else
delete message.sender_session;
continue;
}
case 4: {
if (wireType !== 0)
break;
if (typeof (value = reader.uint64()) === "object" ? value.low || value.high : value !== 0)
message.frame_number = value;
else
delete message.frame_number;
continue;
}
case 5: {
if (wireType !== 2)
break;
if ((value = reader.bytes()).length)
message.opus_data = value;
else
delete message.opus_data;
continue;
}
case 6: {
if (wireType === 2) {
if (!(message.positional_data && message.positional_data.length))
message.positional_data = [];
reader.floats(message.positional_data);
continue;
}
if (wireType !== 5)
break;
if (!(message.positional_data && message.positional_data.length))
message.positional_data = [];
message.positional_data.push(reader.float());
continue;
}
case 7: {
if (wireType !== 5)
break;
if (!$Object.is(value = reader.float(), 0))
message.volume_adjustment = value;
else
delete message.volume_adjustment;
continue;
}
case 16: {
if (wireType !== 0)
break;
if (value = reader.bool())
message.is_terminator = value;
else
delete message.is_terminator;
continue;
}
}
reader.skipType(wireType, _depth, tag);
if (!reader.discardUnknown) {
$util.makeProp(message, "$unknowns", false);
(message.$unknowns || (message.$unknowns = [])).push(reader.raw(start, reader.pos));
}
}
if (length !== $undefined) {
if (reader.pos !== end)
throw $RangeError("index out of range");
reader.len = length;
}
if (_end !== $undefined)
throw $Error("missing end group");
return message;
};
Audio.verify = function (message, _depth) {
if (typeof message !== "object" || message === null)
return "object expected";
if (_depth === $undefined)
_depth = 0;
if (_depth > $util.recursionLimit)
return "max depth exceeded";
let properties = {};
if (message.target != null && $Object.hasOwnProperty.call(message, "target")) {
properties.Header = 1;
if (!$util.isInteger(message.target))
return "target: integer expected";
}
if (message.context != null && $Object.hasOwnProperty.call(message, "context")) {
if (properties.Header === 1)
return "Header: multiple values";
properties.Header = 1;
if (!$util.isInteger(message.context))
return "context: integer expected";
}
if (message.sender_session != null && $Object.hasOwnProperty.call(message, "sender_session"))
if (!$util.isInteger(message.sender_session))
return "sender_session: integer expected";
if (message.frame_number != null && $Object.hasOwnProperty.call(message, "frame_number"))
if (!$util.isInteger(message.frame_number) && !(message.frame_number && $util.isInteger(message.frame_number.low) && $util.isInteger(message.frame_number.high)))
return "frame_number: integer|Long expected";
if (message.opus_data != null && $Object.hasOwnProperty.call(message, "opus_data"))
if (!(message.opus_data && typeof message.opus_data.length === "number" || $util.isString(message.opus_data)))
return "opus_data: buffer expected";
if (message.positional_data != null && $Object.hasOwnProperty.call(message, "positional_data")) {
if (!$Array.isArray(message.positional_data))
return "positional_data: array expected";
for (let i = 0; i < message.positional_data.length; ++i)
if (typeof message.positional_data[i] !== "number")
return "positional_data: number[] expected";
}
if (message.volume_adjustment != null && $Object.hasOwnProperty.call(message, "volume_adjustment"))
if (typeof message.volume_adjustment !== "number")
return "volume_adjustment: number expected";
if (message.is_terminator != null && $Object.hasOwnProperty.call(message, "is_terminator"))
if (typeof message.is_terminator !== "boolean")
return "is_terminator: boolean expected";
return null;
};
Audio.fromObject = function (object, _depth) {
if (object instanceof $root.MumbleUDP.Audio)
return object;
if (!$util.isObject(object))
throw $TypeError(".MumbleUDP.Audio: object expected");
if (_depth === $undefined)
_depth = 0;
if (_depth > $util.recursionLimit)
throw $Error("max depth exceeded");
let message = new $root.MumbleUDP.Audio();
if (object.target != null)
message.target = object.target >>> 0;
if (object.context != null)
message.context = object.context >>> 0;
if (object.sender_session != null)
if ($Number(object.sender_session) !== 0)
message.sender_session = object.sender_session >>> 0;
if (object.frame_number != null)
if (typeof object.frame_number === "object" ? object.frame_number.low || object.frame_number.high : $Number(object.frame_number) !== 0)
if ($util.Long)
message.frame_number = $util.Long.fromValue(object.frame_number, true);
else if (typeof object.frame_number === "string")
message.frame_number = $parseInt(object.frame_number, 10);
else if (typeof object.frame_number === "number")
message.frame_number = object.frame_number;
else if (typeof object.frame_number === "object")
message.frame_number = new $util.LongBits(object.frame_number.low >>> 0, object.frame_number.high >>> 0).toNumber(true);
if (object.opus_data != null)
if (object.opus_data.length)
if (typeof object.opus_data === "string")
$util.base64.decode(object.opus_data, message.opus_data = $util.newBuffer($util.base64.length(object.opus_data)), 0);
else if (object.opus_data.length >= 0)
message.opus_data = object.opus_data;
if (object.positional_data) {
if (!$Array.isArray(object.positional_data))
throw $TypeError(".MumbleUDP.Audio.positional_data: array expected");
message.positional_data = $Array(object.positional_data.length);
for (let i = 0; i < object.positional_data.length; ++i)
message.positional_data[i] = $Number(object.positional_data[i]);
}
if (object.volume_adjustment != null)
if (!$Object.is($Number(object.volume_adjustment), 0))
message.volume_adjustment = $Number(object.volume_adjustment);
if (object.is_terminator != null)
if (object.is_terminator)
message.is_terminator = $Boolean(object.is_terminator);
return message;
};
Audio.toObject = function (message, options, _depth) {
if (!options)
options = {};
if (_depth === $undefined)
_depth = 0;
if (_depth > $util.recursionLimit)
throw $Error("max depth exceeded");
let object = {};
if (options.arrays || options.defaults)
object.positional_data = [];
if (options.defaults) {
object.sender_session = 0;
if ($util.Long) {
let long = new $util.Long(0, 0, true);
object.frame_number = options.longs === $String ? long.toString() : options.longs === $Number ? long.toNumber() : typeof $BigInt !== "undefined" && options.longs === $BigInt ? long.toBigInt() : long;
} else
object.frame_number = options.longs === $String ? "0" : typeof $BigInt !== "undefined" && options.longs === $BigInt ? $BigInt("0") : 0;
if (options.bytes === $String)
object.opus_data = "";
else {
object.opus_data = [];
if (options.bytes !== $Array)
object.opus_data = $util.newBuffer(object.opus_data);
}
object.volume_adjustment = 0;
object.is_terminator = false;
}
if (message.target != null && $Object.hasOwnProperty.call(message, "target")) {
object.target = message.target;
if (options.oneofs)
object.Header = "target";
}
if (message.context != null && $Object.hasOwnProperty.call(message, "context")) {
object.context = message.context;
if (options.oneofs)
object.Header = "context";
}
if (message.sender_session != null && $Object.hasOwnProperty.call(message, "sender_session"))
object.sender_session = message.sender_session;
if (message.frame_number != null && $Object.hasOwnProperty.call(message, "frame_number"))
if (typeof $BigInt !== "undefined" && options.longs === $BigInt)
object.frame_number = typeof message.frame_number === "number" ? $BigInt(message.frame_number) : $util.Long.fromBits(message.frame_number.low >>> 0, message.frame_number.high >>> 0, true).toBigInt();
else if (typeof message.frame_number === "number")
object.frame_number = options.longs === $String ? $String(message.frame_number) : message.frame_number;
else
object.frame_number = options.longs === $String ? $util.Long.prototype.toString.call(message.frame_number) : options.longs === $Number ? new $util.LongBits(message.frame_number.low >>> 0, message.frame_number.high >>> 0).toNumber(true) : message.frame_number;
if (message.opus_data != null && $Object.hasOwnProperty.call(message, "opus_data"))
object.opus_data = options.bytes === $String ? $util.base64.encode(message.opus_data, 0, message.opus_data.length) : options.bytes === $Array ? $Array.prototype.slice.call(message.opus_data) : message.opus_data;
if (message.positional_data && message.positional_data.length) {
object.positional_data = $Array(message.positional_data.length);
for (let j = 0; j < message.positional_data.length; ++j)
object.positional_data[j] = options.json && !$isFinite(message.positional_data[j]) ? $String(message.positional_data[j]) : message.positional_data[j];
}
if (message.volume_adjustment != null && $Object.hasOwnProperty.call(message, "volume_adjustment"))
object.volume_adjustment = options.json && !$isFinite(message.volume_adjustment) ? $String(message.volume_adjustment) : message.volume_adjustment;
if (message.is_terminator != null && $Object.hasOwnProperty.call(message, "is_terminator"))
object.is_terminator = message.is_terminator;
return object;
};
Audio.prototype.toJSON = function() {
return Audio.toObject(this, $protobuf.util.toJSONOptions);
};
Audio.getTypeUrl = function(prefix) {
if (prefix === $undefined)
prefix = "type.googleapis.com";
return prefix + "/MumbleUDP.Audio";
};
return Audio;
})();
MumbleUDP.Ping = (function() {
const Ping = function (properties) {
if (properties)
for (let keys = $Object.keys(properties), i = 0; i < keys.length; ++i)
if (properties[keys[i]] != null && keys[i] !== "__proto__")
this[keys[i]] = properties[keys[i]];
};
Ping.prototype.timestamp = $util.Long ? $util.Long.fromBits(0,0,true) : 0;
Ping.prototype.request_extended_information = false;
Ping.prototype.server_version_v2 = $util.Long ? $util.Long.fromBits(0,0,true) : 0;
Ping.prototype.user_count = 0;
Ping.prototype.max_user_count = 0;
Ping.prototype.max_bandwidth_per_user = 0;
Ping.create = function(properties) {
return new Ping(properties);
};
Ping.encode = function (message, writer, _depth) {
if (!writer)
writer = $Writer.create();
if (_depth === $undefined)
_depth = 0;
if (_depth > $util.recursionLimit)
throw $Error("max depth exceeded");
if (message.timestamp != null && $Object.hasOwnProperty.call(message, "timestamp") && (typeof message.timestamp === "object" ? message.timestamp.low || message.timestamp.high : message.timestamp !== 0))
writer.uint32(8).uint64(message.timestamp);
if (message.request_extended_information != null && $Object.hasOwnProperty.call(message, "request_extended_information") && message.request_extended_information !== false)
writer.uint32(16).bool(message.request_extended_information);
if (message.server_version_v2 != null && $Object.hasOwnProperty.call(message, "server_version_v2") && (typeof message.server_version_v2 === "object" ? message.server_version_v2.low || message.server_version_v2.high : message.server_version_v2 !== 0))
writer.uint32(24).uint64(message.server_version_v2);
if (message.user_count != null && $Object.hasOwnProperty.call(message, "user_count") && message.user_count !== 0)
writer.uint32(32).uint32(message.user_count);
if (message.max_user_count != null && $Object.hasOwnProperty.call(message, "max_user_count") && message.max_user_count !== 0)
writer.uint32(40).uint32(message.max_user_count);
if (message.max_bandwidth_per_user != null && $Object.hasOwnProperty.call(message, "max_bandwidth_per_user") && message.max_bandwidth_per_user !== 0)
writer.uint32(48).uint32(message.max_bandwidth_per_user);
if (message.$unknowns != null && $Object.hasOwnProperty.call(message, "$unknowns"))
for (let i = 0; i < message.$unknowns.length; ++i)
writer.raw(message.$unknowns[i]);
return writer;
};
Ping.decode = function (reader, length, _end, _depth, _target) {
if (!(reader instanceof $Reader))
reader = $Reader.create(reader);
if (_depth === $undefined)
_depth = 0;
if (_depth > $Reader.recursionLimit)
throw $Error("max depth exceeded");
let end, message, value;
if (length === $undefined)
end = reader.len;
else {
end = reader.pos + length;
if (end > reader.len)
throw $RangeError("index out of range");
length = reader.len;
reader.len = end;
}
message = _target || new $root.MumbleUDP.Ping();
while (reader.pos < end) {
let start = reader.pos;
let tag = reader.tag();
if (tag === _end) {
_end = $undefined;
break;
}
let wireType = tag & 7;
switch (tag >>>= 3) {
case 1: {
if (wireType !== 0)
break;
if (typeof (value = reader.uint64()) === "object" ? value.low || value.high : value !== 0)
message.timestamp = value;
else
delete message.timestamp;
continue;
}
case 2: {
if (wireType !== 0)
break;
if (value = reader.bool())
message.request_extended_information = value;
else
delete message.request_extended_information;
continue;
}
case 3: {
if (wireType !== 0)
break;
if (typeof (value = reader.uint64()) === "object" ? value.low || value.high : value !== 0)
message.server_version_v2 = value;
else
delete message.server_version_v2;
continue;
}
case 4: {
if (wireType !== 0)
break;
if (value = reader.uint32())
message.user_count = value;
else
delete message.user_count;
continue;
}
case 5: {
if (wireType !== 0)
break;
if (value = reader.uint32())
message.max_user_count = value;
else
delete message.max_user_count;
continue;
}
case 6: {
if (wireType !== 0)
break;
if (value = reader.uint32())
message.max_bandwidth_per_user = value;
else
delete message.max_bandwidth_per_user;
continue;
}
}
reader.skipType(wireType, _depth, tag);
if (!reader.discardUnknown) {
$util.makeProp(message, "$unknowns", false);
(message.$unknowns || (message.$unknowns = [])).push(reader.raw(start, reader.pos));
}
}
if (length !== $undefined) {
if (reader.pos !== end)
throw $RangeError("index out of range");
reader.len = length;
}
if (_end !== $undefined)
throw $Error("missing end group");
return message;
};
Ping.verify = function (message, _depth) {
if (typeof message !== "object" || message === null)
return "object expected";
if (_depth === $undefined)
_depth = 0;
if (_depth > $util.recursionLimit)
return "max depth exceeded";
if (message.timestamp != null && $Object.hasOwnProperty.call(message, "timestamp"))
if (!$util.isInteger(message.timestamp) && !(message.timestamp && $util.isInteger(message.timestamp.low) && $util.isInteger(message.timestamp.high)))
return "timestamp: integer|Long expected";
if (message.request_extended_information != null && $Object.hasOwnProperty.call(message, "request_extended_information"))
if (typeof message.request_extended_information !== "boolean")
return "request_extended_information: boolean expected";
if (message.server_version_v2 != null && $Object.hasOwnProperty.call(message, "server_version_v2"))
if (!$util.isInteger(message.server_version_v2) && !(message.server_version_v2 && $util.isInteger(message.server_version_v2.low) && $util.isInteger(message.server_version_v2.high)))
return "server_version_v2: integer|Long expected";
if (message.user_count != null && $Object.hasOwnProperty.call(message, "user_count"))
if (!$util.isInteger(message.user_count))
return "user_count: integer expected";
if (message.max_user_count != null && $Object.hasOwnProperty.call(message, "max_user_count"))
if (!$util.isInteger(message.max_user_count))
return "max_user_count: integer expected";
if (message.max_bandwidth_per_user != null && $Object.hasOwnProperty.call(message, "max_bandwidth_per_user"))
if (!$util.isInteger(message.max_bandwidth_per_user))
return "max_bandwidth_per_user: integer expected";
return null;
};
Ping.fromObject = function (object, _depth) {
if (object instanceof $root.MumbleUDP.Ping)
return object;
if (!$util.isObject(object))
throw $TypeError(".MumbleUDP.Ping: object expected");
if (_depth === $undefined)
_depth = 0;
if (_depth > $util.recursionLimit)
throw $Error("max depth exceeded");
let message = new $root.MumbleUDP.Ping();
if (object.timestamp != null)
if (typeof object.timestamp === "object" ? object.timestamp.low || object.timestamp.high : $Number(object.timestamp) !== 0)
if ($util.Long)
message.timestamp = $util.Long.fromValue(object.timestamp, true);
else if (typeof object.timestamp === "string")
message.timestamp = $parseInt(object.timestamp, 10);
else if (typeof object.timestamp === "number")
message.timestamp = object.timestamp;
else if (typeof object.timestamp === "object")
message.timestamp = new $util.LongBits(object.timestamp.low >>> 0, object.timestamp.high >>> 0).toNumber(true);
if (object.request_extended_information != null)
if (object.request_extended_information)
message.request_extended_information = $Boolean(object.request_extended_information);
if (object.server_version_v2 != null)
if (typeof object.server_version_v2 === "object" ? object.server_version_v2.low || object.server_version_v2.high : $Number(object.server_version_v2) !== 0)
if ($util.Long)
message.server_version_v2 = $util.Long.fromValue(object.server_version_v2, true);
else if (typeof object.server_version_v2 === "string")
message.server_version_v2 = $parseInt(object.server_version_v2, 10);
else if (typeof object.server_version_v2 === "number")
message.server_version_v2 = object.server_version_v2;
else if (typeof object.server_version_v2 === "object")
message.server_version_v2 = new $util.LongBits(object.server_version_v2.low >>> 0, object.server_version_v2.high >>> 0).toNumber(true);
if (object.user_count != null)
if ($Number(object.user_count) !== 0)
message.user_count = object.user_count >>> 0;
if (object.max_user_count != null)
if ($Number(object.max_user_count) !== 0)
message.max_user_count = object.max_user_count >>> 0;
if (object.max_bandwidth_per_user != null)
if ($Number(object.max_bandwidth_per_user) !== 0)
message.max_bandwidth_per_user = object.max_bandwidth_per_user >>> 0;
return message;
};
Ping.toObject = function (message, options, _depth) {
if (!options)
options = {};
if (_depth === $undefined)
_depth = 0;
if (_depth > $util.recursionLimit)
throw $Error("max depth exceeded");
let object = {};
if (options.defaults) {
if ($util.Long) {
let long = new $util.Long(0, 0, true);
object.timestamp = options.longs === $String ? long.toString() : options.longs === $Number ? long.toNumber() : typeof $BigInt !== "undefined" && options.longs === $BigInt ? long.toBigInt() : long;
} else
object.timestamp = options.longs === $String ? "0" : typeof $BigInt !== "undefined" && options.longs === $BigInt ? $BigInt("0") : 0;
object.request_extended_information = false;
if ($util.Long) {
let long = new $util.Long(0, 0, true);
object.server_version_v2 = options.longs === $String ? long.toString() : options.longs === $Number ? long.toNumber() : typeof $BigInt !== "undefined" && options.longs === $BigInt ? long.toBigInt() : long;
} else
object.server_version_v2 = options.longs === $String ? "0" : typeof $BigInt !== "undefined" && options.longs === $BigInt ? $BigInt("0") : 0;
object.user_count = 0;
object.max_user_count = 0;
object.max_bandwidth_per_user = 0;
}
if (message.timestamp != null && $Object.hasOwnProperty.call(message, "timestamp"))
if (typeof $BigInt !== "undefined" && options.longs === $BigInt)
object.timestamp = typeof message.timestamp === "number" ? $BigInt(message.timestamp) : $util.Long.fromBits(message.timestamp.low >>> 0, message.timestamp.high >>> 0, true).toBigInt();
else if (typeof message.timestamp === "number")
object.timestamp = options.longs === $String ? $String(message.timestamp) : message.timestamp;
else
object.timestamp = options.longs === $String ? $util.Long.prototype.toString.call(message.timestamp) : options.longs === $Number ? new $util.LongBits(message.timestamp.low >>> 0, message.timestamp.high >>> 0).toNumber(true) : message.timestamp;
if (message.request_extended_information != null && $Object.hasOwnProperty.call(message, "request_extended_information"))
object.request_extended_information = message.request_extended_information;
if (message.server_version_v2 != null && $Object.hasOwnProperty.call(message, "server_version_v2"))
if (typeof $BigInt !== "undefined" && options.longs === $BigInt)
object.server_version_v2 = typeof message.server_version_v2 === "number" ? $BigInt(message.server_version_v2) : $util.Long.fromBits(message.server_version_v2.low >>> 0, message.server_version_v2.high >>> 0, true).toBigInt();
else if (typeof message.server_version_v2 === "number")
object.server_version_v2 = options.longs === $String ? $String(message.server_version_v2) : message.server_version_v2;
else
object.server_version_v2 = options.longs === $String ? $util.Long.prototype.toString.call(message.server_version_v2) : options.longs === $Number ? new $util.LongBits(message.server_version_v2.low >>> 0, message.server_version_v2.high >>> 0).toNumber(true) : message.server_version_v2;
if (message.user_count != null && $Object.hasOwnProperty.call(message, "user_count"))
object.user_count = message.user_count;
if (message.max_user_count != null && $Object.hasOwnProperty.call(message, "max_user_count"))
object.max_user_count = message.max_user_count;
if (message.max_bandwidth_per_user != null && $Object.hasOwnProperty.call(message, "max_bandwidth_per_user"))
object.max_bandwidth_per_user = message.max_bandwidth_per_user;
return object;
};
Ping.prototype.toJSON = function() {
return Ping.toObject(this, $protobuf.util.toJSONOptions);
};
Ping.getTypeUrl = function(prefix) {
if (prefix === $undefined)
prefix = "type.googleapis.com";
return prefix + "/MumbleUDP.Ping";
};
return Ping;
})();
return MumbleUDP;
})();
export {
$root as default
};
+81
View File
@@ -0,0 +1,81 @@
// Generated from proto/Mumble.proto by `npm run proto` (static code, no eval, so it runs under a strict CSP)
import { MumbleProto } from './mumble-pb.js';
// TCP control message type ids, in wire order (index = type id).
export const MESSAGE_TYPES = [
'Version', 'UDPTunnel', 'Authenticate', 'Ping', 'Reject', 'ServerSync',
'ChannelRemove', 'ChannelState', 'UserRemove', 'UserState', 'BanList',
'TextMessage', 'PermissionDenied', 'ACL', 'QueryUsers', 'CryptSetup',
'ContextActionModify', 'ContextAction', 'UserList', 'VoiceTarget',
'PermissionQuery', 'CodecVersion', 'UserStats', 'RequestBlob',
'ServerConfig', 'SuggestConfig', 'PluginDataTransmission'
] as const;
export type MessageName = (typeof MESSAGE_TYPES)[number];
export interface Codec {
encode(name: MessageName, payload: Record<string, unknown>): Uint8Array;
decode(typeId: number, body: Uint8Array): { name: MessageName; msg: any } | null;
}
interface GeneratedType {
verify(obj: Record<string, unknown>): string | null;
fromObject(obj: Record<string, unknown>): unknown;
encode(msg: unknown): { finish(): Uint8Array };
decode(body: Uint8Array): unknown;
toObject(msg: unknown, opts: { longs: NumberConstructor }): any;
}
// Field names keep their snake_case spelling from the .proto file; 64-bit integers become numbers.
export function createCodec(): Codec {
const types = MESSAGE_TYPES.map(n => (MumbleProto as unknown as Record<string, GeneratedType>)[n]);
return {
encode(name, payload) {
const id = MESSAGE_TYPES.indexOf(name);
const type = types[id];
const err = type.verify(payload);
if (err) throw new Error(`${name}: ${err}`);
const body = type.encode(type.fromObject(payload)).finish();
return frame(id, body);
},
decode(typeId, body) {
const type = types[typeId];
if (!type) return null;
const msg = type.toObject(type.decode(body), { longs: Number });
return { name: MESSAGE_TYPES[typeId], msg };
}
};
}
// 6-byte header: uint16 type, uint32 length (big endian), then the body.
export function frame(typeId: number, body: Uint8Array): Uint8Array {
const out = new Uint8Array(6 + body.length);
const view = new DataView(out.buffer);
view.setUint16(0, typeId);
view.setUint32(2, body.length);
out.set(body, 6);
return out;
}
// Reassembles framed messages from an arbitrary chunked byte stream.
export class FrameReader {
private buf = new Uint8Array(0);
push(chunk: Uint8Array, onFrame: (typeId: number, body: Uint8Array) => void): void {
const merged = new Uint8Array(this.buf.length + chunk.length);
merged.set(this.buf);
merged.set(chunk, this.buf.length);
let off = 0;
while (merged.length - off >= 6) {
const view = new DataView(merged.buffer, merged.byteOffset + off);
const typeId = view.getUint16(0);
const len = view.getUint32(2);
if (len > 8 * 1024 * 1024) throw new Error(`Frame too large: ${len}`);
if (merged.length - off - 6 < len) break;
onFrame(typeId, merged.subarray(off + 6, off + 6 + len));
off += 6 + len;
}
this.buf = merged.slice(off);
}
}
+42
View File
@@ -0,0 +1,42 @@
// A reliable, ordered byte stream to a Mumble server (TLS in Electron, WebSocket proxy on the web).
export interface Transport {
send(bytes: Uint8Array): void;
close(): void;
onData: ((chunk: Uint8Array) => void) | null;
onClose: ((reason: string) => void) | null;
}
export interface ConnectTarget {
host: string;
port: number;
}
// Identity certificate the client presents; Mumble uses it to recognize registered users.
export interface Identity {
certPem: string;
keyPem: string;
fingerprint: string;
}
export interface CertDetails {
subject: string;
issuer: string;
validFrom: string;
validTo: string;
serialNumber: string;
fingerprint: string; // SHA-1, the hash Mumble uses for users
fingerprint256: string;
subjectAltName: string;
keyType: string;
keyBits: number | null;
ca: boolean;
selfSigned: boolean;
pem: string;
}
export interface ServerCertInfo {
fingerprint: string; // sha256 of the server certificate, colon-separated hex
authorized: boolean; // true if it chains to a trusted CA
authError: string | null; // why it is not trusted, e.g. DEPTH_ZERO_SELF_SIGNED_CERT
chain: CertDetails[]; // leaf first
}
+121
View File
@@ -0,0 +1,121 @@
// Mumble voice packets, as sent over UDP or tunneled through TCP (UDPTunnel).
// Two wire formats exist: the legacy one (header byte + Mumble varints) and, since 1.5,
// a protobuf one (type byte 0 + MumbleUDP.Audio). Servers pick per client version.
import { MumbleUDP } from './mumble-udp-pb.js';
export interface VoicePacket {
session: number; // sender (only in packets from the server)
target: number; // 0 normal talking, 1-30 whisper targets, 31 server loopback; context when received
frame: number; // sequence number, in 10 ms audio frames
opus: Uint8Array;
last: boolean; // end of this transmission
}
const LEGACY_OPUS = 4;
const PROTOBUF_AUDIO = 0;
// ─── Mumble varint (PacketDataStream) ─────────────────────────────────────────
export function writeVarint(out: number[], value: number): void {
if (!Number.isInteger(value) || value < 0) throw new Error(`Unsupported varint ${value}`);
if (value < 0x80) out.push(value);
else if (value < 0x4000) out.push(0x80 | (value >> 8), value & 0xff);
else if (value < 0x200000) out.push(0xc0 | (value >> 16), (value >> 8) & 0xff, value & 0xff);
else if (value < 0x10000000) out.push(0xe0 | (value >> 24), (value >> 16) & 0xff, (value >> 8) & 0xff, value & 0xff);
else if (value < 0x100000000) out.push(0xf0, (value >>> 24) & 0xff, (value >> 16) & 0xff, (value >> 8) & 0xff, value & 0xff);
else {
const hi = Math.floor(value / 0x100000000), lo = value >>> 0;
out.push(0xf4, (hi >>> 24) & 0xff, (hi >> 16) & 0xff, (hi >> 8) & 0xff, hi & 0xff,
(lo >>> 24) & 0xff, (lo >> 16) & 0xff, (lo >> 8) & 0xff, lo & 0xff);
}
}
export function readVarint(buf: Uint8Array, pos: number): [value: number, next: number] {
const need = (n: number) => { if (pos + n > buf.length) throw new Error('Truncated varint'); };
need(1);
const b = buf[pos];
if ((b & 0x80) === 0) return [b, pos + 1];
if ((b & 0xc0) === 0x80) { need(2); return [((b & 0x3f) << 8) | buf[pos + 1], pos + 2]; }
if ((b & 0xe0) === 0xc0) { need(3); return [((b & 0x1f) << 16) | (buf[pos + 1] << 8) | buf[pos + 2], pos + 3]; }
if ((b & 0xf0) === 0xe0) { need(4); return [((b & 0x0f) << 24 | (buf[pos + 1] << 16) | (buf[pos + 2] << 8) | buf[pos + 3]) >>> 0, pos + 4]; }
const be32 = (p: number) => ((buf[p] << 24) | (buf[p + 1] << 16) | (buf[p + 2] << 8) | buf[p + 3]) >>> 0;
if ((b & 0xfc) === 0xf0) { need(5); return [be32(pos + 1), pos + 5]; }
if ((b & 0xfc) === 0xf4) { need(9); return [be32(pos + 1) * 0x100000000 + be32(pos + 5), pos + 9]; }
// Negative numbers (0xf8, 0xfc prefixes) never occur in audio headers
throw new Error('Unsupported varint prefix');
}
// ─── Encoding (client to server) ──────────────────────────────────────────────
export function encodeVoice(p: Omit<VoicePacket, 'session'>, protobuf: boolean): Uint8Array {
if (protobuf) {
const msg = MumbleUDP.Audio.fromObject({
target: p.target,
frame_number: p.frame,
opus_data: p.opus,
is_terminator: p.last
});
const body: Uint8Array = MumbleUDP.Audio.encode(msg).finish();
const out = new Uint8Array(body.length + 1);
out[0] = PROTOBUF_AUDIO;
out.set(body, 1);
return out;
}
if (p.opus.length >= 0x2000) throw new Error('Opus frame too large');
const head: number[] = [(LEGACY_OPUS << 5) | (p.target & 0x1f)];
writeVarint(head, p.frame);
writeVarint(head, p.opus.length | (p.last ? 0x2000 : 0));
const out = new Uint8Array(head.length + p.opus.length);
out.set(head);
out.set(p.opus, head.length);
return out;
}
// ─── Decoding (server to client) ──────────────────────────────────────────────
// Returns null for pings, other codecs and malformed packets.
export function decodeVoice(buf: Uint8Array): VoicePacket | null {
if (!buf.length) return null;
try {
if (buf[0] === PROTOBUF_AUDIO) {
const m = MumbleUDP.Audio.toObject(MumbleUDP.Audio.decode(buf.subarray(1)), { longs: Number, defaults: true });
return {
session: m.sender_session,
target: m.context ?? m.target ?? 0,
frame: m.frame_number,
opus: m.opus_data instanceof Uint8Array ? m.opus_data : new Uint8Array(m.opus_data ?? []),
last: !!m.is_terminator
};
}
if (buf[0] >> 5 !== LEGACY_OPUS) return null;
const target = buf[0] & 0x1f;
let pos = 1, session, frame, size;
[session, pos] = readVarint(buf, pos);
[frame, pos] = readVarint(buf, pos);
[size, pos] = readVarint(buf, pos);
const len = size & 0x1fff;
if (pos + len > buf.length) return null;
return { session, target, frame, opus: buf.slice(pos, pos + len), last: (size & 0x2000) !== 0 };
} catch {
return null;
}
}
// For tests and the server-side view: a client packet as the server would relay it
export function relayAsServer(clientPacket: Uint8Array, session: number, protobuf: boolean): Uint8Array {
if (protobuf) {
const m = MumbleUDP.Audio.decode(clientPacket.subarray(1));
m.sender_session = session;
m.context = 0;
const body: Uint8Array = MumbleUDP.Audio.encode(m).finish();
const out = new Uint8Array(body.length + 1);
out.set(body, 1);
return out;
}
const head: number[] = [clientPacket[0] & 0xe0];
writeVarint(head, session);
const out = new Uint8Array(head.length + clientPacket.length - 1);
out.set(head);
out.set(clientPacket.subarray(1), head.length);
return out;
}
+86
View File
@@ -0,0 +1,86 @@
import { session } from './session.svelte.ts';
import { ui } from './ui.svelte.ts';
import { store } from './settings.svelte.ts';
import { voice, userKey } from './audio/voice.svelte.ts';
import type { MenuItem } from './menu.svelte.ts';
import type { Channel, User } from '../core/client.ts';
// Right-click menu for a channel in the tree
export function channelMenu(c: Channel): MenuItem[] {
const here = session.self?.channelId === c.id;
return [
{ label: here ? 'You are here' : 'Join channel', action: () => session.joinChannel(c.id), disabled: here || !c.canEnter },
{ label: 'Open chat', action: () => { session.showChannel(c.id, 'chat'); ui.panelOpen = true; } },
{ label: 'Show description', action: () => { session.showChannel(c.id, 'info'); ui.panelOpen = true; } },
{ separator: true, label: '' },
{ label: session.isExpanded(c.id) ? 'Collapse' : 'Expand', action: () => session.toggleExpanded(c.id), disabled: c.id === 0 },
{ label: 'Expand all', action: () => session.setAllExpanded(true) },
{ label: 'Collapse all', action: () => session.setAllExpanded(false) },
{ separator: true, label: '' },
{ label: 'Copy channel name', action: () => navigator.clipboard.writeText(c.name || 'Root') }
];
}
// Right-click menu for a user. Moderation entries are always offered; the server decides
// whether we may use them and answers with a permission denial otherwise.
export function userMenu(u: User): MenuItem[] {
const self = session.self;
const isSelf = u.session === self?.session;
const items: MenuItem[] = [
{ label: 'View profile', action: () => { session.showUser(u.session); ui.panelOpen = true; } },
{ label: 'Information', action: () => (ui.userInfo = u.session) }
];
if (isSelf) {
items.push(
{ label: 'Mute', checked: u.selfMute, action: () => session.setSelfMute(!u.selfMute) },
{ label: 'Deafen', checked: u.selfDeaf, action: () => session.setSelfDeaf(!u.selfDeaf) },
{ label: 'Change name...', action: () => changeNamePrompt(u) }
);
if (u.userId == null) items.push({ label: 'Register on this server', action: () => session.register(u.session) });
} else {
items.push(
{ label: 'Send message', action: () => session.selectView(`dm:${u.session}`) },
{ label: 'Mute for me', checked: !!voice.settings.localMutes[userKey(u)], action: () => voice.setLocalMute(u, !voice.settings.localMutes[userKey(u)]) },
{ label: 'Set nickname...', action: () => nicknamePrompt(u) }
);
if (self && u.channelId !== self.channelId) {
items.push({ label: 'Move to my channel', action: () => session.moveUser(u.session, self.channelId) });
}
}
items.push(
{ separator: true, label: '' },
{ label: 'Server mute', checked: u.mute, action: () => session.setServerMute(u.session, !u.mute) },
{ label: 'Server deafen', checked: u.deaf, action: () => session.setServerDeaf(u.session, !u.deaf) },
{ label: 'Priority speaker', checked: u.prioritySpeaker, action: () => session.setPrioritySpeaker(u.session, !u.prioritySpeaker) }
);
if (!isSelf && u.userId == null) items.push({ label: 'Register user', action: () => session.register(u.session) });
if (!isSelf) {
items.push(
{ separator: true, label: '' },
{ label: 'Kick...', danger: true, action: () => (ui.prompt = { title: `Kick ${u.name}`, label: 'Reason (optional)', confirm: 'Kick', danger: true, onsubmit: r => session.kick(u.session, r) }) },
{ label: 'Ban...', danger: true, action: () => (ui.prompt = { title: `Ban ${u.name}`, label: 'Reason (optional)', confirm: 'Ban', danger: true, onsubmit: r => session.kick(u.session, r, true) }) }
);
}
items.push({ separator: true, label: '' }, { label: 'Copy name', action: () => navigator.clipboard.writeText(u.name) });
if (u.hash) items.push({ label: 'Copy certificate hash', action: () => navigator.clipboard.writeText(u.hash) });
return items;
}
export function changeNamePrompt(u: User): void {
ui.prompt = {
title: 'Change your name', label: 'New name', confirm: 'Reconnect with this name', initial: u.name,
text: u.userId != null
? 'You are registered here, so the server keeps your registered name. The new name applies on servers where you are a guest.'
: 'Mumble cannot rename you while connected, so mumh5 reconnects with the new name and remembers it for this server.',
onsubmit: name => session.changeName(name)
};
}
export function nicknamePrompt(u: User): void {
ui.prompt = {
title: `Nickname for ${u.name}`, label: 'Nickname (only you see it; empty to remove)', confirm: 'Save',
initial: store.nicknames[userKey(u)] ?? '',
onsubmit: nick => store.setNickname(userKey(u), nick)
};
}
+33
View File
@@ -0,0 +1,33 @@
/// <reference path="./worklet-env.d.ts" />
// Cuts microphone input into 10 ms mono frames (480 samples at 48 kHz) for the encoder,
// with the RMS level of each frame for the meter and voice activity detection.
const FRAME = 480;
class CaptureProcessor extends AudioWorkletProcessor {
private buf = new Float32Array(FRAME);
private fill = 0;
process(inputs: Float32Array[][]): boolean {
const input = inputs[0]?.[0];
if (!input) return true;
let i = 0;
while (i < input.length) {
const n = Math.min(FRAME - this.fill, input.length - i);
this.buf.set(input.subarray(i, i + n), this.fill);
this.fill += n;
i += n;
if (this.fill === FRAME) {
let sum = 0;
for (let k = 0; k < FRAME; k++) sum += this.buf[k] * this.buf[k];
const frame = this.buf;
this.port.postMessage({ frame, rms: Math.sqrt(sum / FRAME) }, [frame.buffer]);
this.buf = new Float32Array(FRAME);
this.fill = 0;
}
}
return true;
}
}
registerProcessor('mumh5-capture', CaptureProcessor);
+95
View File
@@ -0,0 +1,95 @@
/// <reference path="./worklet-env.d.ts" />
// Mixes decoded voice of all speakers. Each speaker has a queue that starts playing once
// `jitter` ms are buffered (absorbs network jitter), and re-buffers after running dry.
interface Speaker {
chunks: Float32Array[];
offset: number; // read position in chunks[0]
queued: number; // samples waiting
playing: boolean;
gain: number;
peak: number;
}
class PlaybackProcessor extends AudioWorkletProcessor {
private speakers = new Map<number, Speaker>();
private jitter = Math.round(sampleRate * 0.06);
private maxQueue = Math.round(sampleRate * 0.4);
private gains = new Map<number, number>();
private sinceReport = 0;
constructor() {
super();
this.port.onmessage = e => {
const m = e.data;
if (m.pcm) this.push(m.session, m.pcm);
else if (m.gain !== undefined) {
this.gains.set(m.session, m.gain);
const s = this.speakers.get(m.session);
if (s) s.gain = m.gain;
} else if (m.jitterMs !== undefined) this.jitter = Math.round(sampleRate * m.jitterMs / 1000);
else if (m.drop !== undefined) this.speakers.delete(m.drop);
else if (m.clear) this.speakers.clear();
};
}
private push(session: number, pcm: Float32Array) {
let s = this.speakers.get(session);
if (!s) {
s = { chunks: [], offset: 0, queued: 0, playing: false, gain: this.gains.get(session) ?? 1, peak: 0 };
this.speakers.set(session, s);
}
s.chunks.push(pcm);
s.queued += pcm.length;
// Too far behind (e.g. after a stall): drop the oldest audio to catch up
while (s.queued > this.maxQueue && s.chunks.length > 1) {
const c = s.chunks.shift()!;
s.queued -= c.length - s.offset;
s.offset = 0;
}
if (!s.playing && s.queued >= this.jitter) s.playing = true;
}
process(_inputs: Float32Array[][], outputs: Float32Array[][]): boolean {
const out = outputs[0];
const n = out[0].length;
const mix = out[0];
mix.fill(0);
for (const s of this.speakers.values()) {
if (!s.playing) continue;
let i = 0;
while (i < n && s.chunks.length) {
const c = s.chunks[0];
const take = Math.min(n - i, c.length - s.offset);
for (let k = 0; k < take; k++) {
const v = c[s.offset + k] * s.gain;
mix[i + k] += v;
const a = v < 0 ? -v : v;
if (a > s.peak) s.peak = a;
}
i += take;
s.offset += take;
s.queued -= take;
if (s.offset === c.length) { s.chunks.shift(); s.offset = 0; }
}
if (!s.chunks.length) s.playing = false; // ran dry: buffer again before resuming
}
// Soft clip so several loud speakers do not distort harshly
for (let i = 0; i < n; i++) {
const v = mix[i];
if (v > 1 || v < -1) mix[i] = Math.tanh(v);
}
for (let ch = 1; ch < out.length; ch++) out[ch].set(mix);
this.sinceReport += n;
if (this.sinceReport >= sampleRate / 10) {
const levels: Record<number, number> = {};
for (const [id, s] of this.speakers) { levels[id] = s.peak; s.peak = 0; }
this.port.postMessage({ levels });
this.sinceReport = 0;
}
return true;
}
}
registerProcessor('mumh5-playback', PlaybackProcessor);
+447
View File
@@ -0,0 +1,447 @@
import captureUrl from './capture.worklet.ts?worker&url';
import playbackUrl from './playback.worklet.ts?worker&url';
import { encodeVoice, decodeVoice } from '../../core/voice-packet.ts';
import type { MumbleClient } from '../../core/client.ts';
import { load, save } from '../storage.ts';
export type TransmitMode = 'vad' | 'ptt' | 'continuous';
export interface VoiceSettings {
inputDevice: string;
outputDevice: string;
mode: TransmitMode;
pttKey: string; // KeyboardEvent.code
vadThreshold: number; // dBFS; louder frames open the voice activity gate
vadHoldMs: number; // keep sending this long after the level drops
inputGain: number; // 0..3
outputVolume: number; // 0..2
echoCancellation: boolean;
noiseSuppression: boolean;
autoGainControl: boolean;
bitrate: number; // Opus bits per second
frameMs: 10 | 20 | 40 | 60;
jitterMs: number;
// Per-user local volume and mute, keyed by certificate hash (or name without one)
userVolumes: Record<string, number>;
localMutes: Record<string, boolean>;
}
const defaults: VoiceSettings = {
inputDevice: 'default',
outputDevice: 'default',
mode: 'vad',
pttKey: 'Backquote',
vadThreshold: -45,
vadHoldMs: 350,
inputGain: 1,
outputVolume: 1,
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
bitrate: 40000,
frameMs: 20,
jitterMs: 60,
userVolumes: {},
localMutes: {}
};
const SAMPLE_RATE = 48000;
const FRAME = 480; // 10 ms
// Rough per-packet cost of IP, TCP/TLS or UDP, and Mumble headers, for the bandwidth limit
const PACKET_OVERHEAD_BYTES = 60;
export const userKey = (u: { hash: string; name: string }) => u.hash || `name:${u.name}`;
export interface DeviceInfo { id: string; label: string }
class VoiceEngine {
settings = $state<VoiceSettings>(load('mumh5.voice', defaults));
inputDevices = $state<DeviceInfo[]>([]);
outputDevices = $state<DeviceInfo[]>([]);
running = $state(false);
error = $state('');
level = $state(-100); // current input level, dBFS
transmitting = $state(false);
pttDown = $state(false);
testing = $state(false); // hear yourself
talking = $state<Record<number, boolean>>({});
effectiveBitrate = $state(0);
stats = $state({ sent: 0, received: 0, decoded: 0, lost: 0 });
private ctx: AudioContext | null = null;
private stream: MediaStream | null = null;
private source: MediaStreamAudioSourceNode | null = null;
private inputGainNode: GainNode | null = null;
private outputGainNode: GainNode | null = null;
private capture: AudioWorkletNode | null = null;
private playback: AudioWorkletNode | null = null;
private encoder: AudioEncoder | null = null;
private decoders = new Map<number, { decoder: AudioDecoder; lastFrame: number; timer: ReturnType<typeof setTimeout> | null }>();
private client: MumbleClient | null = null;
private sessionGains = new Map<number, number>();
private unsubscribe: (() => void)[] = [];
private frameCounter = 0; // Mumble sequence, in 10 ms frames
private sampleTime = 0; // encoder timestamps, microseconds
private lastVoiceAt = 0; // for VAD hold
private open = false; // gate state
private stopping = false; // flushing the last packet of a transmission
private held: Uint8Array[] = [];
private levelRaw = -100;
private levelTimer: ReturnType<typeof setInterval> | null = null;
constructor() {
if (typeof window === 'undefined') return;
window.addEventListener('keydown', e => this.onKey(e, true));
window.addEventListener('keyup', e => this.onKey(e, false));
// Released keys are not reported while the window is in the background
window.addEventListener('blur', () => { this.pttDown = false; });
navigator.mediaDevices?.addEventListener('devicechange', () => this.refreshDevices());
}
save(): void {
save('mumh5.voice', $state.snapshot(this.settings));
}
update(patch: Partial<VoiceSettings>): void {
Object.assign(this.settings, patch);
this.save();
if ('inputGain' in patch && this.inputGainNode) this.inputGainNode.gain.value = this.settings.inputGain;
if ('outputVolume' in patch && this.outputGainNode) this.outputGainNode.gain.value = this.settings.outputVolume;
if ('jitterMs' in patch) this.playback?.port.postMessage({ jitterMs: this.settings.jitterMs });
if ('outputDevice' in patch) this.applyOutputDevice();
if ('bitrate' in patch || 'frameMs' in patch) this.configureEncoder();
if (['inputDevice', 'echoCancellation', 'noiseSuppression', 'autoGainControl'].some(k => k in patch)) this.restartInput();
}
// ─── Devices ────────────────────────────────────────────────────────────────
async refreshDevices(): Promise<void> {
if (!navigator.mediaDevices?.enumerateDevices) return;
const all = await navigator.mediaDevices.enumerateDevices();
const map = (kind: MediaDeviceKind) => all.filter(d => d.kind === kind && d.deviceId !== 'communications')
.map(d => ({ id: d.deviceId, label: d.label || (d.deviceId === 'default' ? 'System default' : 'Unnamed device') }));
this.inputDevices = map('audioinput');
this.outputDevices = map('audiooutput');
}
// ─── Lifecycle ──────────────────────────────────────────────────────────────
// Starts the audio graph; safe to call repeatedly
async start(): Promise<void> {
if (this.ctx) return;
this.error = '';
try {
if (typeof AudioEncoder === 'undefined' || !(await AudioEncoder.isConfigSupported(this.encoderConfig())).supported) {
throw new Error('This system cannot encode Opus audio');
}
const ctx = new AudioContext({ sampleRate: SAMPLE_RATE, latencyHint: 'interactive' });
this.ctx = ctx;
await ctx.audioWorklet.addModule(captureUrl);
await ctx.audioWorklet.addModule(playbackUrl);
this.outputGainNode = new GainNode(ctx, { gain: this.settings.outputVolume });
this.playback = new AudioWorkletNode(ctx, 'mumh5-playback', { numberOfInputs: 0, outputChannelCount: [2] });
this.playback.port.onmessage = e => this.onLevels(e.data.levels);
this.playback.port.postMessage({ jitterMs: this.settings.jitterMs });
for (const [s, g] of this.sessionGains) this.playback.port.postMessage({ session: s, gain: g });
this.playback.connect(this.outputGainNode).connect(ctx.destination);
this.inputGainNode = new GainNode(ctx, { gain: this.settings.inputGain });
this.capture = new AudioWorkletNode(ctx, 'mumh5-capture', { numberOfOutputs: 0 });
this.capture.port.onmessage = e => this.onFrame(e.data.frame, e.data.rms);
this.inputGainNode.connect(this.capture);
await this.applyOutputDevice();
await this.restartInput();
this.configureEncoder();
await ctx.resume();
this.levelTimer = setInterval(() => { this.level = this.levelRaw; }, 50);
this.running = true;
await this.refreshDevices();
} catch (e) {
this.error = (e as Error).message;
await this.stop();
}
}
async stop(): Promise<void> {
this.endTransmission();
if (this.levelTimer) clearInterval(this.levelTimer);
this.levelTimer = null;
this.stream?.getTracks().forEach(t => t.stop());
this.stream = null;
for (const d of this.decoders.values()) { try { d.decoder.close(); } catch { /* closed */ } }
this.decoders.clear();
try { this.encoder?.close(); } catch { /* closed */ }
this.encoder = null;
await this.ctx?.close().catch(() => {});
this.ctx = null;
this.capture = this.playback = null;
this.running = false;
this.transmitting = false;
this.talking = {};
this.level = -100;
}
private async restartInput(): Promise<void> {
if (!this.ctx || !this.inputGainNode) return;
this.source?.disconnect();
this.stream?.getTracks().forEach(t => t.stop());
const s = this.settings;
try {
this.stream = await navigator.mediaDevices.getUserMedia({
audio: {
deviceId: s.inputDevice && s.inputDevice !== 'default' ? { exact: s.inputDevice } : undefined,
echoCancellation: s.echoCancellation,
noiseSuppression: s.noiseSuppression,
autoGainControl: s.autoGainControl,
channelCount: 1,
sampleRate: SAMPLE_RATE
}
});
} catch (e) {
this.error = `Microphone unavailable: ${(e as Error).message}`;
return;
}
this.error = '';
this.source = this.ctx.createMediaStreamSource(this.stream);
this.source.connect(this.inputGainNode);
this.refreshDevices();
}
private async applyOutputDevice(): Promise<void> {
const ctx = this.ctx as (AudioContext & { setSinkId?: (id: string) => Promise<void> }) | null;
if (!ctx?.setSinkId) return;
try {
await ctx.setSinkId(this.settings.outputDevice === 'default' ? '' : this.settings.outputDevice);
} catch (e) {
this.error = `Output device unavailable: ${(e as Error).message}`;
}
}
// ─── Connection ─────────────────────────────────────────────────────────────
attach(client: MumbleClient): void {
this.unsubscribe.forEach(off => off());
this.client = client;
this.stats = { sent: 0, received: 0, decoded: 0, lost: 0 };
this.unsubscribe = [
client.on('voice', raw => { if (this.client === client) this.receive(raw); }),
client.on('userRemove', u => this.dropSession(u.session))
];
this.limitBitrate();
this.start();
}
detach(): void {
this.endTransmission();
this.unsubscribe.forEach(off => off());
this.unsubscribe = [];
this.client = null;
for (const s of [...this.decoders.keys()]) this.dropSession(s);
this.playback?.port.postMessage({ clear: true });
this.talking = {};
this.transmitting = false;
// Release the microphone while not connected (unless testing it)
if (!this.testing) this.stop();
}
// Opus bitrate that keeps bitrate plus packet overhead inside the server's per-user limit
private allowedBitrate(): number {
const max = this.client?.maxBandwidth ?? 0;
const overhead = PACKET_OVERHEAD_BYTES * 8 * (1000 / this.settings.frameMs);
return Math.min(this.settings.bitrate, max ? Math.max(8000, max - overhead) : Infinity);
}
private limitBitrate(): void {
if (this.allowedBitrate() !== this.effectiveBitrate) this.configureEncoder();
}
// ─── Sending ────────────────────────────────────────────────────────────────
private encoderConfig(): AudioEncoderConfig {
return {
codec: 'opus',
sampleRate: SAMPLE_RATE,
numberOfChannels: 1,
bitrate: this.effectiveBitrate || this.allowedBitrate(),
opus: { frameDuration: this.settings.frameMs * 1000, complexity: 10, useinbandfec: true, usedtx: false }
} as AudioEncoderConfig;
}
private configureEncoder(): void {
this.effectiveBitrate = this.allowedBitrate();
if (!this.ctx) return;
try { this.encoder?.close(); } catch { /* closed */ }
this.encoder = new AudioEncoder({
output: chunk => this.onEncoded(chunk),
error: e => { this.error = `Encoder: ${e.message}`; }
});
this.encoder.configure(this.encoderConfig());
}
private canTalk(): boolean {
const self = this.client?.self;
return !!self && this.client!.synced && !self.selfMute && !self.mute && !self.suppress && !self.selfDeaf && !self.deaf;
}
private onFrame(frame: Float32Array<ArrayBuffer>, rms: number): void {
const db = rms > 0 ? 20 * Math.log10(rms) : -100;
this.levelRaw = Math.max(-100, db);
const now = performance.now();
const s = this.settings;
if (this.testing) this.playback?.port.postMessage({ session: -1, pcm: frame.slice() });
let wanted: boolean;
if (s.mode === 'continuous') wanted = true;
else if (s.mode === 'ptt') wanted = this.pttDown;
else {
if (db > s.vadThreshold) this.lastVoiceAt = now;
wanted = now - this.lastVoiceAt < s.vadHoldMs;
}
wanted &&= this.canTalk() && !!this.encoder && this.encoder.state === 'configured';
if (wanted && !this.open) { this.open = true; this.transmitting = true; }
if (!wanted && this.open) { this.endTransmission(); return; }
if (!this.open) return;
const data = new AudioData({
format: 'f32-planar', sampleRate: SAMPLE_RATE, numberOfFrames: FRAME, numberOfChannels: 1,
timestamp: this.sampleTime, data: frame
});
this.sampleTime += 10000;
this.encoder!.encode(data);
data.close();
}
private onEncoded(chunk: EncodedAudioChunk): void {
const opus = new Uint8Array(chunk.byteLength);
chunk.copyTo(opus);
if (this.stopping) { this.held.push(opus); return; }
this.sendPacket(opus, false);
}
private sendPacket(opus: Uint8Array, last: boolean): void {
const client = this.client;
if (!client) return;
client.sendVoiceTunnel(encodeVoice({ target: 0, frame: this.frameCounter, opus, last }, client.protobufVoice));
this.frameCounter += this.settings.frameMs / 10;
this.stats.sent++;
}
// Flush what the encoder still holds and mark the final packet, so receivers end the stream cleanly
private endTransmission(): void {
if (!this.open) return;
this.open = false;
this.transmitting = false;
const enc = this.encoder;
if (!enc || enc.state !== 'configured') return;
this.stopping = true;
enc.flush().then(() => {
const held = this.held;
this.held = [];
this.stopping = false;
held.forEach((opus, i) => this.sendPacket(opus, i === held.length - 1));
if (!held.length) this.sendPacket(new Uint8Array(0), true);
}).catch(() => { this.stopping = false; this.held = []; });
}
private onKey(e: KeyboardEvent, down: boolean): void {
if (e.code !== this.settings.pttKey || e.repeat) return;
// Typing in a text field does not trigger push to talk
const t = e.target as HTMLElement | null;
if (down && t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
this.pttDown = down;
}
// ─── Receiving ──────────────────────────────────────────────────────────────
private receive(raw: Uint8Array): void {
const p = decodeVoice(raw);
if (!p) return;
this.stats.received++;
const self = this.client?.self;
if (!this.ctx || !this.playback || self?.selfDeaf || self?.deaf) return;
const user = this.client?.users.get(p.session);
if (user && this.settings.localMutes[userKey(user)]) return;
let d = this.decoders.get(p.session);
if (!d) {
const session = p.session;
const decoder = new AudioDecoder({
output: data => this.onDecoded(session, data),
error: () => this.dropSession(session)
});
decoder.configure({ codec: 'opus', sampleRate: SAMPLE_RATE, numberOfChannels: 1 });
d = { decoder, lastFrame: -1, timer: null };
this.decoders.set(session, d);
if (user) this.setSessionGain(session, this.settings.userVolumes[userKey(user)] ?? 1);
}
if (d.lastFrame >= 0 && p.frame > d.lastFrame + 6 && p.frame - d.lastFrame < 500) this.stats.lost++;
d.lastFrame = p.frame;
if (p.opus.length) {
d.decoder.decode(new EncodedAudioChunk({ type: 'key', timestamp: p.frame * 10000, data: p.opus }));
}
if (!this.talking[p.session]) this.talking[p.session] = true;
if (d.timer) clearTimeout(d.timer);
// Speaking ends with the terminator, or when packets stop arriving
d.timer = setTimeout(() => { delete this.talking[p.session]; }, p.last ? this.settings.jitterMs + 60 : 400);
}
private onDecoded(session: number, data: AudioData): void {
const pcm = new Float32Array(data.numberOfFrames);
data.copyTo(pcm, { planeIndex: 0, format: 'f32-planar' });
data.close();
this.stats.decoded++;
this.playback?.port.postMessage({ session, pcm }, [pcm.buffer]);
}
private dropSession(session: number): void {
const d = this.decoders.get(session);
if (d) {
if (d.timer) clearTimeout(d.timer);
try { d.decoder.close(); } catch { /* closed */ }
this.decoders.delete(session);
}
this.playback?.port.postMessage({ drop: session });
delete this.talking[session];
}
private onLevels(_levels: Record<number, number>): void {
// Per-speaker peaks from the playback worklet, for future meters
}
// ─── Per-user controls ──────────────────────────────────────────────────────
private setSessionGain(session: number, gain: number): void {
this.sessionGains.set(session, gain);
this.playback?.port.postMessage({ session, gain });
}
setUserVolume(user: { session: number; hash: string; name: string }, volume: number): void {
this.settings.userVolumes[userKey(user)] = volume;
this.save();
this.setSessionGain(user.session, volume);
}
setLocalMute(user: { session: number; hash: string; name: string }, muted: boolean): void {
if (muted) this.settings.localMutes[userKey(user)] = true;
else delete this.settings.localMutes[userKey(user)];
this.save();
if (muted) this.dropSession(user.session);
}
setTesting(on: boolean): void {
this.testing = on;
if (on) this.start();
else {
this.playback?.port.postMessage({ drop: -1 });
if (!this.client) this.stop();
}
}
}
export const voice = new VoiceEngine();
+7
View File
@@ -0,0 +1,7 @@
// Minimal AudioWorklet global scope typings (not part of the DOM lib)
declare abstract class AudioWorkletProcessor {
readonly port: MessagePort;
abstract process(inputs: Float32Array[][], outputs: Float32Array[][], parameters: Record<string, Float32Array>): boolean;
}
declare function registerProcessor(name: string, ctor: new () => AudioWorkletProcessor): void;
declare const sampleRate: number;
+167
View File
@@ -0,0 +1,167 @@
import DOMPurify, { type Config } from 'dompurify';
export type EmbedKind = 'image' | 'video' | 'audio' | 'youtube';
export interface Embed { kind: EmbedKind; url: string; name: string; videoId?: string; start?: number }
// YouTube links: watch, youtu.be, shorts, embed, live, music, mobile
export function youtubeVideo(href: string): { id: string; start: number } | null {
let u: URL;
try { u = new URL(href); } catch { return null; }
const host = u.hostname.replace(/^(www|m|music)\./, '');
let id = '';
if (host === 'youtu.be') id = u.pathname.slice(1).split('/')[0];
else if (host === 'youtube.com' || host === 'youtube-nocookie.com') {
if (u.pathname === '/watch') id = u.searchParams.get('v') ?? '';
else id = /^\/(?:shorts|embed|live|v)\/([^/?#]+)/.exec(u.pathname)?.[1] ?? '';
}
if (!/^[A-Za-z0-9_-]{11}$/.test(id)) return null;
// t=90, t=1m30s, start=90
const t = u.searchParams.get('t') ?? u.searchParams.get('start') ?? '';
const m = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s?)?$/.exec(t);
const start = m ? (Number(m[1] ?? 0) * 3600 + Number(m[2] ?? 0) * 60 + Number(m[3] ?? 0)) : 0;
return { id, start };
}
const EXT: Record<string, EmbedKind> = {
png: 'image', jpg: 'image', jpeg: 'image', gif: 'image', webp: 'image', avif: 'image',
mp4: 'video', webm: 'video', mov: 'video', m4v: 'video',
mp3: 'audio', ogg: 'audio', opus: 'audio', wav: 'audio', flac: 'audio', m4a: 'audio'
};
export function mediaKind(name: string): EmbedKind | null {
const m = /\.([a-z0-9]+)$/i.exec(name);
return m ? EXT[m[1].toLowerCase()] ?? null : null;
}
const purifyConfig: Config & { RETURN_DOM_FRAGMENT: true } = {
ALLOWED_TAGS: ['a', 'b', 'i', 'u', 's', 'strong', 'em', 'br', 'p', 'span', 'div', 'ul', 'ol', 'li',
'blockquote', 'code', 'pre', 'img', 'h1', 'h2', 'h3', 'h4', 'table', 'thead', 'tbody', 'tr', 'td', 'th',
'hr', 'sub', 'sup', 'font'],
// style is reduced to a safe subset below; color is the <font color> attribute older clients send
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'style', 'color'],
ALLOW_DATA_ATTR: false,
RETURN_DOM_FRAGMENT: true
};
// Text styling that Mumble clients (Qt rich text) use; anything else is dropped
const STYLE_PROPS: Record<string, RegExp> = {
'color': /^(#[0-9a-f]{3,8}|rgba?\([\d\s.,%]+\)|[a-z]{3,20})$/i,
'background-color': /^(#[0-9a-f]{3,8}|rgba?\([\d\s.,%]+\)|[a-z]{3,20})$/i,
'font-weight': /^(normal|bold|bolder|lighter|[1-9]00)$/i,
'font-style': /^(normal|italic|oblique)$/i,
'text-decoration': /^(none|underline|line-through|overline)( (underline|line-through|overline))*$/i,
'font-size': /^(\d{1,2}(\.\d+)?(pt|px|em|%)|small|medium|large|x-large|xx-large|smaller|larger)$/i,
'text-align': /^(left|right|center|justify)$/i
};
// rgb(1, 2, 3) -> #010203, which every Mumble client understands
function normalizeColor(v: string): string {
const m = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i.exec(v);
return m ? '#' + m.slice(1, 4).map(n => Math.min(255, Number(n)).toString(16).padStart(2, '0')).join('') : v;
}
export function cleanStyle(style: string): string {
const out: string[] = [];
for (const decl of style.split(';')) {
const i = decl.indexOf(':');
if (i < 0) continue;
const prop = decl.slice(0, i).trim().toLowerCase();
let value = decl.slice(i + 1).trim().replace(/\s*!important$/i, '');
if (!STYLE_PROPS[prop]?.test(value)) continue;
if (prop.endsWith('color')) value = normalizeColor(value);
out.push(`${prop}:${value}`);
}
return out.join(';');
}
DOMPurify.addHook('afterSanitizeAttributes', node => {
const el = node as Element;
if (el.hasAttribute('style')) {
const clean = cleanStyle(el.getAttribute('style') ?? '');
if (clean) el.setAttribute('style', clean);
else el.removeAttribute('style');
}
if (el.hasAttribute('color') && !STYLE_PROPS.color.test(el.getAttribute('color') ?? '')) el.removeAttribute('color');
if (el.tagName === 'A') {
const href = el.getAttribute('href') ?? '';
if (!/^https?:\/\//i.test(href)) el.removeAttribute('href');
el.setAttribute('target', '_blank');
el.setAttribute('rel', 'noopener noreferrer');
}
// Only inline data: images; remote images would leak our IP to arbitrary hosts
if (el.tagName === 'IMG' && !/^data:image\/(png|jpe?g|gif|webp);/i.test(el.getAttribute('src') ?? '')) {
el.replaceWith(document.createTextNode('[image]'));
}
});
// Sanitize an incoming Mumble HTML message and pull out media links from trusted hosts.
export function renderIncoming(html: string, trustedHosts: string[]): { html: string; embeds: Embed[] } {
const frag = DOMPurify.sanitize(html, purifyConfig);
const embeds: Embed[] = [];
for (const a of frag.querySelectorAll('a[href]')) {
const yt = youtubeVideo(a.getAttribute('href')!);
if (yt) {
if (!embeds.some(e => e.videoId === yt.id) && embeds.filter(e => e.kind === 'youtube').length < 3) {
embeds.push({ kind: 'youtube', url: a.getAttribute('href')!, name: a.textContent ?? '', videoId: yt.id, start: yt.start });
}
continue;
}
let url: URL;
try { url = new URL(a.getAttribute('href')!); } catch { continue; }
if (!trustedHosts.includes(url.host.toLowerCase())) continue;
const name = decodeURIComponent(url.pathname.split('/').pop() ?? '');
const kind = mediaKind(name);
if (kind) embeds.push({ kind, url: url.href, name });
}
// The inline thumbnail is only there for vanilla clients; we show the real media instead
if (embeds.some(e => e.kind !== 'youtube')) frag.querySelectorAll('img').forEach(img => img.remove());
const div = document.createElement('div');
div.append(frag);
return { html: div.innerHTML.replace(/^(<br\s*\/?>)+|(<br\s*\/?>)+$/g, ''), embeds };
}
export function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// Plain composer text to the HTML Mumble clients expect: escaped, links clickable, newlines as <br/>.
// Output must stay well-formed XML: Murmur parses long messages as XML and denies them otherwise.
export function textToHtml(text: string): string {
return escapeHtml(text)
.replace(/https?:\/\/[^\s<]+[^\s<.,:;"')\]]/g, url => `<a href="${url}">${url}</a>`)
.replace(/\r?\n/g, '<br/>');
}
export function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1048576) return `${(n / 1024).toFixed(1)} KB`;
if (n < 1073741824) return `${(n / 1048576).toFixed(1)} MB`;
return `${(n / 1073741824).toFixed(2)} GB`;
}
// ─── Outgoing rich text (comments) ────────────────────────────────────────────
const VOID_TAGS = new Set(['br', 'hr', 'img']);
const OUT_ATTRS = new Set(['href', 'src', 'alt', 'title', 'style', 'color']);
const escText = (t: string) => t.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const escAttr = (t: string) => escText(t).replace(/"/g, '&quot;');
function serialize(node: Node): string {
if (node.nodeType === Node.TEXT_NODE) return escText(node.textContent ?? '');
if (node.nodeType !== Node.ELEMENT_NODE) return '';
const el = node as Element;
const tag = el.tagName.toLowerCase();
const attrs = [...el.attributes].filter(a => OUT_ATTRS.has(a.name)).map(a => ` ${a.name}="${escAttr(a.value)}"`).join('');
if (VOID_TAGS.has(tag)) return `<${tag}${attrs}/>`;
return `<${tag}${attrs}>${[...el.childNodes].map(serialize).join('')}</${tag}>`;
}
// Sanitized, well-formed XHTML that Murmur's XML check accepts and Mumble clients render
export function toMumbleHtml(html: string): string {
const frag = DOMPurify.sanitize(html, purifyConfig);
return [...frag.childNodes].map(serialize).join('')
.replace(/(<p><br\/><\/p>)+$/, '')
.replace(/^(<br\/>)+|(<br\/>)+$/g, '')
.trim();
}
+30
View File
@@ -0,0 +1,30 @@
// SVG path data (24x24 viewBox, square caps) shared by <Icon> and the tray icon
export const ICON_PATHS: Record<string, string> = {
mic: 'M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3zM19 10v2a7 7 0 0 1-14 0v-2M12 19v3',
'mic-off': 'M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3zM19 10v2a7 7 0 0 1-14 0v-2M12 19v3M3 3l18 18',
headphones: 'M3 14v-2a9 9 0 0 1 18 0v2M21 14v7h-4v-7zM3 14v7h4v-7z',
'headphones-off': 'M3 14v-2a9 9 0 0 1 18 0v2M21 14v7h-4v-7zM3 14v7h4v-7zM3 3l18 18',
settings: 'M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6',
plus: 'M12 5v14M5 12h14',
paperclip: 'M21.4 11.1l-9.2 9.2a6 6 0 0 1-8.5-8.5l9.2-9.2a4 4 0 0 1 5.7 5.7l-9.2 9.2a2 2 0 0 1-2.8-2.8l8.5-8.5',
hash: 'M4 9h16M4 15h16M10 3L8 21M16 3l-2 18',
channel: 'M11 5L6 9H2v6h4l5 4zM15.5 8.5a5 5 0 0 1 0 7',
users: 'M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM23 21v-2a4 4 0 0 0-3-3.9M16 3.1a4 4 0 0 1 0 7.8',
menu: 'M3 6h18M3 12h18M3 18h18',
x: 'M18 6L6 18M6 6l12 12',
power: 'M18.4 6.6a9 9 0 1 1-12.8 0M12 2v10',
lock: 'M5 11h14v10H5zM8 11V7a4 4 0 0 1 8 0v4',
file: 'M14 2H6v20h12V8zM14 2v6h6',
send: 'M22 2L11 13M22 2l-7 20-4-9-9-4z',
message: 'M21 15H7l-4 4V3h18z',
trash: 'M3 6h18M8 6V3h8v3M19 6l-1 15H6L5 6',
server: 'M2 3h20v8H2zM2 13h20v8H2zM6 7h.01M6 17h.01',
shield: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z',
download: 'M12 3v12M7 10l5 5 5-5M4 21h16',
list: 'M9 6h12M9 12h12M9 18h12M4 6h.01M4 12h.01M4 18h.01',
'list-ordered': 'M10 6h11M10 12h11M10 18h11M4 4v4M3 18h3l-3 3h3M3 10h2l-2 3h2',
link: 'M10 14a4 4 0 0 0 6 0l3-3a4 4 0 0 0-6-6l-1 1M14 10a4 4 0 0 0-6 0l-3 3a4 4 0 0 0 6 6l1-1',
image: 'M3 3h18v18H3zM8.5 10a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zM21 15l-5-5L5 21',
code: 'M16 18l6-6-6-6M8 6l-6 6 6 6',
eraser: 'M20 20H7L3 16l10-10 7 7-6.5 7M6 11l7 7'
};
+27
View File
@@ -0,0 +1,27 @@
import { native, type IdentitySummary } from './native.ts';
// Client certificates kept by the desktop app's main process
class Identities {
list = $state<IdentitySummary[]>([]);
setupDone = $state(true);
loaded = $state(false);
async refresh(): Promise<void> {
if (!native) return;
const r = await native.identities.list();
this.list = r.identities;
this.setupDone = r.setupDone;
this.loaded = true;
}
get default(): IdentitySummary | undefined {
return this.list.find(i => i.isDefault) ?? this.list[0];
}
byId(id: string | undefined): IdentitySummary | undefined {
return this.list.find(i => i.id === id) ?? this.default;
}
}
export const identities = new Identities();
identities.refresh();
+25
View File
@@ -0,0 +1,25 @@
export interface MenuItem {
label: string;
action?: () => void;
danger?: boolean;
checked?: boolean;
disabled?: boolean;
separator?: boolean;
}
// One context menu for the whole app, opened at the pointer position
class MenuStore {
open = $state<{ x: number; y: number; items: MenuItem[] } | null>(null);
show(e: MouseEvent, items: MenuItem[]): void {
e.preventDefault();
e.stopPropagation();
this.open = { x: e.clientX, y: e.clientY, items };
}
close(): void {
this.open = null;
}
}
export const menu = new MenuStore();
+72
View File
@@ -0,0 +1,72 @@
import type { Transport, ServerCertInfo, CertDetails } from '../core/transport.ts';
interface NativeApi {
platformInfo(): Promise<{ os: string; osVersion: string }>;
describeCerts(ders: Uint8Array[]): Promise<CertDetails[]>;
updateTray(state: { icon: string; tooltip: string; connected: boolean; server: string; muted: boolean; deafened: boolean; mode: string }): void;
onTrayAction(fn: (action: string) => void): void;
identities: {
list(): Promise<{ identities: IdentitySummary[]; setupDone: boolean }>;
create(name: string, email: string): Promise<IdentitySummary>;
importP12(bytes: Uint8Array, password: string, name: string): Promise<IdentitySummary>;
findMumble(): Promise<{ name: string } | null>;
importMumble(name: string): Promise<IdentitySummary>;
exportP12(id: string, password: string): Promise<string | null>;
setDefault(id: string): Promise<void>;
rename(id: string, name: string): Promise<void>;
remove(id: string): Promise<void>;
finishSetup(): Promise<void>;
};
open(connId: string, host: string, port: number, identityId?: string): Promise<void>;
send(connId: string, bytes: Uint8Array): void;
close(connId: string): void;
on(event: 'secure' | 'data' | 'close', fn: (connId: string, ...args: any[]) => void): () => void;
}
export interface IdentitySummary {
id: string;
name: string;
fingerprint: string;
isDefault: boolean;
cert: CertDetails;
}
declare global {
interface Window { mumh5Native?: NativeApi }
}
export const native: NativeApi | null = typeof window !== 'undefined' ? window.mumh5Native ?? null : null;
// Transport backed by a TLS socket in the Electron main process.
// `secure` resolves once the TLS handshake is done, before any Mumble data is sent,
// so the caller can check the server certificate before sending a password.
export class ElectronTransport implements Transport {
onData: ((chunk: Uint8Array) => void) | null = null;
onClose: ((reason: string) => void) | null = null;
readonly secure: Promise<ServerCertInfo>;
private id = crypto.randomUUID();
private offs: (() => void)[] = [];
private api: NativeApi;
constructor(api: NativeApi, host: string, port: number, identityId?: string) {
this.api = api;
let resolveSecure!: (i: ServerCertInfo) => void;
let rejectSecure!: (e: Error) => void;
this.secure = new Promise((res, rej) => { resolveSecure = res; rejectSecure = rej; });
const id = this.id;
this.offs.push(
api.on('secure', (cid, info) => { if (cid === id) resolveSecure(info); }),
api.on('data', (cid, chunk) => { if (cid === id) this.onData?.(chunk); }),
api.on('close', (cid, reason) => {
if (cid !== id) return;
this.offs.forEach(off => off());
rejectSecure(new Error(reason));
this.onClose?.(reason);
})
);
api.open(id, host, port, identityId).catch(e => rejectSecure(e));
}
send(bytes: Uint8Array): void { this.api.send(this.id, bytes); }
close(): void { this.api.close(this.id); }
}
+678
View File
@@ -0,0 +1,678 @@
import { createCodec } from '../core/proto.ts';
import { MumbleClient, describeDenial, type Channel, type User, type TextMessage } from '../core/client.ts';
import { native, ElectronTransport } from './native.ts';
import type { ServerCertInfo } from '../core/transport.ts';
import { store, type SavedServer } from './settings.svelte.ts';
import { renderIncoming, textToHtml, escapeHtml, formatBytes, type Embed } from './html.ts';
import { uploadFile, makeThumbnail, makeInlineImage, fetchUploadInfo, checkFile, type UploadInfo } from './upload.ts';
import { voice } from './audio/voice.svelte.ts';
const codec = createCodec();
// A chat view is a channel ("ch:<id>") or a direct conversation with a user ("dm:<session>").
export type ViewKey = `ch:${number}` | `dm:${number}`;
export interface ChatEntry {
id: number;
kind: 'text' | 'system';
author: string;
authorSession: number | null;
self: boolean;
html: string;
embeds: Embed[];
time: number;
}
export interface UploadJob {
id: number;
key: string;
name: string;
progress: number;
error: string;
}
export interface CertPrompt {
server: SavedServer;
hostPort: string;
fingerprint: string;
previous: string;
}
// Right-hand panel: member list, a channel (description preview or its side chat), or a user profile
export type Panel =
| { kind: 'members' }
| { kind: 'channel'; id: number; tab: 'info' | 'chat' }
| { kind: 'user'; session: number };
let nextId = 1;
export class Session {
status = $state<'idle' | 'connecting' | 'connected'>('idle');
error = $state('');
server = $state<SavedServer | null>(null);
// Bumped on every client state change; views read it to re-derive from the client's maps
tick = $state(0);
view = $state<ViewKey>('ch:0');
panel = $state<Panel>({ kind: 'members' });
logs = $state<Record<string, ChatEntry[]>>({});
unread = $state<Record<string, number>>({});
uploads = $state<UploadJob[]>([]);
certPrompt = $state<CertPrompt | null>(null);
rtt = $state(0);
// TLS details of the current server, for the certificate viewer and the trusted badge
serverCert = $state<ServerCertInfo | null>(null);
uploadInfo = $state<UploadInfo | null>(null);
// Channel tree expansion the user chose; channels not in here follow the default
expanded = $state<Record<number, boolean>>({});
client: MumbleClient | null = null;
// Incremented per connect so a slow, superseded attempt can tell it is stale
private attempt = 0;
private uploadInfoFor = '';
// Blob hashes already requested, so a preview does not ask the server repeatedly
private requested = new Set<string>();
// Last message with an inline image, resent as a plain link if the server refuses it
private lastImageSend: { key: string; fallback: string; at: number } | null = null;
// Channel we asked to move to; the chat view switches when the server confirms it
private pendingJoin: number | null = null;
// ─── Derived helpers ────────────────────────────────────────────────────────
// The client mutates its objects in place. These return shallow copies and read `tick`,
// so every state change hands Svelte new objects and dependent views update.
get self(): User | null {
void this.tick;
const u = this.client?.self;
return u ? { ...u } : null;
}
channel(id: number): Channel | undefined {
void this.tick;
const c = this.client?.channels.get(id);
return c && { ...c };
}
user(session: number): User | undefined {
void this.tick;
const u = this.client?.users.get(session);
return u && { ...u };
}
childrenOf(parent: number | null): Channel[] {
void this.tick;
if (!this.client) return [];
return [...this.client.channels.values()]
.filter(c => (parent === null ? c.id === 0 : c.parent === parent && c.id !== 0))
.sort((a, b) => a.position - b.position || a.name.localeCompare(b.name))
.map(c => ({ ...c }));
}
usersIn(channelId: number): User[] {
void this.tick;
if (!this.client) return [];
return [...this.client.users.values()]
.filter(u => u.channelId === channelId)
.sort((a, b) => a.name.localeCompare(b.name))
.map(u => ({ ...u }));
}
allUsers(): User[] {
void this.tick;
return this.client ? [...this.client.users.values()].sort((a, b) => a.name.localeCompare(b.name)).map(u => ({ ...u })) : [];
}
// Users in a channel and all of its subchannels
subtreeUserCount(id: number): number {
void this.tick;
const client = this.client;
if (!client) return 0;
const ids = new Set([id]);
let grew = true;
while (grew) {
grew = false;
for (const c of client.channels.values()) {
if (c.parent != null && ids.has(c.parent) && !ids.has(c.id)) { ids.add(c.id); grew = true; }
}
}
let n = 0;
for (const u of client.users.values()) if (ids.has(u.channelId)) n++;
return n;
}
// Collapsed by default; channels with people in them open up, like the desktop client
isExpanded(id: number): boolean {
if (id === 0) return true;
return this.expanded[id] ?? this.subtreeUserCount(id) > 0;
}
toggleExpanded(id: number): void {
this.expanded[id] = !this.isExpanded(id);
}
setAllExpanded(open: boolean): void {
const all: Record<number, boolean> = {};
for (const id of this.client?.channels.keys() ?? []) all[id] = open;
this.expanded = all;
}
viewTitle(key: string = this.view): string {
const [kind, id] = key.split(':');
if (kind === 'ch') return this.channel(Number(id))?.name || 'Root';
return this.user(Number(id))?.name ?? 'Offline user';
}
// Keys whose chat is on screen right now (main view and an open side chat)
private visibleKeys(): string[] {
const keys: string[] = [this.view];
if (this.panel.kind === 'channel' && this.panel.tab === 'chat') keys.push(`ch:${this.panel.id}`);
return keys;
}
// ─── Connection ─────────────────────────────────────────────────────────────
async connect(server: SavedServer): Promise<void> {
this.disconnect();
const attempt = ++this.attempt;
this.server = server;
this.error = '';
this.logs = {};
this.unread = {};
this.expanded = {};
this.panel = { kind: 'members' };
this.requested.clear();
this.serverCert = null;
this.status = 'connecting';
if (!native) {
this.fail('Connecting needs the desktop app for now. The web version will connect through a proxy.');
return;
}
const transport = new ElectronTransport(native, server.host, server.port, server.identityId);
let info;
try {
info = await transport.secure;
} catch (e) {
if (attempt === this.attempt) this.fail((e as Error).message);
return;
}
if (attempt !== this.attempt) return transport.close();
this.serverCert = info;
// Trust on first use: pin the certificate, and stop before sending credentials if it changed
const hostPort = `${server.host.toLowerCase()}:${server.port}`;
const pinned = store.pins[hostPort];
if (!pinned) {
store.pin(hostPort, info.fingerprint);
} else if (pinned !== info.fingerprint && !info.authorized) {
transport.close();
this.certPrompt = { server, hostPort, fingerprint: info.fingerprint, previous: pinned };
this.fail('The server certificate changed.');
return;
}
const client = new MumbleClient(codec);
this.client = client;
this.wire(client);
// TLS is up but the server may still never finish the login
setTimeout(() => { if (this.client === client && !client.synced) client.disconnect('Login timed out'); }, 20000);
const platform = await native.platformInfo();
if (attempt !== this.attempt) return transport.close();
client.connect(transport, {
username: server.username,
password: server.password,
os: platform.os,
osVersion: platform.osVersion
});
}
acceptCertificate(): void {
const p = this.certPrompt;
if (!p) return;
store.pin(p.hostPort, p.fingerprint);
this.certPrompt = null;
this.connect(p.server);
}
disconnect(): void {
this.attempt++;
const client = this.client;
this.client = null;
client?.disconnect();
sessions.onClosed(this);
this.status = 'idle';
this.tick++;
}
private fail(msg: string): void {
this.status = 'idle';
this.error = msg;
}
private wire(client: MumbleClient): void {
const live = () => this.client === client;
const bump = () => { if (live()) this.tick++; };
client.on('synced', () => {
if (!live()) return;
this.status = 'connected';
this.view = `ch:${client.self?.channelId ?? 0}`;
sessions.onSynced(this);
if (client.welcomeText) this.system(this.view, client.welcomeText, true);
bump();
});
client.on('channel', bump);
client.on('channelRemove', id => {
if (!live()) return;
if (this.panel.kind === 'channel' && this.panel.id === id) this.panel = { kind: 'members' };
bump();
});
client.on('ping', rtt => { if (live()) this.rtt = rtt; });
client.on('user', (user, changed, _actor, isNew) => {
if (!live()) return;
const self = client.self;
if (client.synced && self) {
if (isNew) {
if (user.channelId === self.channelId) this.system(`ch:${self.channelId}`, `${escapeHtml(user.name)} connected`);
} else if (changed.includes('userId') && user.userId != null) {
const who = user.session === self.session ? 'You are' : `${escapeHtml(user.name)} is`;
this.system(this.view, `${who} now registered on this server`);
} else if (changed.includes('channelId')) {
if (user.session === self.session) {
// Follow our own channel moves in the chat view (also from a DM when we asked to join)
if (this.view.startsWith('ch:') || this.pendingJoin === user.channelId) this.selectView(`ch:${user.channelId}`);
this.pendingJoin = null;
this.system(`ch:${user.channelId}`, `You joined ${escapeHtml(this.channel(user.channelId)?.name || 'Root')}`);
} else if (user.channelId === self.channelId) {
this.system(`ch:${self.channelId}`, `${escapeHtml(user.name)} joined the channel`);
}
}
}
bump();
});
client.on('userRemove', (user, actor, reason, ban) => {
if (!live()) return;
const self = client.self;
if (self && user.channelId === self.channelId) {
const by = actor != null && actor !== user.session ? client.users.get(actor)?.name : null;
const what = by ? `${ban ? 'was banned' : 'was kicked'} by ${escapeHtml(by)}` : 'disconnected';
this.system(`ch:${self.channelId}`, `${escapeHtml(user.name)} ${what}${reason ? `: ${escapeHtml(reason)}` : ''}`);
}
if (this.panel.kind === 'user' && this.panel.session === user.session) this.panel = { kind: 'members' };
bump();
});
client.on('text', msg => { if (live()) this.receive(client, msg); });
client.on('permissionDenied', d => {
if (!live()) return;
// An image preview can exceed what the server accepts; the link alone still gets through
const img = this.lastImageSend;
if (d.type === 4 && img && Date.now() - img.at < 5000) {
this.lastImageSend = null;
this.system(img.key, 'The server refused the image preview, sent the link only');
client.sendText(this.target(img.key), img.fallback);
return;
}
if (d.channelId != null && d.channelId === this.pendingJoin) this.pendingJoin = null;
const ch = d.channelId != null ? client.channels.get(d.channelId)?.name : undefined;
this.system(this.view, escapeHtml(describeDenial(d, ch)));
});
client.on('close', reason => {
if (this.client !== client) return;
this.client = null;
sessions.onClosed(this);
this.status = 'idle';
this.error = reason === 'Disconnected' ? '' : reason;
this.tick++;
});
}
// ─── Panels, descriptions, comments ─────────────────────────────────────────
showChannel(id: number, tab: 'info' | 'chat' = 'info'): void {
this.panel = { kind: 'channel', id, tab };
if (tab === 'chat') delete this.unread[`ch:${id}`];
this.loadDescription(id);
}
showUser(session: number): void {
this.panel = { kind: 'user', session };
this.loadComment(session);
}
closePanel(): void {
this.panel = { kind: 'members' };
}
// Long descriptions and comments are only announced by hash; fetch the text on demand
loadDescription(id: number): void {
const c = this.client?.channels.get(id);
if (!c?.descriptionHash || c.description) return;
const tag = `d:${id}:${[...c.descriptionHash].join(',')}`;
if (this.requested.has(tag)) return;
this.requested.add(tag);
this.client!.requestBlob({ descriptions: [id] });
}
loadComment(session: number): void {
const u = this.client?.users.get(session);
if (!u?.commentHash || u.comment) return;
const tag = `c:${session}:${[...u.commentHash].join(',')}`;
if (this.requested.has(tag)) return;
this.requested.add(tag);
this.client!.requestBlob({ comments: [session] });
}
register(session: number): void {
this.client?.register(session);
}
// Mumble has no rename while connected: reconnect under the new name (saved for this server)
changeName(name: string): void {
const server = this.server;
if (!server || !name.trim()) return;
const updated = { ...$state.snapshot(server), username: name.trim() };
store.upsertServer(updated);
this.connect(updated);
}
// html comes from the rich editor, already sanitized XHTML
setComment(html: string): void {
this.client?.setComment(html);
}
// Largest description or image message the server accepts (0: no limit)
get htmlLimit(): number {
void this.tick;
return this.client?.config.imageMessageLength ?? 131072;
}
// ─── Chat ───────────────────────────────────────────────────────────────────
private push(key: string, entry: Omit<ChatEntry, 'id' | 'time'>): void {
// Assign first, then push through the stored proxy; pushing onto the raw array would not be reactive
if (!this.logs[key]) this.logs[key] = [];
this.logs[key].push({ ...entry, id: nextId++, time: Date.now() });
if (sessions.active !== this || !this.visibleKeys().includes(key)) this.unread[key] = (this.unread[key] ?? 0) + 1;
}
private system(key: string, html: string, trusted = false): void {
const rendered = trusted ? renderIncoming(html, store.trustedHosts) : { html, embeds: [] };
this.push(key, { kind: 'system', author: '', authorSession: null, self: false, ...rendered });
}
private receive(client: MumbleClient, msg: TextMessage): void {
const self = client.self;
const sender = msg.actor != null ? client.users.get(msg.actor) : undefined;
let key: string;
if (msg.actor == null) key = this.view;
else if (msg.channels.length || msg.trees.length) key = `ch:${msg.channels[0] ?? self?.channelId ?? 0}`;
else key = `dm:${msg.actor}`;
const rendered = renderIncoming(msg.html, store.trustedHosts);
if (msg.actor == null) {
this.push(key, { kind: 'system', author: '', authorSession: null, self: false, ...rendered });
} else {
this.push(key, { kind: 'text', author: sender ? store.displayName(sender) : `#${msg.actor}`, authorSession: msg.actor, self: false, ...rendered });
}
}
// Opening a server marks what is on screen as read
markVisibleRead(): void {
for (const k of this.visibleKeys()) delete this.unread[k];
}
selectView(key: ViewKey): void {
this.view = key;
delete this.unread[key];
}
// The view follows once the server confirms the move (see the 'user' handler). Murmur
// silently drops own state changes sent too quickly (leaky bucket, about one per second),
// so an unconfirmed join is sent once more before giving up.
joinChannel(id: number): void {
const client = this.client;
if (!client) return;
// Joining a channel is where you want to talk: voice moves to this server
sessions.setVoice(this);
if (this.self?.channelId === id) {
this.selectView(`ch:${id}`);
return;
}
this.pendingJoin = id;
client.joinChannel(id);
const moved = () => this.client !== client || this.pendingJoin !== id || client.self?.channelId === id;
setTimeout(() => {
if (moved()) return;
client.joinChannel(id);
setTimeout(() => {
if (moved()) return;
this.pendingJoin = null;
this.system(this.view, `The server did not move you to ${escapeHtml(client.channels.get(id)?.name || 'Root')}. Try again in a moment.`);
}, 1500);
}, 1200);
}
private target(key: string): { channels?: number[]; users?: number[] } {
const [kind, id] = key.split(':');
return kind === 'ch' ? { channels: [Number(id)] } : { users: [Number(id)] };
}
// Send HTML to a view and echo it locally (the server does not echo our messages).
sendHtml(html: string, key: string = this.view): boolean {
const client = this.client;
if (!client || this.status !== 'connected') return false;
const limit = /<img/i.test(html) ? client.config.imageMessageLength : client.config.messageLength;
if (limit && html.length > limit) {
this.system(key, `Message too long for this server (${html.length} / ${limit} characters)`);
return false;
}
client.sendText(this.target(key), html);
const rendered = renderIncoming(html, store.trustedHosts);
this.push(key, { kind: 'text', author: client.self?.name ?? '', authorSession: client.session, self: true, ...rendered });
return true;
}
sendText(text: string, key: string = this.view): boolean {
const trimmed = text.trim();
return trimmed ? this.sendHtml(textToHtml(trimmed), key) : false;
}
async uploadFiles(files: File[], key: string = this.view): Promise<void> {
const { uploadHost, uploadKey, uploadExpiry, sendThumbnails } = store.settings;
if (!uploadHost || !uploadKey) return this.sendInlineImages(files, key);
let info: UploadInfo;
try {
info = await this.loadUploadInfo();
} catch (e) {
this.system(key, `File sharing unavailable: ${escapeHtml((e as Error).message)}`);
return;
}
for (const f of files) {
const problem = checkFile(info, f);
if (problem) this.system(key, escapeHtml(problem));
}
for (const file of files.filter(f => !checkFile(info, f))) {
const job = { id: nextId++, key, name: file.name, progress: 0, error: '' };
this.uploads.push(job);
const live = () => this.uploads.find(j => j.id === job.id)!;
try {
const res = await uploadFile(uploadHost, uploadKey, file, uploadExpiry, p => { live().progress = p; });
// Well-formed XHTML (<br/>, <img/>): Murmur rejects long messages that do not parse as XML
const link = `<a href="${escapeHtml(res.url)}">${escapeHtml(res.name)}</a> (${formatBytes(res.size)})`;
let html = link;
const cfg = this.client?.config;
if (sendThumbnails && cfg?.allowHtml !== false) {
const budget = Math.min(cfg?.imageMessageLength || 131072, 100000) - link.length - 64;
const thumb = await makeThumbnail(file, budget);
if (thumb) html += `<br/><img src="${thumb}"/>`;
}
if (html !== link) this.lastImageSend = { key, fallback: link, at: Date.now() };
this.sendHtml(html, key);
this.uploads = this.uploads.filter(j => j.id !== job.id);
} catch (e) {
live().error = (e as Error).message;
setTimeout(() => { this.uploads = this.uploads.filter(j => j.id !== job.id); }, 6000);
}
}
}
// Without file hosting: images go inline like a pasted picture in the desktop client,
// scaled to the server's image limit. Other files need an upload host.
private async sendInlineImages(files: File[], key: string): Promise<void> {
const cfg = this.client?.config;
for (const file of files) {
if (!file.type.startsWith('image/')) {
this.system(key, `${escapeHtml(file.name)}: sending files needs file hosting. Set up an upload host in Settings; without one only images can be sent.`);
continue;
}
if (cfg?.allowHtml === false) {
this.system(key, 'This server does not allow images in messages.');
return;
}
// 0 means no limit; stay reasonable anyway
const limit = cfg?.imageMessageLength || 1024 * 1024;
const uri = await makeInlineImage(file, limit - 64);
if (!uri) {
this.system(key, `${escapeHtml(file.name)}: could not make this image small enough for the server (limit ${formatBytes(limit)})`);
continue;
}
this.sendHtml(`<img src="${uri}"/>`, key);
}
}
// Cached per host+key; refreshed when settings change
async loadUploadInfo(): Promise<UploadInfo> {
const { uploadHost, uploadKey } = store.settings;
const id = `${uploadHost}|${uploadKey}`;
if (this.uploadInfo && this.uploadInfoFor === id) return this.uploadInfo;
const info = await fetchUploadInfo(uploadHost, uploadKey);
this.uploadInfo = info;
this.uploadInfoFor = id;
return info;
}
// ─── Voice state and moderation ─────────────────────────────────────────────
setSelfMute(v: boolean) { this.client?.setSelfMute(v); }
setSelfDeaf(v: boolean) { this.client?.setSelfDeaf(v); }
kick(session: number, reason: string, ban = false) { this.client?.kick(session, reason, ban); }
setServerMute(session: number, v: boolean) { this.client?.setUserState(session, { mute: v }); }
setServerDeaf(session: number, v: boolean) { this.client?.setUserState(session, { deaf: v }); }
setPrioritySpeaker(session: number, v: boolean) { this.client?.setUserState(session, { priority_speaker: v }); }
moveUser(session: number, channelId: number) { this.client?.setUserState(session, { channel_id: channelId }); }
}
// ─── Several servers at once ───────────────────────────────────────────────────
// Every server has its own Session. Text works on all of them; voice runs on one (like
// Discord): the server where you last joined a channel. On the others you are shown as
// deafened, so nobody expects you to hear them, and your state is restored when voice returns.
class Sessions {
list = $state<Session[]>([]);
activeId = $state<string | null>(null);
voiceId = $state<string | null>(null);
private idle = new Session();
// Self mute/deaf before we deafened a background server, to restore later
private saved = new Map<Session, { mute: boolean; deaf: boolean }>();
get active(): Session {
return this.list.find(s => s.server?.id === this.activeId) ?? this.idle;
}
byServer(id: string): Session | undefined {
return this.list.find(s => s.server?.id === id);
}
// Show a server, connecting if needed
open(server: SavedServer): void {
let s = this.byServer(server.id);
this.activeId = server.id;
s?.markVisibleRead();
if (!s) {
s = new Session();
this.list.push(s);
s = this.list[this.list.length - 1];
}
if (s.status === 'idle') s.connect(server);
}
select(id: string): void {
this.activeId = id;
this.active.markVisibleRead();
}
close(id: string): void {
const s = this.byServer(id);
if (!s) return;
s.disconnect();
this.list = this.list.filter(x => x !== s);
if (this.activeId === id) this.activeId = this.list.find(x => x.status !== 'idle')?.server?.id ?? null;
}
get voiceSession(): Session | undefined {
return this.list.find(s => s.server?.id === this.voiceId);
}
onSynced(s: Session): void {
if (!this.voiceId || !this.voiceSession?.client) this.setVoice(s, true);
else this.toBackground(s);
}
onClosed(s: Session): void {
this.saved.delete(s);
if (this.voiceId !== s.server?.id) return;
voice.detach();
// Hand voice to another connected server, if any
const next = this.list.find(x => x !== s && x.client && x.status === 'connected');
this.voiceId = null;
if (next) this.setVoice(next);
}
setVoice(s: Session, first = false): void {
const id = s.server?.id ?? null;
if (!id || (this.voiceId === id && !first)) return;
const prev = this.voiceSession;
if (prev && prev !== s) {
voice.detach();
this.toBackground(prev);
}
this.voiceId = id;
this.fromBackground(s);
if (s.client) voice.attach(s.client);
}
private toBackground(s: Session): void {
const self = s.client?.self;
if (!s.client || !self || this.saved.has(s)) return;
this.saved.set(s, { mute: self.selfMute, deaf: self.selfDeaf });
if (!self.selfDeaf) s.client.setSelfDeaf(true);
}
private fromBackground(s: Session): void {
const prev = this.saved.get(s);
this.saved.delete(s);
if (!prev || !s.client || prev.deaf) return;
s.client.send('UserState', { self_deaf: false, self_mute: prev.mute });
}
}
export const sessions = new Sessions();
// The session of the server on screen. Forwards to the active Session so components keep
// using `session.x`; methods are bound to the real instance so async work stays with its server.
export const session = new Proxy({} as Session, {
get(_t, prop) {
const a = sessions.active;
const v = Reflect.get(a, prop, a);
return typeof v === 'function' ? v.bind(a) : v;
},
set(_t, prop, value) {
return Reflect.set(sessions.active, prop, value, sessions.active);
}
});
+100
View File
@@ -0,0 +1,100 @@
import { load, loadList, save } from './storage.ts';
export interface SavedServer {
id: string;
label: string;
host: string;
port: number;
username: string;
password: string;
identityId?: string; // certificate to connect with; the default identity when unset
icon?: string; // custom rail icon, a small square data: image
}
export interface Settings {
uploadHost: string; // f0ckm base URL, e.g. https://f0ck.dev
uploadKey: string; // upload-only key from scripts/chat-upload-key.mjs
uploadExpiry: string; // 1h / 24h / 7d / 30d
embedHosts: string[]; // extra hosts whose media links render inline
sendThumbnails: boolean; // include a small inline image for vanilla Mumble clients
compact: boolean;
youtube: 'off' | 'click' | 'thumbnail'; // YouTube links: plain, click-to-play card, card with thumbnail
}
const defaults: Settings = {
uploadHost: '',
uploadKey: '',
uploadExpiry: '30d',
embedHosts: [],
sendThumbnails: true,
compact: false,
youtube: 'click'
};
class SettingsStore {
settings = $state<Settings>(load('mumh5.settings', defaults));
servers = $state<SavedServer[]>(loadList('mumh5.servers'));
// Pinned sha256 fingerprints of server certificates, keyed by host:port (trust on first use)
pins = $state<Record<string, string>>(load('mumh5.pins', {}));
// Local nicknames for other users, only visible to us; keyed by certificate hash (or name)
nicknames = $state<Record<string, string>>(load('mumh5.nicknames', {}));
saveSettings() { save('mumh5.settings', $state.snapshot(this.settings)); }
upsertServer(s: SavedServer) {
const i = this.servers.findIndex(x => x.id === s.id);
if (i === -1) this.servers.push(s);
else this.servers[i] = s;
save('mumh5.servers', $state.snapshot(this.servers));
}
// Reorder the server list (drag and drop in the rail)
moveServer(id: string, toIndex: number) {
const from = this.servers.findIndex(s => s.id === id);
if (from < 0) return;
const list = [...this.servers];
const [item] = list.splice(from, 1);
list.splice(Math.max(0, Math.min(list.length, toIndex)), 0, item);
this.servers = list;
save('mumh5.servers', $state.snapshot(this.servers));
}
setServerIcon(id: string, icon: string | undefined) {
const s = this.servers.find(x => x.id === id);
if (!s) return;
if (icon) s.icon = icon;
else delete s.icon;
save('mumh5.servers', $state.snapshot(this.servers));
}
removeServer(id: string) {
this.servers = this.servers.filter(s => s.id !== id);
save('mumh5.servers', $state.snapshot(this.servers));
}
setNickname(key: string, nick: string) {
if (nick.trim()) this.nicknames[key] = nick.trim();
else delete this.nicknames[key];
save('mumh5.nicknames', $state.snapshot(this.nicknames));
}
// Name to show for a user: our local nickname, else their name
displayName(u: { hash: string; name: string }): string {
return this.nicknames[u.hash || `name:${u.name}`] ?? u.name;
}
pin(hostPort: string, fingerprint: string) {
this.pins[hostPort] = fingerprint;
save('mumh5.pins', $state.snapshot(this.pins));
}
// Hosts whose media links are embedded: the upload host plus user-added ones.
// Other links stay plain links, so opening a chat never pings arbitrary servers.
get trustedHosts(): string[] {
const hosts = [...this.settings.embedHosts];
try { if (this.settings.uploadHost) hosts.push(new URL(this.settings.uploadHost).host); } catch { /* invalid URL */ }
return hosts.map(h => h.trim().toLowerCase()).filter(Boolean);
}
}
export const store = new SettingsStore();
+23
View File
@@ -0,0 +1,23 @@
// localStorage wrapper that tolerates missing or broken storage.
export function load<T>(key: string, fallback: T): T {
try {
const raw = localStorage.getItem(key);
return raw ? { ...fallback, ...JSON.parse(raw) } : fallback;
} catch {
return fallback;
}
}
export function loadList<T>(key: string): T[] {
try {
const raw = localStorage.getItem(key);
const v = raw ? JSON.parse(raw) : [];
return Array.isArray(v) ? v : [];
} catch {
return [];
}
}
export function save(key: string, value: unknown): void {
try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* storage unavailable */ }
}
+80
View File
@@ -0,0 +1,80 @@
import { native } from './native.ts';
import { ICON_PATHS } from './icons.ts';
import { session, sessions } from './session.svelte.ts';
import { voice, type TransmitMode } from './audio/voice.svelte.ts';
import { ui } from './ui.svelte.ts';
type TrayLook = 'offline' | 'idle' | 'talking' | 'muted' | 'deafened';
const LOOK: Record<TrayLook, { bg: string; glyph: string }> = {
offline: { bg: '#4a4c54', glyph: 'mic' },
idle: { bg: '#7c6cff', glyph: 'mic' },
talking: { bg: '#2fb863', glyph: 'mic' },
muted: { bg: '#e0443e', glyph: 'mic-off' },
deafened: { bg: '#e0443e', glyph: 'headphones-off' }
};
const cache = new Map<TrayLook, string>();
// Square tile with a white glyph, like the rail tiles; 64 px so the OS can scale it down sharply
function drawIcon(look: TrayLook): string {
const hit = cache.get(look);
if (hit) return hit;
const size = 64;
const canvas = document.createElement('canvas');
canvas.width = canvas.height = size;
const ctx = canvas.getContext('2d')!;
ctx.fillStyle = LOOK[look].bg;
ctx.fillRect(0, 0, size, size);
ctx.translate(8, 8);
ctx.scale(2, 2); // 24 unit glyph into 48 px
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2.6;
ctx.lineCap = 'square';
ctx.lineJoin = 'miter';
ctx.stroke(new Path2D(ICON_PATHS[LOOK[look].glyph]));
const url = canvas.toDataURL('image/png');
cache.set(look, url);
return url;
}
// Mirrors voice state into the system tray and handles its menu
export function startTray(): void {
if (!native) return;
const api = native;
$effect.root(() => {
$effect(() => {
const self = session.self;
const voiceSession = sessions.voiceSession;
const connected = session.status === 'connected';
const vSelf = voiceSession?.self;
const muted = !!(vSelf ? vSelf.selfMute || vSelf.mute : self?.selfMute);
const deafened = !!(vSelf ? vSelf.selfDeaf || vSelf.deaf : self?.selfDeaf);
const look: TrayLook = !connected && !voiceSession ? 'offline'
: deafened ? 'deafened' : muted ? 'muted' : voice.transmitting ? 'talking' : 'idle';
const server = voiceSession?.server?.label ?? session.server?.label ?? '';
const state = { offline: 'not connected', idle: 'connected', talking: 'talking', muted: 'muted', deafened: 'deafened' }[look];
api.updateTray({
icon: drawIcon(look),
tooltip: server ? `mumh5: ${state} on ${server}` : 'mumh5: not connected',
connected: !!(voiceSession?.status === 'connected' || connected),
server,
muted,
deafened,
mode: voice.settings.mode
});
});
});
api.onTrayAction(action => {
// Tray actions apply where voice runs
const target = sessions.voiceSession ?? sessions.active;
const self = target.self;
if (action === 'toggle-mute' && self) target.setSelfMute(!self.selfMute);
else if (action === 'toggle-deafen' && self) target.setSelfDeaf(!self.selfDeaf);
else if (action.startsWith('mode:')) voice.update({ mode: action.slice(5) as TransmitMode });
else if (action === 'settings') { ui.settingsTab = 'voice'; ui.settingsOpen = true; }
else if (action === 'disconnect' && target.server) sessions.close(target.server.id);
});
}
+35
View File
@@ -0,0 +1,35 @@
import type { CertDetails } from '../core/transport.ts';
export interface PromptRequest {
title: string;
label: string;
confirm: string;
danger?: boolean;
input?: 'text' | 'password' | 'none';
initial?: string;
text?: string;
onsubmit: (value: string) => void;
}
export interface CertRequest {
title: string;
chain: CertDetails[];
status?: { ok: boolean; text: string };
}
// App-wide dialogs that can be opened from anywhere (menus, panels)
class Ui {
prompt = $state<PromptRequest | null>(null);
cert = $state<CertRequest | null>(null);
settingsOpen = $state(false);
wizard = $state<'setup' | 'add' | null>(null);
// Quick audio popover from right-clicking mute (input) or deafen (output)
quickAudio = $state<{ kind: 'input' | 'output'; x: number; y: number } | null>(null);
settingsTab = $state<'voice' | 'files' | 'identities'>('voice');
// Session whose connection information is shown
userInfo = $state<number | null>(null);
// Set when a panel was opened on a layout where it is an overlay
panelOpen = $state(false);
}
export const ui = new Ui();
+118
View File
@@ -0,0 +1,118 @@
export interface UploadResult {
url: string;
name: string;
mime: string;
inline: boolean;
size: number;
expires_at: number;
delete_token: string;
}
export interface UploadInfo {
allowed_mimes: string[];
allowed_extensions: string[];
max_bytes: number;
}
const endpoint = (host: string) => host.replace(/\/+$/, '') + '/api/chat/upload';
// Allowed types and limits of the upload host; also serves as a key check.
export async function fetchUploadInfo(host: string, key: string): Promise<UploadInfo> {
const r = await fetch(endpoint(host), { headers: { 'X-API-Key': key } });
const body = await r.json().catch(() => null);
if (r.status === 401) throw new Error('Key rejected');
if (!r.ok || !body?.success) throw new Error(body?.msg || `Unexpected response (HTTP ${r.status})`);
return body;
}
// Client-side pre-check; the host verifies the real type again after upload
export function checkFile(info: UploadInfo, file: File): string | null {
const ext = /\.([a-z0-9]+)$/i.exec(file.name)?.[1]?.toLowerCase() ?? '';
const typeOk = file.type ? info.allowed_mimes.includes(file.type) : info.allowed_extensions.includes(ext);
if (!typeOk) return `${file.name}: file type not allowed on this upload host`;
if (file.size > info.max_bytes) return `${file.name}: too large (max ${Math.round(info.max_bytes / 1048576)} MB)`;
return null;
}
// Value for <input type=file accept>
export function acceptAttr(info: UploadInfo | null): string {
if (!info) return '';
return [...info.allowed_mimes, ...info.allowed_extensions.map(e => '.' + e)].join(',');
}
// Upload one file to a f0ckm chat-upload endpoint. XHR instead of fetch for upload progress.
export function uploadFile(
host: string, key: string, file: File, expiry: string, onProgress: (fraction: number) => void
): Promise<UploadResult> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', endpoint(host));
xhr.setRequestHeader('X-API-Key', key);
xhr.setRequestHeader('X-Filename', encodeURIComponent(file.name));
xhr.setRequestHeader('X-Upload-Expiry', expiry);
if (file.type) xhr.setRequestHeader('Content-Type', file.type);
xhr.upload.onprogress = e => { if (e.lengthComputable) onProgress(e.loaded / e.total); };
xhr.onerror = () => reject(new Error('Network error'));
xhr.onload = () => {
let body: any = null;
try { body = JSON.parse(xhr.responseText); } catch { /* not JSON */ }
if (xhr.status === 200 && body?.success) resolve(body);
else reject(new Error(body?.msg || `Upload failed (HTTP ${xhr.status})`));
};
xhr.send(file);
});
}
const readDataUrl = (file: Blob) => new Promise<string>((resolve, reject) => {
const r = new FileReader();
r.onload = () => resolve(String(r.result));
r.onerror = () => reject(r.error);
r.readAsDataURL(file);
});
// JPEG data URI of an image, scaled down until it fits in maxChars. Mumble clients render
// inline data: images, which is also how the desktop client sends pasted pictures.
export async function makeInlineImage(file: File, maxChars: number, sizes = [1280, 1024, 800, 640, 480, 320, 200, 120]): Promise<string | null> {
if (!/^image\/(png|jpeg|gif|webp|avif|bmp)$/.test(file.type) || maxChars < 2000) return null;
// Small enough as it is: send the original (keeps GIF animation and PNG transparency)
if (/^image\/(png|jpeg|gif|webp)$/.test(file.type) && Math.ceil(file.size / 3) * 4 + 40 < maxChars) {
return readDataUrl(file);
}
try {
const bmp = await createImageBitmap(file);
for (const size of sizes) {
const scale = Math.min(1, size / Math.max(bmp.width, bmp.height));
if (scale === 1 && size !== sizes[0] && Math.max(bmp.width, bmp.height) < size) continue;
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(bmp.width * scale));
canvas.height = Math.max(1, Math.round(bmp.height * scale));
canvas.getContext('2d')!.drawImage(bmp, 0, 0, canvas.width, canvas.height);
for (const q of [0.85, 0.7, 0.5]) {
const uri = canvas.toDataURL('image/jpeg', q);
if (uri.length < maxChars) return uri;
}
}
} catch { /* undecodable image */ }
return null;
}
// Small preview next to an f0ckm link, for vanilla Mumble clients
export function makeThumbnail(file: File, maxChars: number): Promise<string | null> {
return makeInlineImage(file, maxChars, [320, 200, 120]);
}
// Square server icon: center-cropped, scaled to 128x128, as a small WebP data URI
export async function makeServerIcon(file: File, size = 128): Promise<string> {
const bmp = await createImageBitmap(file);
const side = Math.min(bmp.width, bmp.height);
const canvas = document.createElement('canvas');
canvas.width = canvas.height = size;
const ctx = canvas.getContext('2d')!;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(bmp, (bmp.width - side) / 2, (bmp.height - side) / 2, side, side, 0, 0, size, size);
bmp.close();
const webp = canvas.toDataURL('image/webp', 0.85);
// Browsers without WebP encoding fall back to PNG
return webp.startsWith('data:image/webp') ? webp : canvas.toDataURL('image/png');
}
+7
View File
@@ -0,0 +1,7 @@
import { mount } from 'svelte';
import './app.css';
import App from './App.svelte';
import { startTray } from './lib/tray.svelte.ts';
mount(App, { target: document.getElementById('app')! });
startTray();
+19
View File
@@ -0,0 +1,19 @@
<script lang="ts">
let { name, size = 32, speaking = false }: { name: string; size?: number; speaking?: boolean } = $props();
const hue = $derived([...name].reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 7) % 360);
</script>
<span class="avatar" class:speaking style:width="{size}px" style:height="{size}px"
style:font-size="{Math.round(size * 0.45)}px" style:background="hsl({hue} 45% 38%)">
{(name.trim()[0] ?? '?').toUpperCase()}
</span>
<style>
.avatar {
flex: none; display: inline-flex; align-items: center; justify-content: center;
font-weight: 700; color: #fff; user-select: none; box-shadow: 0 0 0 2px transparent;
transition: box-shadow 80ms;
}
.speaking { box-shadow: 0 0 0 2px var(--speaking); }
</style>
+22
View File
@@ -0,0 +1,22 @@
<script lang="ts">
import Modal from './Modal.svelte';
import { session } from '../lib/session.svelte.ts';
const p = $derived(session.certPrompt!);
</script>
<Modal title="Server certificate changed" onclose={() => (session.certPrompt = null)} width={520}>
<p>The certificate of <strong>{p.hostPort}</strong> is different from the one it had before. This happens when a server is reinstalled, but it can also mean someone is intercepting the connection. Your password has not been sent.</p>
<p class="k">Previous</p><code>{p.previous}</code>
<p class="k">Now</p><code>{p.fingerprint}</code>
{#snippet footer()}
<button class="btn" onclick={() => (session.certPrompt = null)}>Cancel</button>
<button class="btn danger" onclick={() => session.acceptCertificate()}>Trust new certificate</button>
{/snippet}
</Modal>
<style>
p { color: var(--text-dim); }
.k { margin: 12px 0 4px; font-size: 12px; font-weight: 600; text-transform: uppercase; }
code { display: block; padding: 8px; background: var(--bg-0); font: 12px var(--mono); overflow-wrap: anywhere; }
</style>
+73
View File
@@ -0,0 +1,73 @@
<script lang="ts">
import Modal from './Modal.svelte';
import type { CertDetails } from '../core/transport.ts';
let { title, chain, status, onclose }:
{ title: string; chain: CertDetails[]; status?: { ok: boolean; text: string }; onclose: () => void } = $props();
let index = $state(0);
const cert = $derived(chain[index]);
// "CN=foo\nO=bar" -> [["CN","foo"],["O","bar"]]
const parts = (dn: string) => dn.split('\n').filter(Boolean).map(l => {
const i = l.indexOf('=');
return [l.slice(0, i), l.slice(i + 1)];
});
const names: Record<string, string> = { CN: 'Common name', O: 'Organization', OU: 'Unit', C: 'Country', ST: 'State', L: 'Locality', emailAddress: 'Email' };
const date = (iso: string) => new Date(iso).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' });
const expired = $derived(cert ? new Date(cert.validTo) < new Date() : false);
function copyPem() { navigator.clipboard.writeText(cert.pem); }
</script>
<Modal {title} {onclose} width={620}>
{#if status}<p class="status" class:ok={status.ok}>{status.text}</p>{/if}
{#if chain.length > 1}
<div class="tabs" role="tablist">
{#each chain as c, i (c.fingerprint256)}
<button role="tab" aria-selected={i === index} class:active={i === index} onclick={() => (index = i)}>
{parts(c.subject).find(p => p[0] === 'CN')?.[1] ?? `Certificate ${i + 1}`}
</button>
{/each}
</div>
{/if}
{#if cert}
<h3>Subject</h3>
<dl>{#each parts(cert.subject) as [k, v] (k + v)}<dt>{names[k] ?? k}</dt><dd>{v}</dd>{/each}</dl>
<h3>Issuer {#if cert.selfSigned}<span class="tag">self-signed</span>{/if}</h3>
<dl>{#each parts(cert.issuer) as [k, v] (k + v)}<dt>{names[k] ?? k}</dt><dd>{v}</dd>{/each}</dl>
<h3>Validity {#if expired}<span class="tag bad">expired</span>{/if}</h3>
<dl>
<dt>Not before</dt><dd>{date(cert.validFrom)}</dd>
<dt>Not after</dt><dd>{date(cert.validTo)}</dd>
</dl>
<h3>Details</h3>
<dl>
<dt>Public key</dt><dd>{cert.keyType.toUpperCase()}{cert.keyBits ? ` ${cert.keyBits} bit` : ''}</dd>
{#if cert.subjectAltName}<dt>Alt names</dt><dd>{cert.subjectAltName}</dd>{/if}
<dt>Serial</dt><dd class="mono">{cert.serialNumber}</dd>
<dt>SHA-1</dt><dd class="mono">{cert.fingerprint}</dd>
<dt>SHA-256</dt><dd class="mono">{cert.fingerprint256}</dd>
</dl>
{/if}
{#snippet footer()}
<button class="btn" onclick={copyPem}>Copy PEM</button>
<button class="btn primary" onclick={onclose}>Close</button>
{/snippet}
</Modal>
<style>
.status { margin: 8px 0 0; padding: 8px 10px; border-left: 3px solid var(--warn); background: var(--bg-0); font-size: 14px; }
.status.ok { border-color: var(--speaking); }
.tabs { display: flex; gap: 2px; margin-top: 12px; overflow-x: auto; }
.tabs button { padding: 6px 12px; background: var(--bg-3); color: var(--text-dim); white-space: nowrap; font-size: 13px; }
.tabs button.active { background: var(--accent); color: var(--accent-text); }
h3 { margin: 18px 0 6px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-dim); display: flex; align-items: center; gap: 8px; }
.tag { text-transform: none; letter-spacing: 0; font-weight: 600; padding: 0 6px; border: 1px solid var(--warn); color: var(--warn); }
.tag.bad { border-color: var(--danger); color: var(--danger); }
dl { display: grid; grid-template-columns: 120px 1fr; gap: 4px 12px; margin: 0; font-size: 14px; }
dt { color: var(--text-faint); }
dd { margin: 0; overflow-wrap: anywhere; user-select: text; }
.mono { font: 12px var(--mono); }
@media (max-width: 500px) { dl { grid-template-columns: 1fr; } dt { margin-top: 6px; } }
</style>
+116
View File
@@ -0,0 +1,116 @@
<script lang="ts">
import Icon from './Icon.svelte';
import Avatar from './Avatar.svelte';
import ChannelNode from './ChannelNode.svelte';
import { session } from '../lib/session.svelte.ts';
import { menu } from '../lib/menu.svelte.ts';
import { ui } from '../lib/ui.svelte.ts';
import { channelMenu, userMenu } from '../lib/actions.ts';
import { voice } from '../lib/audio/voice.svelte.ts';
import { store } from '../lib/settings.svelte.ts';
import type { Channel } from '../core/client.ts';
let { channel, depth = 0, onnavigate }: { channel: Channel; depth?: number; onnavigate: () => void } = $props();
const users = $derived(session.usersIn(channel.id));
const children = $derived(session.childrenOf(channel.id));
const expanded = $derived(session.isExpanded(channel.id));
const here = $derived(session.self?.channelId === channel.id);
const selected = $derived(session.panel.kind === 'channel' && session.panel.id === channel.id);
const unread = $derived(session.unread[`ch:${channel.id}`] ?? 0);
const total = $derived(session.subtreeUserCount(channel.id));
// Single click previews, double click acts. The single action waits briefly so a
// double click does not also flash the preview.
let timer: ReturnType<typeof setTimeout> | undefined;
function clickOrDouble(single: () => void, double: () => void) {
return {
onclick: () => { clearTimeout(timer); timer = setTimeout(single, 220); },
ondblclick: () => { clearTimeout(timer); double(); }
};
}
const channelClicks = clickOrDouble(
() => { session.showChannel(channel.id); ui.panelOpen = true; },
() => { session.joinChannel(channel.id); onnavigate(); }
);
</script>
<li>
<div class="row channel" class:here class:selected style:padding-left="{8 + depth * 14}px"
oncontextmenu={e => menu.show(e, channelMenu(channel))} role="presentation">
{#if children.length || users.length}
<button class="caret" class:collapsed={!expanded} onclick={() => session.toggleExpanded(channel.id)}
aria-label={expanded ? 'Collapse' : 'Expand'} aria-expanded={expanded}>
<svg width="10" height="10" viewBox="0 0 10 10"><path d="M2 3l3 4 3-4" fill="none" stroke="currentColor" stroke-width="1.6" /></svg>
</button>
{:else}
<span class="caret"></span>
{/if}
<button class="name" title="Click for details, double-click to join" {...channelClicks}>
<Icon name={channel.canEnter ? 'channel' : 'lock'} size={16} />
<span class="label">{channel.name || 'Root'}</span>
{#if !expanded && total > 0}<span class="count" title="{total} {total === 1 ? 'person' : 'people'} inside">{total}</span>{/if}
{#if unread}<span class="badge">{unread}</span>{/if}
</button>
</div>
{#if users.length && expanded}
<ul class="users">
{#each users as u (u.session)}
{@const isSelf = u.session === session.self?.session}
{@const clicks = clickOrDouble(
() => { session.showUser(u.session); ui.panelOpen = true; },
() => { if (!isSelf) { session.selectView(`dm:${u.session}`); onnavigate(); } }
)}
<li>
<button class="row user" class:self={isSelf} style:padding-left="{30 + depth * 14}px"
title={isSelf ? 'You' : `${store.displayName(u)} (double-click to message)`} {...clicks}
oncontextmenu={e => menu.show(e, userMenu(u))}>
<Avatar name={u.name} size={22} speaking={voice.talking[u.session] || (isSelf && voice.transmitting)} />
<span class="label" class:talking={voice.talking[u.session] || (isSelf && voice.transmitting)}>{store.displayName(u)}</span>
{#if u.userId != null}<span class="reg" title="Registered">R</span>{/if}
{#if u.comment || u.commentHash}<span class="state" title="Has a description"><Icon name="message" size={12} /></span>{/if}
{#if u.prioritySpeaker}<span class="state" title="Priority speaker"><Icon name="channel" size={14} /></span>{/if}
{#if u.recording}<span class="rec" title="Recording">REC</span>{/if}
{#if u.deaf || u.selfDeaf}
<span class="state" class:admin={u.deaf} title={u.deaf ? 'Deafened by server' : 'Deafened'}><Icon name="headphones-off" size={14} /></span>
{:else if u.mute || u.selfMute || u.suppress}
<span class="state" class:admin={u.mute || u.suppress} title={u.mute ? 'Muted by server' : u.suppress ? 'Suppressed' : 'Muted'}><Icon name="mic-off" size={14} /></span>
{/if}
</button>
</li>
{/each}
</ul>
{/if}
{#if children.length && expanded}
<ul>
{#each children as c (c.id)}
<ChannelNode channel={c} depth={depth + 1} {onnavigate} />
{/each}
</ul>
{/if}
</li>
<style>
ul { list-style: none; margin: 0; padding: 0; }
li { margin: 0; }
.row { display: flex; align-items: center; gap: 4px; width: 100%; min-height: 30px; padding-right: 8px; color: var(--text-dim); }
.channel:hover, .user:hover { background: var(--bg-hover); color: var(--text); }
.channel.selected { background: var(--bg-active); color: var(--text); }
.channel.here .label { color: var(--text); font-weight: 600; }
.channel.here { box-shadow: inset 3px 0 0 var(--speaking); }
.caret { width: 14px; flex: none; display: inline-flex; justify-content: center; color: var(--text-faint); }
.caret.collapsed svg { transform: rotate(-90deg); }
.name { flex: 1; min-width: 0; display: flex; align-items: center; gap: 6px; padding: 4px 0; text-align: left; color: inherit; user-select: none; }
.label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.count { font-size: 11px; color: var(--text-faint); border: 1px solid var(--line); padding: 0 5px; line-height: 16px; }
.badge { background: var(--danger); color: #fff; font-size: 11px; font-weight: 700; padding: 0 5px; line-height: 16px; }
.user { gap: 8px; text-align: left; font-size: 14px; user-select: none; }
.user.self .label { color: var(--text); }
.label.talking { color: var(--speaking); }
.reg { font-size: 10px; font-weight: 700; color: var(--accent-hover); border: 1px solid var(--accent); padding: 0 3px; line-height: 14px; }
.state { color: var(--text-faint); display: inline-flex; }
.state.admin { color: var(--danger); }
.rec { font-size: 10px; font-weight: 700; color: var(--danger); border: 1px solid var(--danger); padding: 0 3px; }
</style>
+58
View File
@@ -0,0 +1,58 @@
<script lang="ts">
import Icon from './Icon.svelte';
import ChatLog from './ChatLog.svelte';
import Composer from './Composer.svelte';
import { session } from '../lib/session.svelte.ts';
let { onmenu, onpanel, showMenu }: { onmenu: () => void; onpanel: () => void; showMenu: boolean } = $props();
const isDm = $derived(session.view.startsWith('dm:'));
const title = $derived(session.status === 'connected' ? session.viewTitle() : 'mumh5');
const topic = $derived.by(() => {
if (isDm || session.status !== 'connected') return '';
return (session.channel(Number(session.view.slice(3)))?.description ?? '').replace(/<[^>]*>/g, ' ').trim();
});
</script>
<section class="chat" aria-label="Chat">
<header>
{#if showMenu}<button class="icon-btn" onclick={onmenu} aria-label="Open channels"><Icon name="menu" /></button>{/if}
<Icon name={isDm ? 'message' : 'hash'} size={20} />
<h1>{title}</h1>
{#if topic}<span class="topic" title={topic}>{topic}</span>{/if}
<span class="grow"></span>
<button class="icon-btn" onclick={onpanel} title="Members"><Icon name="users" /></button>
</header>
<ChatLog key={session.view}>
{#snippet empty()}
<div class="empty">
{#if session.status === 'connected'}
<h2>{isDm ? `Direct messages with ${title}` : `Welcome to #${title}`}</h2>
<p>{isDm ? 'Messages here are only visible to the two of you.' : 'Messages go to everyone in this channel. Drop files here to share them.'}</p>
{:else if session.status === 'connecting'}
<h2>Connecting...</h2>
{:else}
<h2>mumh5</h2>
<p>Choose a server to get started.</p>
{/if}
</div>
{/snippet}
</ChatLog>
<Composer target={session.view} placeholder={session.status === 'connected' ? `Message ${isDm ? '@' : '#'}${title}` : 'Not connected'} />
</section>
<style>
.chat { position: relative; flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--bg-2); }
header { height: 52px; flex: none; display: flex; align-items: center; gap: 8px; padding: 0 8px 0 16px; border-bottom: 1px solid var(--line); color: var(--text-dim); }
h1 { flex: 0 1 auto; max-width: 60%; font-size: 16px; margin: 0; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.topic { flex: 1 1 0; font-size: 13px; padding-left: 10px; margin-left: 4px; border-left: 1px solid var(--line); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
.grow { flex: 1; }
.topic + .grow { flex: 0; }
/* On phones the channel name matters more than its description */
@media (max-width: 600px) { .topic { display: none; } h1 { max-width: none; } .topic + .grow { flex: 1; } }
.empty { padding: 32px 16px 8px; }
.empty h2 { margin: 0 0 4px; font-size: 24px; }
.empty p { margin: 0; color: var(--text-dim); }
</style>
+63
View File
@@ -0,0 +1,63 @@
<script lang="ts">
import { tick, type Snippet } from 'svelte';
import Icon from './Icon.svelte';
import Message from './Message.svelte';
import { session } from '../lib/session.svelte.ts';
// Message list for one chat (channel or DM), with drag and drop upload into that chat
let { key, empty }: { key: string; empty: Snippet } = $props();
const entries = $derived(session.logs[key] ?? []);
const title = $derived(session.viewTitle(key));
let list: HTMLDivElement;
let pinned = true; // stick to the bottom unless the user scrolled up
let dragging = $state(0);
$effect(() => {
void entries.length;
void key;
if (pinned) tick().then(() => list && (list.scrollTop = list.scrollHeight));
});
// Media loads after insertion and grows the list; keep the bottom pinned
function onload() { if (pinned) list.scrollTop = list.scrollHeight; }
function ondrop(e: DragEvent) {
e.preventDefault();
dragging = 0;
const files = [...(e.dataTransfer?.files ?? [])];
if (files.length && session.status === 'connected') session.uploadFiles(files, key);
}
const grouped = (i: number) => {
const a = entries[i - 1], b = entries[i];
return !!a && a.kind === 'text' && b.kind === 'text' && a.authorSession === b.authorSession && a.self === b.self && b.time - a.time < 5 * 60000;
};
</script>
<div class="wrap" role="log"
ondragenter={e => { if (e.dataTransfer?.types.includes('Files')) dragging++; }}
ondragleave={() => (dragging = Math.max(0, dragging - 1))}
ondragover={e => e.preventDefault()} {ondrop}>
<div class="list" bind:this={list} onscroll={() => (pinned = list.scrollHeight - list.scrollTop - list.clientHeight < 40)}
onloadcapture={onload} onloadedmetadatacapture={onload}>
{#if !entries.length}{@render empty()}{/if}
{#each entries as entry, i (entry.id)}
<Message {entry} grouped={grouped(i)} />
{/each}
<div class="end"></div>
</div>
{#if dragging}
<div class="drop"><div><Icon name="download" size={40} /><p>Drop to upload to {key.startsWith('dm:') ? '@' : '#'}{title}</p></div></div>
{/if}
</div>
<style>
.wrap { position: relative; flex: 1; min-height: 0; display: flex; flex-direction: column; }
.list { flex: 1; overflow-y: auto; overflow-x: hidden; padding-bottom: 12px; }
.end { height: 4px; }
.drop { position: absolute; inset: 0; z-index: 10; background: rgb(13 14 16 / 0.8); display: flex; align-items: center; justify-content: center; padding: 16px; pointer-events: none; }
.drop div { border: 2px dashed var(--accent); padding: 32px 48px; text-align: center; color: var(--text); }
.drop p { margin: 8px 0 0; font-weight: 600; }
</style>
+87
View File
@@ -0,0 +1,87 @@
<script lang="ts">
import Icon from './Icon.svelte';
import { session } from '../lib/session.svelte.ts';
import { store } from '../lib/settings.svelte.ts';
import { acceptAttr } from '../lib/upload.ts';
// target: the chat this composer sends to ("ch:<id>" or "dm:<session>")
let { target, placeholder, autofocus = true }: { target: string; placeholder: string; autofocus?: boolean } = $props();
let text = $state('');
let input: HTMLTextAreaElement;
let picker: HTMLInputElement;
const disabled = $derived(session.status !== 'connected');
const uploads = $derived(session.uploads.filter(u => u.key === target));
// Know the host's allowed types before the picker opens
$effect(() => {
if (store.settings.uploadHost && store.settings.uploadKey) session.loadUploadInfo().catch(() => {});
});
function submit() {
if (session.sendText(text, target)) text = '';
}
function onkeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
e.preventDefault();
submit();
}
}
function onpaste(e: ClipboardEvent) {
const files = [...(e.clipboardData?.files ?? [])];
if (files.length) {
e.preventDefault();
session.uploadFiles(files, target);
}
}
// Grow with content up to a limit
$effect(() => {
void text;
input.style.height = 'auto';
input.style.height = Math.min(input.scrollHeight, 200) + 'px';
});
$effect(() => {
void target;
if (autofocus && !disabled && matchMedia('(pointer: fine)').matches) input.focus();
});
</script>
<div class="composer">
{#if uploads.length}
<ul class="uploads">
{#each uploads as u (u.id)}
<li class:error={u.error}>
<Icon name="file" size={16} />
<span class="name">{u.name}</span>
{#if u.error}<span>{u.error}</span>{:else}<span class="bar"><span style:width="{u.progress * 100}%"></span></span>{/if}
</li>
{/each}
</ul>
{/if}
<div class="box">
<button class="icon-btn" title={store.settings.uploadHost ? "Upload a file" : "Send an image"} {disabled} onclick={() => picker.click()}><Icon name="paperclip" /></button>
<input bind:this={picker} type="file" multiple hidden
accept={store.settings.uploadHost && store.settings.uploadKey ? acceptAttr(session.uploadInfo) : 'image/*'}
onchange={() => { session.uploadFiles([...(picker.files ?? [])], target); picker.value = ''; }} />
<textarea bind:this={input} bind:value={text} rows="1" {placeholder} {disabled} {onkeydown} {onpaste}
aria-label="Message"></textarea>
<button class="icon-btn send" title="Send" disabled={disabled || !text.trim()} onclick={submit}><Icon name="send" size={18} /></button>
</div>
</div>
<style>
.composer { flex: none; padding: 0 16px 16px; }
.box { display: flex; align-items: flex-end; gap: 4px; background: var(--bg-3); padding: 6px; }
textarea { flex: 1; resize: none; border: 0; background: transparent; padding: 6px 4px; max-height: 200px; line-height: 1.4; }
.send:not(:disabled) { color: var(--accent-hover); }
.uploads { list-style: none; margin: 0 0 6px; padding: 0; display: flex; flex-direction: column; gap: 4px; }
.uploads li { display: flex; align-items: center; gap: 8px; background: var(--bg-1); border: 1px solid var(--line); padding: 6px 10px; font-size: 13px; }
.uploads li.error { color: var(--danger); border-color: var(--danger); }
.name { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bar { flex: 1; height: 4px; background: var(--bg-3); }
.bar span { display: block; height: 100%; background: var(--accent); transition: width 120ms; }
</style>
+105
View File
@@ -0,0 +1,105 @@
<script lang="ts">
import Modal from './Modal.svelte';
import { store, type SavedServer } from '../lib/settings.svelte.ts';
import { sessions } from '../lib/session.svelte.ts';
import { identities } from '../lib/identities.svelte.ts';
import { makeServerIcon } from '../lib/upload.ts';
let { server, onclose }: { server: SavedServer | null; onclose: () => void } = $props();
// svelte-ignore state_referenced_locally
const initial = server;
let label = $state(initial?.label ?? '');
let host = $state(initial?.host ?? '');
let port = $state(initial?.port ?? 64738);
let username = $state(initial?.username ?? '');
let password = $state(initial?.password ?? '');
let identityId = $state(initial?.identityId ?? '');
let icon = $state(initial?.icon ?? '');
let iconError = $state('');
let iconPicker: HTMLInputElement;
async function pickIcon() {
const file = iconPicker.files?.[0];
iconPicker.value = '';
if (!file) return;
iconError = '';
try { icon = await makeServerIcon(file); } catch { iconError = 'Could not read this image.'; }
}
const valid = $derived(host.trim() && username.trim() && port > 0 && port < 65536);
function build(): SavedServer {
// Accept "host:port" pasted into the host field
let h = host.trim(), p = Number(port);
const m = /^(.+):(\d+)$/.exec(h);
if (m && !h.includes(']')) { h = m[1]; p = Number(m[2]); }
return { id: initial?.id ?? crypto.randomUUID(), label: label.trim() || h, host: h, port: p, username: username.trim(), password, identityId: identityId || undefined, icon: icon || undefined };
}
function saveAndConnect(e: SubmitEvent) {
e.preventDefault();
if (!valid) return;
const s = build();
store.upsertServer(s);
// Reconnect with the new settings if it was connected
if (sessions.byServer(s.id)) sessions.close(s.id);
sessions.open(s);
onclose();
}
function remove() {
if (!initial) return;
sessions.close(initial.id);
store.removeServer(initial.id);
onclose();
}
</script>
<Modal title={initial ? 'Edit server' : 'Add a server'} {onclose}>
<form id="connect" onsubmit={saveAndConnect}>
<label for="c-host">Address</label>
<div class="row">
<input id="c-host" bind:value={host} placeholder="mumble.example.com" autocomplete="off" required />
<input class="port" type="number" bind:value={port} min="1" max="65535" aria-label="Port" />
</div>
<label for="c-user">Username</label>
<input id="c-user" bind:value={username} autocomplete="username" required />
<label for="c-pass">Password <span class="opt">(optional)</span></label>
<input id="c-pass" type="password" bind:value={password} autocomplete="current-password" />
{#if identities.list.length > 1}
<label for="c-id">Identity</label>
<select id="c-id" bind:value={identityId}>
<option value="">Default ({identities.default?.name})</option>
{#each identities.list as i (i.id)}<option value={i.id}>{i.name}</option>{/each}
</select>
{/if}
<label for="c-label">Label <span class="opt">(optional)</span></label>
<input id="c-label" bind:value={label} placeholder={host || 'My server'} />
<label for="c-icon">Icon <span class="opt">(optional)</span></label>
<div class="icon-row">
<span class="preview">
{#if icon}<img src={icon} alt="" />{:else}{(label || host || '?').trim().slice(0, 2).toUpperCase()}{/if}
</span>
<button id="c-icon" type="button" class="btn" onclick={() => iconPicker.click()}>{icon ? 'Change image' : 'Choose image'}</button>
{#if icon}<button type="button" class="btn" onclick={() => (icon = '')}>Remove</button>{/if}
<input bind:this={iconPicker} type="file" accept="image/*" hidden onchange={pickIcon} />
</div>
{#if iconError}<p class="err">{iconError}</p>{/if}
</form>
{#snippet footer()}
{#if initial}<button class="btn danger" onclick={remove}>Remove</button><span style="flex:1"></span>{/if}
<button class="btn" onclick={onclose}>Cancel</button>
<button class="btn primary" type="submit" form="connect" disabled={!valid}>Save and connect</button>
{/snippet}
</Modal>
<style>
.row { display: flex; gap: 8px; }
.port { width: 100px; flex: none; }
.icon-row { display: flex; align-items: center; gap: 8px; }
.preview { width: 44px; height: 44px; flex: none; display: flex; align-items: center; justify-content: center; background: var(--bg-3); font-weight: 700; overflow: hidden; }
.preview img { width: 100%; height: 100%; object-fit: cover; display: block; }
.err { color: var(--danger); font-size: 13px; margin: 6px 0 0; }
.opt { text-transform: none; font-weight: 400; letter-spacing: 0; color: var(--text-faint); }
</style>
+64
View File
@@ -0,0 +1,64 @@
<script lang="ts">
import { menu } from '../lib/menu.svelte.ts';
let el: HTMLDivElement | undefined = $state();
let pos = $state({ x: 0, y: 0 });
// Keep the menu on screen near the pointer
$effect(() => {
const m = menu.open;
if (!m || !el) return;
const r = el.getBoundingClientRect();
pos = {
x: Math.max(4, Math.min(m.x, window.innerWidth - r.width - 4)),
y: Math.max(4, Math.min(m.y, window.innerHeight - r.height - 4))
};
el.querySelector<HTMLButtonElement>('button:not(:disabled)')?.focus();
});
function onkeydown(e: KeyboardEvent) {
if (!menu.open) return;
if (e.key === 'Escape') menu.close();
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
const buttons = [...el!.querySelectorAll<HTMLButtonElement>('button:not(:disabled)')];
const i = buttons.indexOf(document.activeElement as HTMLButtonElement);
buttons[(i + (e.key === 'ArrowDown' ? 1 : -1) + buttons.length) % buttons.length]?.focus();
}
}
</script>
<svelte:window {onkeydown} onblur={() => menu.close()} onresize={() => menu.close()} />
{#if menu.open}
<div class="catcher" role="presentation" onmousedown={() => menu.close()} oncontextmenu={e => { e.preventDefault(); menu.close(); }}></div>
<div class="menu" role="menu" bind:this={el} style:left="{pos.x}px" style:top="{pos.y}px">
{#each menu.open.items as item, i (i)}
{#if item.separator}
<div class="sep" role="separator"></div>
{:else}
<button role="menuitem" class:danger={item.danger} disabled={item.disabled}
onclick={() => { menu.close(); item.action?.(); }}>
<span class="label">{item.label}</span>
{#if item.checked !== undefined}<span class="box" class:on={item.checked}></span>{/if}
</button>
{/if}
{/each}
</div>
{/if}
<style>
.catcher { position: fixed; inset: 0; z-index: 200; }
.menu {
position: fixed; z-index: 201; min-width: 200px; max-width: 280px; padding: 6px;
background: var(--bg-0); border: 1px solid var(--line); box-shadow: 0 8px 24px rgb(0 0 0 / 0.5);
}
button { display: flex; align-items: center; gap: 12px; width: 100%; padding: 7px 10px; font-size: 14px; text-align: left; color: var(--text); }
button:hover:not(:disabled), button:focus-visible { background: var(--accent); color: var(--accent-text); outline: none; }
button.danger { color: var(--danger); }
button.danger:hover:not(:disabled), button.danger:focus-visible { background: var(--danger); color: #fff; }
.label { flex: 1; }
.box { width: 14px; height: 14px; border: 2px solid currentColor; }
.box.on { background: currentColor; }
.sep { height: 1px; margin: 4px 6px; background: var(--line); }
</style>
+10
View File
@@ -0,0 +1,10 @@
<script lang="ts">
import { ICON_PATHS as paths } from '../lib/icons.ts';
let { name, size = 20 }: { name: string; size?: number } = $props();
</script>
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="square" stroke-linejoin="miter" aria-hidden="true">
<path d={paths[name] ?? ''} />
</svg>
+83
View File
@@ -0,0 +1,83 @@
<script lang="ts">
import { native, type IdentitySummary } from '../lib/native.ts';
import { identities } from '../lib/identities.svelte.ts';
import { store } from '../lib/settings.svelte.ts';
import { ui } from '../lib/ui.svelte.ts';
let message = $state('');
const usedBy = (id: string) => store.servers.filter(s => s.identityId === id).map(s => s.label);
async function act(task: () => Promise<unknown>) {
message = '';
try {
await task();
await identities.refresh();
} catch (e) {
message = (e as Error).message.replace(/^Error invoking remote method '[^']+': (Error: )?/, '');
}
}
function exportId(i: IdentitySummary) {
ui.prompt = {
title: `Back up ${i.name}`, label: 'Password for the backup file', confirm: 'Choose location...', input: 'password',
text: 'The .p12 file contains your private key. Protect it with a password; the desktop Mumble client can import it.',
onsubmit: pw => act(async () => {
const path = await native!.identities.exportP12(i.id, pw);
if (path) message = `Saved to ${path}`;
})
};
}
function rename(i: IdentitySummary) {
ui.prompt = { title: 'Rename identity', label: 'Name', confirm: 'Rename', initial: i.name, onsubmit: n => act(() => native!.identities.rename(i.id, n)) };
}
function remove(i: IdentitySummary) {
const servers = usedBy(i.id);
ui.prompt = {
title: `Delete ${i.name}?`, label: '', confirm: 'Delete', danger: true, input: 'none',
text: `Registrations on servers are tied to this certificate and cannot be recovered without a backup.${servers.length ? ` Used by: ${servers.join(', ')}; they will use the default identity instead.` : ''}`,
onsubmit: () => act(() => native!.identities.remove(i.id))
};
}
</script>
<ul class="ids">
{#each identities.list as i (i.id)}
<li class:default={i.isDefault}>
<div class="head">
<strong>{i.name}</strong>
{#if i.isDefault}<span class="tag">default</span>{/if}
</div>
<code>{i.fingerprint}</code>
{#if usedBy(i.id).length}<p class="help">Used for {usedBy(i.id).join(', ')}</p>{/if}
<div class="actions">
{#if !i.isDefault}<button class="link" onclick={() => act(() => native!.identities.setDefault(i.id))}>Make default</button>{/if}
<button class="link" onclick={() => (ui.cert = { title: `Certificate: ${i.name}`, chain: [i.cert] })}>View certificate</button>
<button class="link" onclick={() => exportId(i)}>Back up</button>
<button class="link" onclick={() => rename(i)}>Rename</button>
<button class="link danger" onclick={() => remove(i)}>Delete</button>
</div>
</li>
{:else}
<li class="help">No identity yet; one is created when you first connect.</li>
{/each}
</ul>
<button class="btn" onclick={() => (ui.wizard = 'add')}>Add identity</button>
{#if message}<p class="help msg">{message}</p>{/if}
<style>
.ids { list-style: none; margin: 8px 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
li { padding: 10px 12px; background: var(--bg-0); border: 1px solid var(--line); }
li.default { border-color: var(--accent); }
.head { display: flex; align-items: center; gap: 8px; }
.tag { font-size: 11px; font-weight: 600; padding: 0 6px; background: var(--accent); color: var(--accent-text); }
code { display: block; margin-top: 4px; font: 11px var(--mono); color: var(--text-dim); overflow-wrap: anywhere; }
.actions { display: flex; flex-wrap: wrap; gap: 4px 14px; margin-top: 8px; }
.link { color: var(--link); font-size: 13px; }
.link:hover { text-decoration: underline; }
.link.danger { color: var(--danger); }
.help { margin: 4px 0 0; font-size: 13px; color: var(--text-dim); }
.msg { overflow-wrap: anywhere; }
</style>
+178
View File
@@ -0,0 +1,178 @@
<script lang="ts">
import Modal from './Modal.svelte';
import Icon from './Icon.svelte';
import { native, type IdentitySummary } from '../lib/native.ts';
import { identities } from '../lib/identities.svelte.ts';
// setup: first start (marks setup as done at the end); add: adding another identity from Settings
let { mode = 'setup', onclose }: { mode?: 'setup' | 'add'; onclose: () => void } = $props();
type Step = 'welcome' | 'create' | 'import' | 'backup' | 'done';
let step = $state<Step>('welcome');
let busy = $state(false);
let error = $state('');
let result = $state<IdentitySummary | null>(null);
let mumble = $state<{ name: string } | null>(null);
// create
let name = $state('');
let email = $state('');
// import
let file = $state<File | null>(null);
let filePassword = $state('');
// backup
let backupPassword = $state('');
let backupConfirm = $state('');
let savedTo = $state('');
// svelte-ignore state_referenced_locally
const existing = mode === 'setup' ? identities.default : undefined;
native?.identities.findMumble().then(m => (mumble = m));
async function run(task: () => Promise<IdentitySummary>, next: Step) {
busy = true;
error = '';
try {
result = await task();
await identities.refresh();
step = next;
} catch (e) {
error = (e as Error).message.replace(/^Error invoking remote method '[^']+': (Error: )?/, '');
} finally {
busy = false;
}
}
const create = (e: SubmitEvent) => {
e.preventDefault();
run(() => native!.identities.create(name.trim(), email.trim()), 'backup');
};
const importFile = async (e: SubmitEvent) => {
e.preventDefault();
if (!file) return;
const bytes = new Uint8Array(await file.arrayBuffer());
run(() => native!.identities.importP12(bytes, filePassword, ''), 'done');
};
const importMumble = () => run(() => native!.identities.importMumble(''), 'done');
function keepExisting() {
result = existing ?? null;
step = 'backup';
}
async function saveBackup() {
if (!result) return;
busy = true;
error = '';
try {
const path = await native!.identities.exportP12(result.id, backupPassword);
if (path) {
savedTo = path;
step = 'done';
}
} catch (e) {
error = (e as Error).message;
} finally {
busy = false;
}
}
async function finish() {
if (mode === 'setup') await native?.identities.finishSetup();
await identities.refresh();
onclose();
}
const title = $derived(mode === 'setup' ? 'Set up your identity' : 'Add an identity');
const passwordsOk = $derived(backupPassword.length >= 8 && backupPassword === backupConfirm);
</script>
<Modal {title} onclose={mode === 'setup' ? () => {} : onclose} width={560}>
{#if step === 'welcome'}
<p>Mumble recognizes you by a <strong>certificate</strong>, not by a password. Registered names, admin rights and friends lists on servers are tied to it, so it is worth setting up once and keeping a backup.</p>
<div class="choices">
<button class="choice" onclick={() => (step = 'create')}>
<Icon name="plus" /><span><strong>Create a new identity</strong><small>Start fresh with a new certificate</small></span>
</button>
{#if mumble}
<button class="choice" disabled={busy} onclick={importMumble}>
<Icon name="download" /><span><strong>Use my Mumble desktop identity</strong><small>Found "{mumble.name}" from the desktop client; keeps your registrations</small></span>
</button>
{/if}
<button class="choice" onclick={() => (step = 'import')}>
<Icon name="file" /><span><strong>Import a certificate file</strong><small>A .p12 or .pfx backup, for example exported from Mumble</small></span>
</button>
{#if existing}
<button class="choice" onclick={keepExisting}>
<Icon name="shield" /><span><strong>Keep the current identity</strong><small>"{existing.name}", created automatically earlier</small></span>
</button>
{/if}
</div>
{:else if step === 'create'}
<form id="w-create" onsubmit={create}>
<p>The name is stored in the certificate and helps you tell identities apart. Servers show the username you enter when connecting, not this.</p>
<label for="w-name">Name</label>
<!-- svelte-ignore a11y_autofocus -->
<input id="w-name" bind:value={name} required autofocus placeholder="e.g. kibi" />
<label for="w-email">Email <span class="opt">(optional, stored in the certificate)</span></label>
<input id="w-email" type="email" bind:value={email} />
</form>
{:else if step === 'import'}
<form id="w-import" onsubmit={importFile}>
<label for="w-file">Certificate file</label>
<input id="w-file" type="file" accept=".p12,.pfx,application/x-pkcs12" onchange={e => (file = (e.currentTarget as HTMLInputElement).files?.[0] ?? null)} />
<label for="w-fpass">Password <span class="opt">(leave empty if it has none)</span></label>
<input id="w-fpass" type="password" bind:value={filePassword} autocomplete="off" />
</form>
{:else if step === 'backup' && result}
<p>Identity <strong>{result.name}</strong> is ready.</p>
<p class="mono">{result.fingerprint}</p>
<p>If this computer dies, the certificate goes with it, and so do your registrations. Save a password-protected backup now; the desktop Mumble client can import it too.</p>
<label for="w-bpass">Backup password <span class="opt">(at least 8 characters)</span></label>
<input id="w-bpass" type="password" bind:value={backupPassword} autocomplete="new-password" />
<label for="w-bconf">Repeat password</label>
<input id="w-bconf" type="password" bind:value={backupConfirm} autocomplete="new-password" />
{:else if step === 'done'}
<p>You are set up{result ? ` as ${result.name}` : ''}.</p>
{#if result}<p class="mono">{result.fingerprint}</p>{/if}
{#if savedTo}<p class="ok">Backup saved to {savedTo}</p>{/if}
<p class="dim">Manage identities, pick one per server and export backups in Settings.</p>
{/if}
{#if error}<p class="error">{error}</p>{/if}
{#snippet footer()}
{#if step === 'welcome'}
{#if mode === 'add'}<button class="btn" onclick={onclose}>Cancel</button>{/if}
{:else if step === 'create'}
<button class="btn" onclick={() => (step = 'welcome')}>Back</button>
<button class="btn primary" type="submit" form="w-create" disabled={busy || !name.trim()}>{busy ? 'Generating...' : 'Create'}</button>
{:else if step === 'import'}
<button class="btn" onclick={() => (step = 'welcome')}>Back</button>
<button class="btn primary" type="submit" form="w-import" disabled={busy || !file}>Import</button>
{:else if step === 'backup'}
<button class="btn" onclick={() => (step = 'done')}>Skip for now</button>
<button class="btn primary" disabled={busy || !passwordsOk} onclick={saveBackup}>Save backup...</button>
{:else}
<button class="btn primary" onclick={finish}>Done</button>
{/if}
{/snippet}
</Modal>
<style>
p { color: var(--text-dim); margin: 10px 0 0; }
p strong { color: var(--text); }
.choices { display: flex; flex-direction: column; gap: 8px; margin-top: 16px; }
.choice { display: flex; align-items: center; gap: 14px; padding: 12px 14px; text-align: left; background: var(--bg-3); border: 1px solid var(--line); }
.choice:hover:not(:disabled) { border-color: var(--accent); background: var(--bg-hover); }
.choice span { display: flex; flex-direction: column; }
.choice small { color: var(--text-dim); font-size: 13px; }
.opt { text-transform: none; font-weight: 400; letter-spacing: 0; color: var(--text-faint); }
.mono { font: 12px var(--mono); overflow-wrap: anywhere; color: var(--text); }
.ok { color: var(--speaking); overflow-wrap: anywhere; }
.dim { font-size: 13px; }
.error { color: var(--danger); }
</style>
+52
View File
@@ -0,0 +1,52 @@
<script lang="ts">
import Avatar from './Avatar.svelte';
import Icon from './Icon.svelte';
import { session } from '../lib/session.svelte.ts';
import { menu } from '../lib/menu.svelte.ts';
import { userMenu } from '../lib/actions.ts';
import { voice } from '../lib/audio/voice.svelte.ts';
import { store } from '../lib/settings.svelte.ts';
let { onnavigate }: { onnavigate: () => void } = $props();
const here = $derived(session.self ? session.usersIn(session.self.channelId) : []);
const elsewhere = $derived(session.allUsers().filter(u => u.channelId !== session.self?.channelId));
</script>
<aside class="members" aria-label="Members">
{#snippet group(title: string, users: typeof here)}
{#if users.length}
<h3>{title} ({users.length})</h3>
<ul>
{#each users as u (u.session)}
{@const isSelf = u.session === session.self?.session}
<li>
<button onclick={() => session.showUser(u.session)} title={isSelf ? 'You' : `${u.name} (double-click to message)`}
ondblclick={() => { if (!isSelf) { session.selectView(`dm:${u.session}`); onnavigate(); } }}
oncontextmenu={e => menu.show(e, userMenu(u))}>
<Avatar name={u.name} size={30} speaking={voice.talking[u.session] || (isSelf && voice.transmitting)} />
<span class="who">
<span class="name">{store.displayName(u)}</span>
<span class="sub">{u.userId != null ? 'Registered' : 'Guest'}{title !== 'In channel' ? ` in ${session.channel(u.channelId)?.name || 'Root'}` : ''}</span>
</span>
{#if u.selfDeaf || u.deaf}<Icon name="headphones-off" size={14} />{:else if u.selfMute || u.mute}<Icon name="mic-off" size={14} />{/if}
</button>
</li>
{/each}
</ul>
{/if}
{/snippet}
{@render group('In channel', here)}
{@render group('Elsewhere', elsewhere)}
</aside>
<style>
.members { flex: 1; min-height: 0; overflow-y: auto; padding: 8px 6px; }
h3 { font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-faint); margin: 14px 8px 6px; }
ul { list-style: none; margin: 0; padding: 0; }
button { display: flex; align-items: center; gap: 10px; width: 100%; padding: 5px 8px; text-align: left; color: var(--text-dim); }
button:hover { background: var(--bg-hover); color: var(--text); }
.who { flex: 1; min-width: 0; display: flex; flex-direction: column; line-height: 1.2; }
.name { color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sub { font-size: 11px; color: var(--text-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
</style>
+80
View File
@@ -0,0 +1,80 @@
<script lang="ts">
import Avatar from './Avatar.svelte';
import YouTubeEmbed from './YouTubeEmbed.svelte';
import { store } from '../lib/settings.svelte.ts';
import type { ChatEntry } from '../lib/session.svelte.ts';
let { entry, grouped }: { entry: ChatEntry; grouped: boolean } = $props();
const time = $derived(new Date(entry.time));
const hhmm = $derived(time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }));
</script>
{#if entry.kind === 'system'}
<div class="system">
<span class="mark" aria-hidden="true"></span>
<div class="html">{@html entry.html} <span class="time">{hhmm}</span></div>
</div>
{:else}
<article class="msg" class:grouped>
<div class="gutter">
{#if grouped}
<span class="time hover">{hhmm}</span>
{:else}
<Avatar name={entry.author} size={38} />
{/if}
</div>
<div class="content">
{#if !grouped}
<header>
<strong class:self={entry.self}>{entry.author}</strong>
<time datetime={time.toISOString()}>{time.toLocaleString([], { dateStyle: 'short', timeStyle: 'short' })}</time>
</header>
{/if}
{#if entry.html}<div class="html">{@html entry.html}</div>{/if}
{#each entry.embeds as m (m.url)}
{#if m.kind === 'youtube'}
{#if store.settings.youtube !== 'off'}<div class="embed"><YouTubeEmbed videoId={m.videoId!} start={m.start} url={m.url} /></div>{/if}
{:else}
<div class="embed">
{#if m.kind === 'image'}
<a href={m.url} target="_blank" rel="noopener noreferrer"><img src={m.url} alt={m.name} loading="lazy" /></a>
{:else if m.kind === 'video'}
<video src={m.url} controls preload="metadata"><track kind="captions" /></video>
{:else}
<audio src={m.url} controls preload="metadata"></audio>
{/if}
</div>
{/if}
{/each}
</div>
</article>
{/if}
<style>
.msg { display: flex; gap: 14px; padding: 2px 16px; margin-top: 14px; }
.msg.grouped { margin-top: 0; }
.msg:hover { background: rgb(255 255 255 / 0.02); }
.gutter { width: 38px; flex: none; display: flex; justify-content: center; }
.content { flex: 1; min-width: 0; }
header { display: flex; align-items: baseline; gap: 8px; }
header strong { font-weight: 600; }
header strong.self { color: var(--accent-hover); }
time, .time { font-size: 11px; color: var(--text-faint); }
.time.hover { visibility: hidden; line-height: 22px; }
.msg:hover .time.hover { visibility: visible; }
.html { overflow-wrap: anywhere; user-select: text; }
.html :global(p) { margin: 0; }
.html :global(img) { max-width: min(520px, 100%); max-height: 360px; display: block; margin-top: 4px; border: 1px solid var(--line); }
.html :global(pre), .html :global(code) { font-family: var(--mono); font-size: 13px; background: var(--bg-0); padding: 1px 4px; }
.html :global(pre) { padding: 8px 10px; overflow-x: auto; }
.html :global(blockquote) { margin: 4px 0; padding-left: 10px; border-left: 3px solid var(--line); color: var(--text-dim); }
.embed { margin-top: 6px; max-width: min(520px, 100%); }
.embed img, .embed video { display: block; max-width: 100%; max-height: 360px; background: var(--bg-0); border: 1px solid var(--line); }
.embed audio { width: 100%; }
.system { display: flex; gap: 14px; padding: 4px 16px; margin-top: 8px; color: var(--text-dim); font-size: 13px; }
.system .mark { width: 38px; flex: none; display: flex; justify-content: center; padding-top: 7px; }
.system .mark::before { content: ''; width: 6px; height: 6px; background: var(--text-faint); }
.system .time { margin-left: 6px; }
.system .html { flex: 1; min-width: 0; }
</style>
+35
View File
@@ -0,0 +1,35 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import Icon from './Icon.svelte';
let { title, onclose, children, footer, width = 460 }:
{ title: string; onclose: () => void; children: Snippet; footer?: Snippet; width?: number } = $props();
</script>
<svelte:window onkeydown={e => { if (e.key === 'Escape') onclose(); }} />
<div class="backdrop" role="presentation" onmousedown={e => { if (e.target === e.currentTarget) onclose(); }}>
<div class="modal" role="dialog" aria-modal="true" aria-label={title} style:max-width="{width}px">
<header>
<h2>{title}</h2>
<button class="icon-btn" onclick={onclose} aria-label="Close"><Icon name="x" /></button>
</header>
<div class="body">{@render children()}</div>
{#if footer}<footer>{@render footer()}</footer>{/if}
</div>
</div>
<style>
.backdrop {
position: fixed; inset: 0; z-index: 100; background: rgb(0 0 0 / 0.6);
display: flex; align-items: center; justify-content: center; padding: 16px;
}
.modal {
width: 100%; max-height: calc(100vh - 32px); display: flex; flex-direction: column;
background: var(--bg-1); border: 1px solid var(--line); box-shadow: 0 16px 48px rgb(0 0 0 / 0.5);
}
header { display: flex; align-items: center; justify-content: space-between; padding: 16px 16px 0 20px; }
h2 { margin: 0; font-size: 18px; }
.body { padding: 4px 20px 20px; overflow-y: auto; }
footer { display: flex; justify-content: flex-end; gap: 8px; padding: 14px 20px; background: var(--bg-0); border-top: 1px solid var(--line); }
</style>
+29
View File
@@ -0,0 +1,29 @@
<script lang="ts">
import Modal from './Modal.svelte';
let { title, label, confirm, danger = false, input = 'text', initial = '', text = '', onsubmit, onclose }:
{ title: string; label: string; confirm: string; danger?: boolean; input?: 'text' | 'password' | 'none'; initial?: string;
text?: string; onsubmit: (value: string) => void; onclose: () => void } = $props();
// svelte-ignore state_referenced_locally
let value = $state(initial);
</script>
<Modal {title} {onclose}>
<form id="prompt" onsubmit={e => { e.preventDefault(); onsubmit(value); onclose(); }}>
{#if text}<p>{text}</p>{/if}
{#if input !== 'none'}
<label for="p-val">{label}</label>
<!-- svelte-ignore a11y_autofocus -->
<input id="p-val" type={input} bind:value autofocus autocomplete="off" />
{/if}
</form>
{#snippet footer()}
<button class="btn" onclick={onclose}>Cancel</button>
<button class="btn" class:primary={!danger} class:danger type="submit" form="prompt">{confirm}</button>
{/snippet}
</Modal>
<style>
p { color: var(--text-dim); }
</style>
+77
View File
@@ -0,0 +1,77 @@
<script lang="ts">
import { voice, type TransmitMode } from '../lib/audio/voice.svelte.ts';
import { ui } from '../lib/ui.svelte.ts';
const q = $derived(ui.quickAudio!);
const s = voice.settings;
let el: HTMLDivElement | undefined = $state();
let pos = $state({ x: 0, y: 0 });
voice.refreshDevices();
// Open above the button it came from, kept on screen
$effect(() => {
if (!el) return;
const r = el.getBoundingClientRect();
pos = {
x: Math.max(8, Math.min(q.x - r.width / 2, window.innerWidth - r.width - 8)),
y: Math.max(8, q.y - r.height - 8)
};
});
const close = () => (ui.quickAudio = null);
const METER_MIN = -80;
const pct = (db: number) => Math.max(0, Math.min(100, ((db - METER_MIN) / -METER_MIN) * 100));
const modes: { id: TransmitMode; label: string }[] = [
{ id: 'vad', label: 'Activity' }, { id: 'ptt', label: 'Push to talk' }, { id: 'continuous', label: 'Always on' }
];
</script>
<svelte:window onkeydown={e => { if (e.key === 'Escape') close(); }} onresize={close} />
<div class="catcher" role="presentation" onmousedown={close} oncontextmenu={e => { e.preventDefault(); close(); }}></div>
<div class="pop" role="dialog" aria-label={q.kind === 'input' ? 'Microphone' : 'Output'} bind:this={el} style:left="{pos.x}px" style:top="{pos.y}px">
{#if q.kind === 'input'}
<label for="qa-in">Microphone</label>
<select id="qa-in" value={s.inputDevice} onchange={e => voice.update({ inputDevice: e.currentTarget.value })}>
{#each voice.inputDevices as d (d.id)}<option value={d.id}>{d.label}</option>{:else}<option value="default">System default</option>{/each}
</select>
<label for="qa-gain">Input volume: {Math.round(s.inputGain * 100)}%</label>
<input id="qa-gain" type="range" min="0" max="3" step="0.05" value={s.inputGain}
oninput={e => voice.update({ inputGain: Number(e.currentTarget.value) })} />
<div class="meter" aria-hidden="true"><div class="fill" class:open={s.mode !== 'vad' || voice.level > s.vadThreshold} style:width="{pct(voice.level)}%"></div></div>
<div class="modes" role="radiogroup" aria-label="Transmit mode">
{#each modes as m (m.id)}
<button role="radio" aria-checked={s.mode === m.id} class:active={s.mode === m.id} onclick={() => voice.update({ mode: m.id })}>{m.label}</button>
{/each}
</div>
<label class="check"><input type="checkbox" checked={s.noiseSuppression} onchange={e => voice.update({ noiseSuppression: e.currentTarget.checked })} /> Noise suppression</label>
{:else}
<label for="qa-out">Output device</label>
<select id="qa-out" value={s.outputDevice} onchange={e => voice.update({ outputDevice: e.currentTarget.value })}>
{#each voice.outputDevices as d (d.id)}<option value={d.id}>{d.label}</option>{:else}<option value="default">System default</option>{/each}
</select>
<label for="qa-vol">Output volume: {Math.round(s.outputVolume * 100)}%</label>
<input id="qa-vol" type="range" min="0" max="2" step="0.05" value={s.outputVolume}
oninput={e => voice.update({ outputVolume: Number(e.currentTarget.value) })} />
{/if}
<button class="more" onclick={() => { close(); ui.settingsTab = 'voice'; ui.settingsOpen = true; }}>Voice settings...</button>
</div>
<style>
.catcher { position: fixed; inset: 0; z-index: 200; }
.pop { position: fixed; z-index: 201; width: 260px; padding: 10px 12px 8px; background: var(--bg-0); border: 1px solid var(--line); box-shadow: 0 8px 24px rgb(0 0 0 / 0.5); }
label { margin-top: 10px; }
label:first-child { margin-top: 0; }
select { font-size: 13px; padding: 6px 8px; }
input[type=range] { padding: 0; border: 0; background: transparent; accent-color: var(--accent); }
.meter { height: 6px; background: var(--bg-3); margin-top: 4px; overflow: hidden; }
.fill { height: 100%; background: var(--text-faint); transition: width 50ms linear; }
.fill.open { background: var(--speaking); }
.modes { display: flex; gap: 2px; margin-top: 10px; }
.modes button { flex: 1; padding: 5px 4px; font-size: 12px; background: var(--bg-3); color: var(--text-dim); }
.modes button.active { background: var(--accent); color: var(--accent-text); }
.check { display: flex; align-items: center; gap: 8px; text-transform: none; font-weight: 400; letter-spacing: 0; font-size: 13px; color: var(--text); margin-top: 10px; }
.check input { width: auto; }
.more { display: block; width: 100%; margin-top: 10px; padding: 6px 0 0; border-top: 1px solid var(--line); text-align: left; font-size: 13px; color: var(--link); }
</style>
+166
View File
@@ -0,0 +1,166 @@
<script lang="ts">
import { onMount } from 'svelte';
import Icon from './Icon.svelte';
import { toMumbleHtml, formatBytes } from '../lib/html.ts';
import { makeInlineImage } from '../lib/upload.ts';
import { ui } from '../lib/ui.svelte.ts';
// Rich text in the subset Mumble clients render (Qt rich text): bold, italic, underline,
// strikethrough, colors, lists, links and inline data: images. `limit` is the server's
// size limit for the whole HTML, pictures included.
let { value = '', limit, placeholder = '', onchange }:
{ value?: string; limit: number; placeholder?: string; onchange: (html: string) => void } = $props();
let editor: HTMLDivElement;
let picker: HTMLInputElement;
let source = $state(false);
let sourceText = $state('');
let html = $state('');
let error = $state('');
let color = $state('#e8b23c');
const size = $derived(html.length);
const over = $derived(limit > 0 && size > limit);
onMount(() => {
document.execCommand('defaultParagraphSeparator', false, 'p');
document.execCommand('styleWithCSS', false, 'true');
editor.innerHTML = toMumbleHtml(value) || '<p><br></p>';
sync();
});
function sync() {
html = toMumbleHtml(editor.innerHTML);
onchange(html);
}
function exec(cmd: string, arg?: string) {
editor.focus();
document.execCommand(cmd, false, arg);
sync();
}
// window.prompt does not exist in Electron; use the app's dialog and restore the selection after
function link() {
const sel = window.getSelection();
const range = sel && sel.rangeCount && editor.contains(sel.anchorNode) ? sel.getRangeAt(0).cloneRange() : null;
ui.prompt = {
title: 'Insert link', label: 'Address (https://...)', confirm: 'Insert link', initial: 'https://',
onsubmit: raw => {
const url = raw.trim();
if (!/^https?:\/\/\S+$/i.test(url)) return;
editor.focus();
if (range) { sel!.removeAllRanges(); sel!.addRange(range); }
if (range && !range.collapsed) exec('createLink', url);
else exec('insertHTML', `<a href="${url.replace(/"/g, '&quot;')}">${url.replace(/</g, '&lt;')}</a>`);
}
};
}
async function insertImage(file: File) {
error = '';
// Room left for the picture inside the server's limit
const room = (limit || 131072) - size - 64;
const uri = await makeInlineImage(file, room, [640, 480, 320, 200, 120]);
if (!uri) {
error = room < 2000 ? 'No room left for a picture within the server limit.' : 'Could not make this picture small enough.';
return;
}
exec('insertImage', uri);
}
function onpaste(e: ClipboardEvent) {
const img = [...(e.clipboardData?.files ?? [])].find(f => f.type.startsWith('image/'));
e.preventDefault();
if (img) return insertImage(img);
const pasted = e.clipboardData?.getData('text/html');
if (pasted) exec('insertHTML', toMumbleHtml(pasted));
else exec('insertText', e.clipboardData?.getData('text/plain') ?? '');
}
function toggleSource() {
if (!source) {
sourceText = html;
source = true;
} else {
editor.innerHTML = toMumbleHtml(sourceText) || '<p><br></p>';
source = false;
sync();
}
}
function onkeydown(e: KeyboardEvent) {
if (!(e.ctrlKey || e.metaKey)) return;
const map: Record<string, string> = { b: 'bold', i: 'italic', u: 'underline' };
const cmd = map[e.key.toLowerCase()];
if (cmd) { e.preventDefault(); exec(cmd); }
}
const tools = [
{ cmd: 'bold', label: 'Bold (Ctrl+B)', text: 'B', cls: 'b' },
{ cmd: 'italic', label: 'Italic (Ctrl+I)', text: 'I', cls: 'i' },
{ cmd: 'underline', label: 'Underline (Ctrl+U)', text: 'U', cls: 'u' },
{ cmd: 'strikeThrough', label: 'Strikethrough', text: 'S', cls: 's' }
];
</script>
<div class="rich" class:over>
<div class="toolbar" role="toolbar" aria-label="Formatting">
{#each tools as t (t.cmd)}
<button class="tool {t.cls}" title={t.label} aria-label={t.label} disabled={source} onmousedown={e => e.preventDefault()} onclick={() => exec(t.cmd)}>{t.text}</button>
{/each}
<span class="sep"></span>
<label class="tool color" title="Text color">
<span class="swatch" style:background={color}></span>
<input type="color" bind:value={color} disabled={source} onchange={() => exec('foreColor', color)} aria-label="Text color" />
</label>
<span class="sep"></span>
<button class="tool" title="Bulleted list" aria-label="Bulleted list" disabled={source} onmousedown={e => e.preventDefault()} onclick={() => exec('insertUnorderedList')}><Icon name="list" size={16} /></button>
<button class="tool" title="Numbered list" aria-label="Numbered list" disabled={source} onmousedown={e => e.preventDefault()} onclick={() => exec('insertOrderedList')}><Icon name="list-ordered" size={16} /></button>
<button class="tool" title="Link" aria-label="Link" disabled={source} onmousedown={e => e.preventDefault()} onclick={link}><Icon name="link" size={16} /></button>
<button class="tool" title="Insert picture" aria-label="Insert picture" disabled={source} onmousedown={e => e.preventDefault()} onclick={() => picker.click()}><Icon name="image" size={16} /></button>
<input bind:this={picker} type="file" accept="image/*" hidden onchange={() => { const f = picker.files?.[0]; picker.value = ''; if (f) insertImage(f); }} />
<button class="tool" title="Clear formatting" aria-label="Clear formatting" disabled={source} onmousedown={e => e.preventDefault()} onclick={() => exec('removeFormat')}><Icon name="eraser" size={16} /></button>
<span class="grow"></span>
<button class="tool" class:on={source} title="Edit HTML" aria-label="Edit HTML" aria-pressed={source} onclick={toggleSource}><Icon name="code" size={16} /></button>
</div>
<div class="area" class:hidden={source} class:blank={!html} bind:this={editor} contenteditable="true" role="textbox" aria-multiline="true"
aria-label="Description" tabindex="0" data-placeholder={placeholder} oninput={sync} {onpaste} {onkeydown}></div>
{#if source}
<textarea class="area src" bind:value={sourceText} oninput={() => onchange(toMumbleHtml(sourceText))} aria-label="HTML source" spellcheck="false"></textarea>
{/if}
<div class="foot">
{#if error}<span class="err">{error}</span>{/if}
<span class="grow"></span>
<span class="count" title="Size of the description as the server counts it">{formatBytes(source ? sourceText.length : size)}{limit ? ` / ${formatBytes(limit)}` : ''}</span>
</div>
</div>
<style>
.rich { border: 1px solid var(--line); background: var(--bg-0); }
.rich:focus-within { border-color: var(--accent); }
.rich.over { border-color: var(--danger); }
.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 2px; padding: 4px; border-bottom: 1px solid var(--line); background: var(--bg-2); }
.tool { width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; color: var(--text-dim); font-size: 14px; cursor: pointer; }
.tool:hover:not(:disabled), .tool.on { background: var(--bg-hover); color: var(--text); }
.b { font-weight: 800; }
.i { font-style: italic; font-family: Georgia, serif; }
.u { text-decoration: underline; }
.s { text-decoration: line-through; }
.sep { width: 1px; height: 18px; background: var(--line); margin: 0 4px; }
.grow { flex: 1; }
.color { position: relative; margin: 0; }
.swatch { width: 14px; height: 14px; border: 1px solid var(--text-faint); }
.color input { position: absolute; inset: 0; opacity: 0; cursor: pointer; padding: 0; }
.area { position: relative; min-height: 140px; max-height: 360px; overflow-y: auto; padding: 8px 10px; outline: none; overflow-wrap: anywhere; line-height: 1.45; }
.area.hidden { display: none; }
.area :global(p) { margin: 0 0 6px; }
.area :global(img) { max-width: 100%; }
.area.blank::before { content: attr(data-placeholder); color: var(--text-faint); position: absolute; left: 10px; top: 8px; pointer-events: none; }
.src { width: 100%; border: 0; resize: vertical; font: 12px var(--mono); background: transparent; }
.foot { display: flex; align-items: center; gap: 8px; padding: 4px 8px; border-top: 1px solid var(--line); font-size: 12px; color: var(--text-faint); }
.over .count { color: var(--danger); font-weight: 600; }
.err { color: var(--danger); }
</style>
+239
View File
@@ -0,0 +1,239 @@
<script lang="ts">
import Icon from './Icon.svelte';
import Avatar from './Avatar.svelte';
import Members from './Members.svelte';
import ChatLog from './ChatLog.svelte';
import Composer from './Composer.svelte';
import { session } from '../lib/session.svelte.ts';
import { store } from '../lib/settings.svelte.ts';
import { menu } from '../lib/menu.svelte.ts';
import { userMenu, changeNamePrompt, nicknamePrompt } from '../lib/actions.ts';
import { voice, userKey } from '../lib/audio/voice.svelte.ts';
import { renderIncoming } from '../lib/html.ts';
import RichEditor from './RichEditor.svelte';
let { onnavigate, onclose }: { onnavigate: () => void; onclose: () => void } = $props();
const panel = $derived(session.panel);
const channel = $derived(panel.kind === 'channel' ? session.channel(panel.id) : undefined);
const user = $derived(panel.kind === 'user' ? session.user(panel.session) : undefined);
const isSelf = $derived(!!user && user.session === session.self?.session);
// Description or comment arrive later when they were only announced by hash
// Re-runs when the hash changes, e.g. right after saving a long description
$effect(() => { if (channel?.descriptionHash && !channel.description) session.loadDescription(channel.id); });
$effect(() => { if (user?.commentHash && !user.comment) session.loadComment(user.session); });
const description = $derived(channel?.description ? renderIncoming(channel.description, store.trustedHosts).html : '');
const comment = $derived(user?.comment ? renderIncoming(user.comment, store.trustedHosts).html : '');
const pendingDescription = $derived(!!channel?.descriptionHash && !channel.description);
const pendingComment = $derived(!!user?.commentHash && !user.comment);
// User-resizable width, remembered between sessions
const MIN_W = 240, DEFAULT_W = 300;
const maxW = () => Math.max(MIN_W, Math.min(900, window.innerWidth - 420));
let width = $state(Math.max(MIN_W, Number(localStorage.getItem('mumh5.panelWidth')) || DEFAULT_W));
let resizing = $state(false);
function setWidth(w: number) {
width = Math.round(Math.max(MIN_W, Math.min(maxW(), w)));
try { localStorage.setItem('mumh5.panelWidth', String(width)); } catch { /* storage unavailable */ }
}
function startResize(e: PointerEvent) {
e.preventDefault();
const handle = e.currentTarget as HTMLElement;
handle.setPointerCapture(e.pointerId);
resizing = true;
const right = (handle.parentElement as HTMLElement).getBoundingClientRect().right;
const move = (ev: PointerEvent) => setWidth(right - ev.clientX);
const up = () => {
resizing = false;
handle.removeEventListener('pointermove', move);
handle.removeEventListener('pointerup', up);
handle.removeEventListener('pointercancel', up);
};
handle.addEventListener('pointermove', move);
handle.addEventListener('pointerup', up);
handle.addEventListener('pointercancel', up);
}
function resizeKey(e: KeyboardEvent) {
if (e.key === 'ArrowLeft') setWidth(width + 16);
else if (e.key === 'ArrowRight') setWidth(width - 16);
else return;
e.preventDefault();
}
let editing = $state(false);
let draft = $state('');
$effect(() => { void panel; editing = false; });
function startEdit() {
draft = user?.comment ?? '';
editing = true;
}
function saveComment() {
session.setComment(draft);
editing = false;
}
</script>
<svelte:window onresize={() => setWidth(width)} />
<aside class="panel" class:resizing aria-label="Details" style:width="{width}px">
<!-- Focusable separator is the ARIA window-splitter pattern -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
<div class="resize" role="separator" aria-orientation="vertical" aria-label="Resize panel" aria-valuenow={width}
aria-valuemin={MIN_W} aria-valuemax={maxW()} tabindex="0" title="Drag to resize, double-click to reset"
onpointerdown={startResize} ondblclick={() => setWidth(DEFAULT_W)} onkeydown={resizeKey}></div>
{#if panel.kind === 'members' || (!channel && !user)}
<header><h2>Members</h2><button class="icon-btn" onclick={onclose} aria-label="Close"><Icon name="x" size={18} /></button></header>
<Members {onnavigate} />
{:else if channel && panel.kind === 'channel'}
<header>
<Icon name="channel" size={18} />
<h2>{channel.name || 'Root'}</h2>
<button class="icon-btn" onclick={() => session.closePanel()} aria-label="Close"><Icon name="x" size={18} /></button>
</header>
<div class="tabs" role="tablist">
<button role="tab" aria-selected={panel.tab === 'info'} class:active={panel.tab === 'info'} onclick={() => session.showChannel(channel.id, 'info')}>Info</button>
<button role="tab" aria-selected={panel.tab === 'chat'} class:active={panel.tab === 'chat'} onclick={() => session.showChannel(channel.id, 'chat')}>
Chat{#if session.unread[`ch:${channel.id}`]}<span class="badge">{session.unread[`ch:${channel.id}`]}</span>{/if}
</button>
</div>
{#if panel.tab === 'info'}
<div class="body">
{#if description}
<div class="html">{@html description}</div>
{:else if pendingDescription}
<p class="dim">Loading description...</p>
{:else}
<p class="dim">This channel has no description.</p>
{/if}
<p class="dim small">{session.usersIn(channel.id).length} here, {session.subtreeUserCount(channel.id)} including subchannels{channel.maxUsers ? `, limit ${channel.maxUsers}` : ''}{channel.temporary ? ', temporary' : ''}</p>
<div class="actions">
{#if session.self?.channelId === channel.id}
<span class="here">You are in this channel</span>
{:else}
<button class="btn primary" disabled={!channel.canEnter} onclick={() => { session.joinChannel(channel.id); onnavigate(); }}>
{channel.canEnter ? 'Join channel' : 'No access'}
</button>
{/if}
<button class="btn" onclick={() => session.showChannel(channel.id, 'chat')}>Open chat</button>
</div>
</div>
{:else}
<ChatLog key={`ch:${channel.id}`}>
{#snippet empty()}<p class="dim pad">Messages sent here go to everyone in #{channel.name || 'Root'}, even if you are not in it.</p>{/snippet}
</ChatLog>
<Composer target={`ch:${channel.id}`} placeholder={`Message #${channel.name || 'Root'}`} />
{/if}
{:else if user}
<header>
<h2>{isSelf ? 'Your profile' : 'Profile'}</h2>
<button class="icon-btn" onclick={() => session.closePanel()} aria-label="Close"><Icon name="x" size={18} /></button>
</header>
<div class="body">
<div class="who" role="presentation" oncontextmenu={e => menu.show(e, userMenu(user))}>
<Avatar name={user.name} size={56} speaking={voice.talking[user.session] || (isSelf && voice.transmitting)} />
<div>
<h3>{store.displayName(user)}</h3>
{#if store.displayName(user) !== user.name}<p class="dim small">Real name: {user.name}</p>{/if}
<p class="badges">
<span class="badge-reg" class:registered={user.userId != null}>{user.userId != null ? 'Registered' : 'Guest'}</span>
{#if isSelf && user.userId == null}
<button class="register" title="Reserve this name on the server, tied to your certificate" onclick={() => session.register(user.session)}>Register</button>
{/if}
<span class="dim small">in {session.channel(user.channelId)?.name || 'Root'}</span>
</p>
<p class="flags">
{#if user.selfMute || user.mute}<span>{user.mute ? 'server muted' : 'muted'}</span>{/if}
{#if user.selfDeaf || user.deaf}<span>{user.deaf ? 'server deafened' : 'deafened'}</span>{/if}
{#if user.prioritySpeaker}<span>priority speaker</span>{/if}
{#if user.recording}<span class="bad">recording</span>{/if}
</p>
</div>
</div>
{#if isSelf}
<div class="actions"><button class="btn" onclick={() => changeNamePrompt(user)}>Change name</button></div>
{:else}
<h4>Voice</h4>
<label for="p-vol" class="vol">Volume for me: {Math.round((voice.settings.userVolumes[userKey(user)] ?? 1) * 100)}%</label>
<input id="p-vol" type="range" min="0" max="3" step="0.05" value={voice.settings.userVolumes[userKey(user)] ?? 1}
oninput={e => voice.setUserVolume(user, Number(e.currentTarget.value))} />
<div class="actions">
<button class="btn" class:danger={!!voice.settings.localMutes[userKey(user)]} onclick={() => voice.setLocalMute(user, !voice.settings.localMutes[userKey(user)])}>
{voice.settings.localMutes[userKey(user)] ? 'Unmute for me' : 'Mute for me'}
</button>
<button class="btn" onclick={() => nicknamePrompt(user)}>Set nickname</button>
</div>
{/if}
<h4>Description</h4>
{#if editing}
<RichEditor value={draft} limit={session.htmlLimit} placeholder="Tell people about yourself" onchange={h => (draft = h)} />
<div class="actions">
<button class="btn primary" onclick={saveComment}>Save</button>
<button class="btn" onclick={() => (editing = false)}>Cancel</button>
</div>
{:else}
{#if comment}
<div class="html">{@html comment}</div>
{:else if pendingComment}
<p class="dim">Loading description...</p>
{:else}
<p class="dim">{isSelf ? 'You have no description yet.' : 'No description.'}</p>
{/if}
{#if isSelf}<div class="actions"><button class="btn" onclick={startEdit}>Edit description</button></div>{/if}
{/if}
{#if !isSelf}
<div class="actions">
<button class="btn primary" onclick={() => { session.selectView(`dm:${user.session}`); onnavigate(); }}>Send message</button>
<button class="btn" onclick={e => menu.show(e, userMenu(user))}>More</button>
</div>
{/if}
</div>
{/if}
</aside>
<style>
.panel { position: relative; max-width: 100%; height: 100%; flex: none; display: flex; flex-direction: column; background: var(--bg-1); border-left: 1px solid var(--line); min-height: 0; }
.panel.resizing { user-select: none; }
.resize { position: absolute; z-index: 5; top: 0; bottom: 0; left: -4px; width: 8px; cursor: col-resize; touch-action: none; }
.resize:hover, .resize:focus-visible, .resizing .resize { background: linear-gradient(to right, transparent 3px, var(--accent) 3px, var(--accent) 5px, transparent 5px); outline: none; }
header { height: 52px; flex: none; display: flex; align-items: center; gap: 8px; padding: 0 8px 0 14px; border-bottom: 1px solid var(--line); color: var(--text-dim); }
h2 { flex: 1; min-width: 0; margin: 0; font-size: 15px; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tabs { display: flex; flex: none; border-bottom: 1px solid var(--line); }
.tabs button { flex: 1; padding: 10px; color: var(--text-dim); font-weight: 600; font-size: 14px; display: inline-flex; justify-content: center; gap: 6px; }
.tabs button.active { color: var(--text); box-shadow: inset 0 -2px 0 var(--accent); }
.badge { background: var(--danger); color: #fff; font-size: 11px; padding: 0 5px; line-height: 16px; }
.body { flex: 1; overflow-y: auto; padding: 14px; }
.pad { padding: 14px; }
.dim { color: var(--text-dim); margin: 0; }
.small { font-size: 13px; margin-top: 8px; }
.html { overflow-wrap: anywhere; user-select: text; }
.html :global(img) { max-width: 100%; }
.html :global(p) { margin: 0 0 6px; }
.actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; align-items: center; }
.here { color: var(--speaking); font-weight: 600; font-size: 14px; }
.who { display: flex; gap: 14px; align-items: center; }
h3 { margin: 0; font-size: 18px; overflow-wrap: anywhere; }
h4 { margin: 22px 0 8px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-dim); }
.badges { display: flex; align-items: center; gap: 8px; margin: 6px 0 0; }
.badges .small { margin: 0; }
.badge-reg { font-size: 11px; font-weight: 700; padding: 1px 6px; border: 1px solid var(--line); color: var(--text-dim); }
.badge-reg.registered { border-color: var(--accent); background: var(--accent); color: var(--accent-text); }
.register { font-size: 12px; color: var(--link); }
.register:hover { text-decoration: underline; }
.vol { margin-top: 0; }
input[type=range] { padding: 0; border: 0; background: transparent; accent-color: var(--accent); }
.flags { display: flex; flex-wrap: wrap; gap: 4px; margin: 6px 0 0; }
.flags span { font-size: 11px; padding: 0 6px; border: 1px solid var(--line); color: var(--text-dim); }
.flags .bad { border-color: var(--danger); color: var(--danger); }
</style>
+127
View File
@@ -0,0 +1,127 @@
<script lang="ts">
import Icon from './Icon.svelte';
import { store, type SavedServer } from '../lib/settings.svelte.ts';
import { sessions } from '../lib/session.svelte.ts';
import { menu } from '../lib/menu.svelte.ts';
import { makeServerIcon } from '../lib/upload.ts';
let { onadd, onedit, onsettings }:
{ onadd: () => void; onedit: (s: SavedServer) => void; onsettings: () => void } = $props();
const initials = (label: string) =>
label.split(/[\s._-]+/).filter(Boolean).slice(0, 2).map(w => w[0]).join('').toUpperCase() || '?';
const stateOf = (id: string) => sessions.byServer(id)?.status ?? 'idle';
const unreadOf = (id: string) => {
const s = sessions.byServer(id);
return s && sessions.activeId !== id ? Object.values(s.unread).reduce((a, b) => a + b, 0) : 0;
};
// Drag and drop reordering
let dragging = $state<string | null>(null);
let dropIndex = $state<number | null>(null);
function onDragOver(e: DragEvent, i: number) {
if (!dragging) return;
e.preventDefault();
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
dropIndex = e.clientY < r.top + r.height / 2 ? i : i + 1;
}
function onDrop(e: DragEvent) {
e.preventDefault();
if (dragging && dropIndex !== null) {
const from = store.servers.findIndex(s => s.id === dragging);
store.moveServer(dragging, dropIndex > from ? dropIndex - 1 : dropIndex);
}
dragging = null;
dropIndex = null;
}
// Icon picked from the tile's context menu
let iconPicker: HTMLInputElement;
let iconFor = '';
async function pickIcon() {
const file = iconPicker.files?.[0];
iconPicker.value = '';
if (file && iconFor) store.setServerIcon(iconFor, await makeServerIcon(file).catch(() => undefined));
}
function serverMenu(e: MouseEvent, s: SavedServer, i: number) {
const state = stateOf(s.id);
menu.show(e, [
state === 'idle'
? { label: 'Connect', action: () => sessions.open(s) }
: { label: 'Disconnect', action: () => sessions.close(s.id) },
...(state === 'connected' && sessions.voiceId !== s.id ? [{ label: 'Talk here', action: () => { sessions.select(s.id); sessions.setVoice(sessions.byServer(s.id)!); } }] : []),
{ separator: true, label: '' },
{ label: 'Move up', disabled: i === 0, action: () => store.moveServer(s.id, i - 1) },
{ label: 'Move down', disabled: i === store.servers.length - 1, action: () => store.moveServer(s.id, i + 1) },
{ separator: true, label: '' },
{ label: 'Change icon...', action: () => { iconFor = s.id; iconPicker.click(); } },
...(s.icon ? [{ label: 'Remove icon', action: () => store.setServerIcon(s.id, undefined) }] : []),
{ label: 'Edit...', action: () => onedit(s) }
]);
}
</script>
<nav class="rail" aria-label="Servers" ondrop={onDrop} ondragover={e => { if (dragging) e.preventDefault(); }}>
{#each store.servers as s, i (s.id)}
{@const state = stateOf(s.id)}
{@const unread = unreadOf(s.id)}
{#if dropIndex === i && dragging}<div class="drop-line"></div>{/if}
<button class="tile" class:active={sessions.activeId === s.id} class:live={state === 'connected'}
class:connecting={state === 'connecting'} class:voice={sessions.voiceId === s.id && state === 'connected'}
class:dragged={dragging === s.id}
title="{s.label} ({s.host}){state === 'connected' ? (sessions.voiceId === s.id ? ', voice' : ', connected') : ''}"
draggable="true"
ondragstart={e => { dragging = s.id; e.dataTransfer?.setData('text/plain', s.id); if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move'; }}
ondragend={() => { dragging = null; dropIndex = null; }}
ondragover={e => onDragOver(e, i)}
onclick={() => sessions.open(s)}
oncontextmenu={e => serverMenu(e, s, i)}>
{#if s.icon}<img class="icon" src={s.icon} alt="" draggable="false" />{:else}{initials(s.label)}{/if}
{#if unread}<span class="unread">{unread > 99 ? '99+' : unread}</span>{/if}
</button>
{/each}
{#if dropIndex === store.servers.length && dragging}<div class="drop-line"></div>{/if}
<input bind:this={iconPicker} type="file" accept="image/*" hidden onchange={pickIcon} />
<button class="tile add" title="Add a server" onclick={onadd}><Icon name="plus" /></button>
<div class="spacer"></div>
<button class="tile ghost" title="Settings" onclick={onsettings}><Icon name="settings" /></button>
</nav>
<style>
.rail {
width: 68px; flex: none; background: var(--bg-0); display: flex; flex-direction: column;
align-items: center; gap: 8px; padding: 12px 0; overflow-y: auto;
}
.tile {
position: relative; width: 44px; height: 44px; flex: none; display: flex; align-items: center; justify-content: center;
background: var(--bg-3); font-weight: 700; font-size: 15px; transition: background 100ms, color 100ms;
}
.tile:hover { background: var(--accent); color: var(--accent-text); }
.tile.active { background: var(--accent); color: var(--accent-text); }
.tile.active::before {
content: ''; position: absolute; left: -12px; top: 8px; bottom: 8px; width: 4px; background: var(--text);
}
.tile.dragged { opacity: 0.4; }
.icon { width: 100%; height: 100%; object-fit: cover; display: block; pointer-events: none; }
/* Connection state: grey dot connecting, green connected, green with ring where voice runs */
.tile.live::after, .tile.connecting::after {
content: ''; position: absolute; right: -3px; bottom: -3px; width: 10px; height: 10px;
background: var(--speaking); border: 2px solid var(--bg-0);
}
.tile.connecting::after { background: var(--text-faint); }
.tile.voice::after { box-shadow: 0 0 0 2px var(--speaking); }
.unread {
position: absolute; right: -6px; top: -6px; min-width: 18px; padding: 0 4px; line-height: 16px;
font-size: 10px; background: var(--danger); color: #fff; border: 2px solid var(--bg-0);
}
.drop-line { width: 44px; height: 3px; flex: none; background: var(--accent); margin: -5px 0; }
.add { color: var(--speaking); background: var(--bg-2); }
.add:hover { background: var(--speaking); color: var(--bg-0); }
.ghost { background: transparent; color: var(--text-dim); }
.ghost:hover { background: var(--bg-3); color: var(--text); }
.spacer { flex: 1; }
</style>
+105
View File
@@ -0,0 +1,105 @@
<script lang="ts">
import Modal from './Modal.svelte';
import VoiceSettings from './VoiceSettings.svelte';
import IdentityList from './IdentityList.svelte';
import { store } from '../lib/settings.svelte.ts';
import { native } from '../lib/native.ts';
import { fetchUploadInfo } from '../lib/upload.ts';
let { onclose, tab: initialTab = 'voice' }: { onclose: () => void; tab?: 'voice' | 'files' | 'identities' } = $props();
// svelte-ignore state_referenced_locally
let tab = $state<'voice' | 'files' | 'identities'>(initialTab);
const s = store.settings;
let embedHosts = $state(s.embedHosts.join('\n'));
let testResult = $state('');
// Changes apply right away
function saveFiles() {
s.uploadHost = s.uploadHost.trim().replace(/\/+$/, '');
s.uploadKey = s.uploadKey.trim();
s.embedHosts = embedHosts.split(/[\s,]+/).map(h => h.trim()).filter(Boolean);
store.saveSettings();
}
async function test() {
saveFiles();
testResult = 'Checking...';
try {
const info = await fetchUploadInfo(s.uploadHost, s.uploadKey);
testResult = `Key works. Allowed: ${info.allowed_extensions.join(', ')} (max ${Math.round(info.max_bytes / 1048576)} MB)`;
} catch (e) {
testResult = (e as Error).message;
}
}
type Tab = 'voice' | 'files' | 'identities';
const tabs: { id: Tab; label: string }[] = [
{ id: 'voice', label: 'Voice' },
{ id: 'files', label: 'Chat and files' },
...(native ? [{ id: 'identities' as Tab, label: 'Identities' }] : [])
];
</script>
<Modal title="Settings" onclose={() => { saveFiles(); onclose(); }} width={640}>
<div class="tabs" role="tablist">
{#each tabs as t (t.id)}
<button role="tab" aria-selected={tab === t.id} class:active={tab === t.id} onclick={() => (tab = t.id)}>{t.label}</button>
{/each}
</div>
{#if tab === 'voice'}
<VoiceSettings />
{:else if tab === 'files'}
<p class="help">Files go to a f0ckm instance and are posted as links. Other mumh5 users see them inline; regular Mumble users get a link and a small preview. Without an upload host you can still send images, inline and scaled to the server's limit.</p>
<label for="s-host">Upload host</label>
<input id="s-host" bind:value={s.uploadHost} onchange={saveFiles} placeholder="https://f0ck.dev" />
<label for="s-key">Upload key</label>
<input id="s-key" type="password" bind:value={s.uploadKey} onchange={saveFiles} placeholder="cu_..." autocomplete="off" />
<div class="row">
<div>
<label for="s-exp">Files expire after</label>
<select id="s-exp" bind:value={s.uploadExpiry} onchange={saveFiles}>
<option value="1h">1 hour</option>
<option value="24h">24 hours</option>
<option value="7d">7 days</option>
<option value="30d">30 days</option>
</select>
</div>
<button class="btn" disabled={!s.uploadHost || !s.uploadKey} onclick={test}>Test key</button>
</div>
{#if testResult}<p class="help">{testResult}</p>{/if}
<label class="check"><input type="checkbox" bind:checked={s.sendThumbnails} onchange={saveFiles} /> Send image previews for regular Mumble clients</label>
<h3>Embeds</h3>
<label for="s-embed">Also show media inline from these hosts</label>
<textarea id="s-embed" bind:value={embedHosts} onchange={saveFiles} rows="3" placeholder="one host per line, e.g. i.example.com"></textarea>
<p class="help">Links from other hosts stay plain links, so opening a chat never contacts servers you did not choose.</p>
<label for="s-yt">YouTube links</label>
<select id="s-yt" bind:value={s.youtube} onchange={saveFiles}>
<option value="click">Player card, loads from YouTube when clicked</option>
<option value="thumbnail">Player card with thumbnail (loads the thumbnail from YouTube)</option>
<option value="off">Plain links</option>
</select>
{:else}
<p class="help">Servers recognize you by your certificate. The default is used unless a server is set to another one.</p>
<IdentityList />
{/if}
{#snippet footer()}
<button class="btn primary" onclick={() => { saveFiles(); onclose(); }}>Done</button>
{/snippet}
</Modal>
<style>
.tabs { display: flex; gap: 2px; margin: 8px 0 4px; border-bottom: 1px solid var(--line); overflow-x: auto; }
.tabs button { padding: 8px 14px; color: var(--text-dim); font-weight: 600; white-space: nowrap; }
.tabs button.active { color: var(--text); box-shadow: inset 0 -2px 0 var(--accent); }
h3 { margin: 22px 0 0; font-size: 14px; }
.help { margin: 10px 0 0; font-size: 13px; color: var(--text-dim); }
.row { display: flex; align-items: flex-end; gap: 8px; }
.row > div { flex: 1; }
.check { display: flex; align-items: center; gap: 8px; text-transform: none; font-weight: 400; letter-spacing: 0; font-size: 14px; color: var(--text); margin-top: 14px; }
.check input { width: auto; }
textarea { resize: vertical; }
</style>
+144
View File
@@ -0,0 +1,144 @@
<script lang="ts">
import Icon from './Icon.svelte';
import Avatar from './Avatar.svelte';
import ChannelNode from './ChannelNode.svelte';
import { session, sessions } from '../lib/session.svelte.ts';
import { ui } from '../lib/ui.svelte.ts';
import { menu } from '../lib/menu.svelte.ts';
import { userMenu } from '../lib/actions.ts';
import { voice } from '../lib/audio/voice.svelte.ts';
let { onnavigate }: { onnavigate: () => void } = $props();
const roots = $derived(session.childrenOf(null));
const self = $derived(session.self);
const cert = $derived(session.serverCert);
const trusted = $derived(!!cert?.authorized);
function openQuick(e: MouseEvent, kind: 'input' | 'output') {
e.preventDefault();
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
ui.quickAudio = { kind, x: r.left + r.width / 2, y: r.top };
}
function showServerCert() {
if (!cert?.chain.length) return;
ui.cert = {
title: `Certificate of ${session.server?.host}`,
chain: cert.chain,
status: trusted
? { ok: true, text: 'Valid certificate, signed by a trusted authority.' }
: { ok: false, text: `Not signed by a trusted authority (${cert.authError ?? 'unknown reason'}). mumh5 pinned this certificate the first time you connected and warns you if it changes.` }
};
}
const dms = $derived(
Object.keys(session.logs).filter(k => k.startsWith('dm:')).map(k => ({ key: k as `dm:${number}`, user: session.user(Number(k.slice(3))) }))
);
</script>
<aside class="sidebar">
<header class:trusted>
<button class="title" onclick={showServerCert} disabled={!cert} title={cert ? (trusted ? 'Valid certificate. Click to view' : 'Self-signed or untrusted certificate. Click to view') : ''}>
<strong>{#if cert}<Icon name={trusted ? 'lock' : 'shield'} size={14} />{/if}{session.server?.label ?? 'mumh5'}</strong>
<span class="status">
{#if session.status === 'connected'}
<span class="dot"></span>Connected{#if session.rtt} , {session.rtt} ms{/if}
{:else if session.status === 'connecting'}Connecting...
{:else}Not connected{/if}
</span>
</button>
{#if session.status !== 'idle'}
<button class="icon-btn" title="Disconnect" onclick={() => session.server && sessions.close(session.server.id)}><Icon name="power" size={18} /></button>
{/if}
</header>
<div class="scroll">
{#if session.status === 'connected'}
<ul class="tree">
{#each roots as c (c.id)}<ChannelNode channel={c} {onnavigate} />{/each}
</ul>
{#if dms.length}
<h3>Direct messages</h3>
<ul class="tree">
{#each dms as dm (dm.key)}
<li>
<button class="dm" class:viewing={session.view === dm.key}
onclick={() => { session.selectView(dm.key); onnavigate(); }}>
<Avatar name={dm.user?.name ?? '?'} size={22} />
<span class="label" class:offline={!dm.user}>{dm.user?.name ?? 'Offline user'}</span>
{#if session.unread[dm.key]}<span class="badge">{session.unread[dm.key]}</span>{/if}
</button>
</li>
{/each}
</ul>
{/if}
{:else if session.error}
<p class="error">{session.error}</p>
{:else if session.status === 'idle'}
<p class="hint">Pick a server on the left, or add one with the plus button.</p>
{/if}
</div>
{#if self && sessions.voiceSession && sessions.voiceSession !== sessions.active}
<div class="elsewhere">
<span>Voice is on <strong>{sessions.voiceSession.server?.label}</strong>, deafened here</span>
<button class="btn" onclick={() => sessions.setVoice(sessions.active)}>Talk here</button>
</div>
{/if}
{#if self}
<footer class="me">
<button class="profile" title="Your profile" onclick={() => { session.showUser(self.session); ui.panelOpen = true; }}
oncontextmenu={e => menu.show(e, userMenu(self))}>
<Avatar name={self.name} size={32} speaking={voice.transmitting} />
<span class="who">
<strong>{self.name}</strong>
<span>{voice.error ? 'Voice unavailable' : voice.settings.mode === 'ptt' && !self.selfMute ? (voice.pttDown ? 'Talking' : 'Push to talk') : session.channel(self.channelId)?.name || 'Root'}</span>
</span>
</button>
<button class="icon-btn" class:on={self.selfMute} title={self.selfMute ? 'Unmute (right-click for microphone options)' : 'Mute (right-click for microphone options)'}
aria-label={self.selfMute ? 'Unmute' : 'Mute'}
onclick={() => session.setSelfMute(!self.selfMute)} oncontextmenu={e => openQuick(e, 'input')}>
<Icon name={self.selfMute ? 'mic-off' : 'mic'} size={18} />
</button>
<button class="icon-btn" class:on={self.selfDeaf} title={self.selfDeaf ? 'Undeafen (right-click for output options)' : 'Deafen (right-click for output options)'}
aria-label={self.selfDeaf ? 'Undeafen' : 'Deafen'}
onclick={() => session.setSelfDeaf(!self.selfDeaf)} oncontextmenu={e => openQuick(e, 'output')}>
<Icon name={self.selfDeaf ? 'headphones-off' : 'headphones'} size={18} />
</button>
</footer>
{/if}
</aside>
<style>
.sidebar { width: 260px; flex: none; display: flex; flex-direction: column; background: var(--bg-1); min-height: 0; }
header { height: 52px; flex: none; display: flex; align-items: center; gap: 8px; padding: 0 8px 0 0; border-bottom: 1px solid var(--line); }
header.trusted { background: #1d5c38; border-bottom-color: #2a7a4c; }
header.trusted .status, header.trusted .icon-btn { color: #cfeedd; }
header.trusted .dot { background: #fff; }
.title { flex: 1; min-width: 0; align-self: stretch; display: flex; flex-direction: column; justify-content: center; padding-left: 14px; text-align: left; }
.title:disabled { opacity: 1; }
.title:hover:not(:disabled) { background: rgb(255 255 255 / 0.05); }
.title strong { display: flex; align-items: center; gap: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.status { font-size: 12px; color: var(--text-dim); display: flex; align-items: center; gap: 6px; }
.dot { width: 8px; height: 8px; background: var(--speaking); }
.scroll { flex: 1; overflow-y: auto; padding: 8px 6px; }
ul { list-style: none; margin: 0; padding: 0; }
h3 { font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-faint); margin: 18px 8px 6px; }
.dm { display: flex; align-items: center; gap: 8px; width: 100%; padding: 4px 8px; color: var(--text-dim); text-align: left; }
.dm:hover { background: var(--bg-hover); color: var(--text); }
.dm.viewing { background: var(--bg-active); color: var(--text); }
.label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.offline { color: var(--text-faint); font-style: italic; }
.badge { background: var(--danger); color: #fff; font-size: 11px; font-weight: 700; padding: 0 5px; line-height: 16px; }
.hint, .error { padding: 8px; color: var(--text-dim); font-size: 14px; }
.error { color: var(--danger); }
.elsewhere { flex: none; display: flex; align-items: center; gap: 8px; padding: 8px 10px; font-size: 12px; color: var(--text-dim); background: var(--bg-0); border-top: 1px solid var(--line); }
.elsewhere span { flex: 1; min-width: 0; }
.elsewhere .btn { padding: 4px 10px; font-size: 12px; }
.me { flex: none; display: flex; align-items: center; gap: 8px; padding: 8px; background: var(--bg-0); }
.profile { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; padding: 2px; text-align: left; }
.profile:hover { background: var(--bg-hover); }
.who { flex: 1; min-width: 0; display: flex; flex-direction: column; line-height: 1.2; }
.who strong, .who span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.who span { font-size: 12px; color: var(--text-dim); }
</style>
+149
View File
@@ -0,0 +1,149 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import Modal from './Modal.svelte';
import { session } from '../lib/session.svelte.ts';
import { store } from '../lib/settings.svelte.ts';
import { ui } from '../lib/ui.svelte.ts';
import { native } from '../lib/native.ts';
import { formatBytes } from '../lib/html.ts';
import type { CertDetails } from '../core/transport.ts';
let { target, onclose }: { target: number; onclose: () => void } = $props();
// The dialog is remounted per user ({#key} in App), so the session is fixed for its lifetime
// svelte-ignore state_referenced_locally
const who = target;
// The server fills in only what we may see: details for ourselves and admins,
// packet statistics for people in our channel, times and pings for everyone.
let stats = $state<any>(null);
let certs = $state<CertDetails[]>([]);
let updated = $state(0);
const client = session.client;
const user = $derived(session.user(who));
const off = client?.on('userStats', msg => {
if (msg.session !== who) return;
// Refreshes ask for statistics only; keep the details from the first answer
stats = stats && !msg.version ? { ...stats, ...msg, version: stats.version, certificates: stats.certificates, address: stats.address } : msg;
updated = Date.now();
if (msg.certificates?.length && !certs.length && native) {
native.describeCerts(msg.certificates).then(c => (certs = c));
}
});
client?.requestUserStats(who);
const timer = setInterval(() => client?.requestUserStats(who, true), 5000);
onDestroy(() => { off?.(); clearInterval(timer); });
function duration(secs?: number): string {
if (secs == null) return 'unknown';
const d = Math.floor(secs / 86400), h = Math.floor(secs / 3600) % 24, m = Math.floor(secs / 60) % 60, s = secs % 60;
return [d && `${d}d`, (d || h) && `${h}h`, (d || h || m) && `${m}m`, `${s}s`].filter(Boolean).join(' ');
}
function address(bytes?: Uint8Array): string {
if (!bytes?.length) return '';
const b = [...bytes];
// IPv4-mapped IPv6 (::ffff:a.b.c.d)
if (b.length === 16 && b.slice(0, 10).every(x => x === 0) && b[10] === 255 && b[11] === 255) return b.slice(12).join('.');
const groups = [];
for (let i = 0; i < b.length; i += 2) groups.push(((b[i] << 8) | b[i + 1]).toString(16));
return groups.join(':').replace(/(^|:)0(:0)+(:|$)/, '::');
}
const version = $derived.by(() => {
const v = stats?.version;
if (!v) return '';
const n = v.version_v2 ? Number(v.version_v2) : 0;
const num = n ? `${Math.floor(n / 2 ** 48)}.${Math.floor(n / 2 ** 32) % 65536}.${Math.floor(n / 2 ** 16) % 65536}`
: v.version_v1 ? `${v.version_v1 >> 16}.${(v.version_v1 >> 8) & 255}.${v.version_v1 & 255}` : '';
return [v.release, num && !String(v.release ?? '').includes(num) ? `(protocol ${num})` : ''].filter(Boolean).join(' ');
});
const ping = (avg?: number, v?: number) => avg ? `${avg.toFixed(1)} ms${v ? ` (variance ${Math.sqrt(v).toFixed(1)})` : ''}` : 'n/a';
const loss = (s: any) => {
const total = (s?.good ?? 0) + (s?.late ?? 0) + (s?.lost ?? 0);
return total ? `${(((s?.lost ?? 0) / total) * 100).toFixed(1)}%` : '-';
};
</script>
<Modal title="Information: {user ? store.displayName(user) : `#${target}`}" {onclose} width={560}>
{#if !stats}
<p class="dim">Asking the server...</p>
{:else}
<h3>Connection</h3>
<dl>
<dt>Online for</dt><dd>{duration(stats.onlinesecs)}</dd>
{#if stats.idlesecs != null}<dt>Idle for</dt><dd>{duration(stats.idlesecs)}</dd>{/if}
<dt>Ping (TCP)</dt><dd>{ping(stats.tcp_ping_avg, stats.tcp_ping_var)}</dd>
<dt>Ping (UDP)</dt><dd>{stats.udp_packets ? ping(stats.udp_ping_avg, stats.udp_ping_var) : 'not using UDP (voice over TCP)'}</dd>
<dt>Packets</dt><dd>{stats.tcp_packets ?? 0} TCP, {stats.udp_packets ?? 0} UDP</dd>
{#if stats.bandwidth != null}<dt>Bandwidth</dt><dd>{formatBytes(stats.bandwidth)}/s</dd>{/if}
{#if stats.address?.length}<dt>Address</dt><dd class="mono">{address(stats.address)}</dd>{/if}
</dl>
{#if stats.version}
<h3>Client</h3>
<dl>
<dt>Version</dt><dd>{version || 'unknown'}</dd>
{#if stats.version.os}<dt>System</dt><dd>{stats.version.os} {stats.version.os_version ?? ''}</dd>{/if}
<dt>Opus</dt><dd>{stats.opus ? 'supported' : 'no (legacy codecs only)'}</dd>
</dl>
{/if}
{#if stats.from_client || stats.from_server}
<h3>Voice packets</h3>
<table>
<thead><tr><th></th><th>Good</th><th>Late</th><th>Lost</th><th>Resync</th><th>Loss</th></tr></thead>
<tbody>
<tr><th>From client</th><td>{stats.from_client?.good ?? 0}</td><td>{stats.from_client?.late ?? 0}</td><td>{stats.from_client?.lost ?? 0}</td><td>{stats.from_client?.resync ?? 0}</td><td>{loss(stats.from_client)}</td></tr>
<tr><th>To client</th><td>{stats.from_server?.good ?? 0}</td><td>{stats.from_server?.late ?? 0}</td><td>{stats.from_server?.lost ?? 0}</td><td>{stats.from_server?.resync ?? 0}</td><td>{loss(stats.from_server)}</td></tr>
</tbody>
</table>
<p class="dim small">Counted on the encrypted UDP channel; clients sending voice over TCP show zeros.</p>
{/if}
{#if stats.certificates?.length}
<h3>Certificate</h3>
<dl>
<dt>Subject</dt><dd>{certs[0]?.subject.replace(/\n/g, ', ') ?? '...'}</dd>
<dt>Verified</dt><dd>{stats.strong_certificate ? 'yes, signed by a trusted authority' : 'no, self-signed'}</dd>
</dl>
{#if certs.length}
<button class="btn cert" onclick={() => (ui.cert = { title: `Certificate of ${user?.name ?? target}`, chain: certs })}>View certificate</button>
{/if}
{:else if user?.hash}
<h3>Certificate</h3>
<dl><dt>SHA-1</dt><dd class="mono">{user.hash}</dd></dl>
<p class="dim small">Only admins and the user themselves can see the full certificate.</p>
{/if}
{#if !stats.version}
<p class="dim small note">Client details, address and certificate are only shown to admins and to the user themselves.</p>
{/if}
{/if}
{#snippet footer()}
{#if updated}<span class="dim small upd">Updates every 5 seconds</span>{/if}
<button class="btn primary" onclick={onclose}>Close</button>
{/snippet}
</Modal>
<style>
h3 { margin: 18px 0 6px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-dim); }
h3:first-child { margin-top: 8px; }
dl { display: grid; grid-template-columns: 130px 1fr; gap: 4px 12px; margin: 0; font-size: 14px; }
dt { color: var(--text-faint); }
dd { margin: 0; overflow-wrap: anywhere; user-select: text; }
.mono { font: 12px var(--mono); }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { padding: 4px 6px; text-align: right; border-bottom: 1px solid var(--line); }
th:first-child { text-align: left; color: var(--text-faint); font-weight: 400; }
thead th { color: var(--text-faint); font-weight: 600; }
.dim { color: var(--text-dim); margin: 0; }
.small { font-size: 12px; margin-top: 6px; }
.note { margin-top: 16px; }
.cert { margin-top: 10px; }
.upd { margin-right: auto; align-self: center; }
@media (max-width: 500px) { dl { grid-template-columns: 1fr; } dt { margin-top: 6px; } }
</style>
+173
View File
@@ -0,0 +1,173 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { voice, type TransmitMode } from '../lib/audio/voice.svelte.ts';
import { session } from '../lib/session.svelte.ts';
const s = voice.settings;
let capturing = $state(false);
// Opening the settings starts audio so the meter and device names work before connecting
voice.start();
onDestroy(() => { if (voice.testing) voice.setTesting(false); if (session.status !== 'connected') voice.stop(); });
const METER_MIN = -80;
const pct = (db: number) => Math.max(0, Math.min(100, ((db - METER_MIN) / -METER_MIN) * 100));
const open = $derived(voice.level > s.vadThreshold);
const keyName = (code: string) => code
.replace(/^Key/, '').replace(/^Digit/, '').replace(/^Numpad/, 'Num ')
.replace('Backquote', 'Backtick (`)').replace(/([a-z])([A-Z])/g, '$1 $2');
function captureKey(e: KeyboardEvent) {
if (!capturing) return;
e.preventDefault();
e.stopPropagation();
if (e.code !== 'Escape') voice.update({ pttKey: e.code });
capturing = false;
}
const modes: { id: TransmitMode; label: string; help: string }[] = [
{ id: 'vad', label: 'Voice activity', help: 'Sends when you speak louder than the threshold' },
{ id: 'ptt', label: 'Push to talk', help: 'Sends while you hold the key' },
{ id: 'continuous', label: 'Always on', help: 'Sends all the time' }
];
</script>
<svelte:window onkeydowncapture={captureKey} />
<div class="voice">
{#if voice.error}<p class="error">{voice.error}</p>{/if}
<h3>Devices</h3>
<div class="grid">
<div>
<label for="v-in">Microphone</label>
<select id="v-in" value={s.inputDevice} onchange={e => voice.update({ inputDevice: e.currentTarget.value })}>
{#each voice.inputDevices as d (d.id)}<option value={d.id}>{d.label}</option>{:else}<option value="default">System default</option>{/each}
</select>
</div>
<div>
<label for="v-out">Speakers / headphones</label>
<select id="v-out" value={s.outputDevice} onchange={e => voice.update({ outputDevice: e.currentTarget.value })}>
{#each voice.outputDevices as d (d.id)}<option value={d.id}>{d.label}</option>{:else}<option value="default">System default</option>{/each}
</select>
</div>
</div>
<h3>Transmit</h3>
<div class="modes" role="radiogroup" aria-label="Transmit mode">
{#each modes as m (m.id)}
<button role="radio" aria-checked={s.mode === m.id} class:active={s.mode === m.id} onclick={() => voice.update({ mode: m.id })}>
<strong>{m.label}</strong><small>{m.help}</small>
</button>
{/each}
</div>
{#if s.mode === 'ptt'}
<label for="v-ptt">Push-to-talk key</label>
<div class="row">
<button id="v-ptt" class="btn key" class:capturing onclick={() => (capturing = true)}>
{capturing ? 'Press a key... (Esc cancels)' : keyName(s.pttKey)}
</button>
<span class="hint" class:on={voice.pttDown}>{voice.pttDown ? 'Talking' : 'Hold the key to talk'}</span>
</div>
<p class="help">Works while mumh5 is focused. System-wide push to talk is planned.</p>
{/if}
<label for="v-level">Input level{s.mode === 'vad' ? ' and threshold' : ''}</label>
<div class="meter" class:open={s.mode !== 'vad' || open} aria-hidden="true">
<div class="fill" style:width="{pct(voice.level)}%"></div>
{#if s.mode === 'vad'}<div class="mark" style:left="{pct(s.vadThreshold)}%"></div>{/if}
</div>
{#if s.mode === 'vad'}
<input id="v-level" type="range" min={METER_MIN} max="0" step="1" value={s.vadThreshold}
oninput={e => voice.update({ vadThreshold: Number(e.currentTarget.value) })} aria-label="Voice activity threshold" />
<p class="help">Speak normally and put the marker just below your voice; the bar turns green when you would be sending. Threshold {s.vadThreshold} dB.</p>
<label for="v-hold">Keep sending after speech: {s.vadHoldMs} ms</label>
<input id="v-hold" type="range" min="100" max="1500" step="50" value={s.vadHoldMs}
oninput={e => voice.update({ vadHoldMs: Number(e.currentTarget.value) })} />
{/if}
<div class="grid">
<div>
<label for="v-gain">Microphone volume: {Math.round(s.inputGain * 100)}%</label>
<input id="v-gain" type="range" min="0" max="3" step="0.05" value={s.inputGain}
oninput={e => voice.update({ inputGain: Number(e.currentTarget.value) })} />
</div>
<div>
<label for="v-vol">Output volume: {Math.round(s.outputVolume * 100)}%</label>
<input id="v-vol" type="range" min="0" max="2" step="0.05" value={s.outputVolume}
oninput={e => voice.update({ outputVolume: Number(e.currentTarget.value) })} />
</div>
</div>
<div class="row test">
<button class="btn" class:primary={voice.testing} onclick={() => voice.setTesting(!voice.testing)}>
{voice.testing ? 'Stop test' : 'Test microphone'}
</button>
<span class="help">{voice.testing ? 'You should hear yourself. Use headphones to avoid feedback.' : 'Plays your microphone back to you'}</span>
</div>
<h3>Processing</h3>
<label class="check"><input type="checkbox" checked={s.noiseSuppression} onchange={e => voice.update({ noiseSuppression: e.currentTarget.checked })} /> Noise suppression</label>
<label class="check"><input type="checkbox" checked={s.echoCancellation} onchange={e => voice.update({ echoCancellation: e.currentTarget.checked })} /> Echo cancellation (for speakers instead of headphones)</label>
<label class="check"><input type="checkbox" checked={s.autoGainControl} onchange={e => voice.update({ autoGainControl: e.currentTarget.checked })} /> Automatic gain control</label>
<h3>Quality</h3>
<div class="grid">
<div>
<label for="v-br">Bitrate: {Math.round(s.bitrate / 1000)} kbit/s</label>
<input id="v-br" type="range" min="8000" max="128000" step="4000" value={s.bitrate}
oninput={e => voice.update({ bitrate: Number(e.currentTarget.value) })} />
{#if voice.effectiveBitrate && voice.effectiveBitrate < s.bitrate && session.status === 'connected'}
<p class="help warn">Limited to {Math.round(voice.effectiveBitrate / 1000)} kbit/s by this server</p>
{/if}
</div>
<div>
<label for="v-frame">Audio per packet</label>
<select id="v-frame" value={String(s.frameMs)} onchange={e => voice.update({ frameMs: Number(e.currentTarget.value) as 10 | 20 | 40 | 60 })}>
<option value="10">10 ms (lowest latency)</option>
<option value="20">20 ms (recommended)</option>
<option value="40">40 ms</option>
<option value="60">60 ms (least bandwidth)</option>
</select>
</div>
</div>
<label for="v-jit">Jitter buffer: {s.jitterMs} ms</label>
<input id="v-jit" type="range" min="20" max="300" step="10" value={s.jitterMs}
oninput={e => voice.update({ jitterMs: Number(e.currentTarget.value) })} />
<p class="help">Higher values smooth out bad connections at the cost of delay.</p>
{#if session.status === 'connected'}
<h3>Statistics</h3>
<p class="help stats">Sent {voice.stats.sent} packets, received {voice.stats.received}, decoded {voice.stats.decoded}, gaps {voice.stats.lost}. Transport: TCP tunnel.</p>
{/if}
</div>
<style>
h3 { margin: 22px 0 0; font-size: 14px; }
h3:first-of-type { margin-top: 8px; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 12px; }
@media (max-width: 560px) { .grid { grid-template-columns: 1fr; } }
.modes { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin-top: 8px; }
@media (max-width: 560px) { .modes { grid-template-columns: 1fr; } }
.modes button { display: flex; flex-direction: column; gap: 2px; padding: 10px; text-align: left; background: var(--bg-0); border: 1px solid var(--line); }
.modes button.active { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
.modes small { color: var(--text-dim); font-size: 12px; }
.row { display: flex; align-items: center; gap: 12px; }
.key { min-width: 160px; }
.key.capturing { border-color: var(--accent); color: var(--accent-hover); }
.hint { font-size: 13px; color: var(--text-dim); }
.hint.on { color: var(--speaking); font-weight: 600; }
.meter { position: relative; height: 12px; background: var(--bg-0); border: 1px solid var(--line); overflow: hidden; }
.fill { height: 100%; background: var(--text-faint); transition: width 50ms linear; }
.meter.open .fill { background: var(--speaking); }
.mark { position: absolute; top: -1px; bottom: -1px; width: 2px; background: var(--warn); }
input[type=range] { padding: 0; border: 0; background: transparent; accent-color: var(--accent); }
.test { margin-top: 14px; }
.check { display: flex; align-items: center; gap: 8px; text-transform: none; font-weight: 400; letter-spacing: 0; font-size: 14px; color: var(--text); margin-top: 10px; }
.check input { width: auto; }
.help { margin: 6px 0 0; font-size: 13px; color: var(--text-dim); }
.warn { color: var(--warn); }
.error { color: var(--danger); margin: 8px 0 0; }
</style>
+42
View File
@@ -0,0 +1,42 @@
<script lang="ts">
import Icon from './Icon.svelte';
import { store } from '../lib/settings.svelte.ts';
let { videoId, start = 0, url }: { videoId: string; start?: number; url: string } = $props();
// Nothing is loaded from YouTube until the user clicks (unless thumbnails are enabled)
let playing = $state(false);
const src = $derived(`https://www.youtube-nocookie.com/embed/${videoId}?autoplay=1&rel=0${start ? `&start=${start}` : ''}`);
const thumb = $derived(store.settings.youtube === 'thumbnail' ? `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg` : '');
</script>
<div class="yt">
{#if playing}
<iframe {src} title="YouTube video" allow="autoplay; encrypted-media; picture-in-picture; fullscreen" allowfullscreen
referrerpolicy="strict-origin-when-cross-origin" sandbox="allow-scripts allow-same-origin allow-presentation allow-popups"></iframe>
{:else}
<button class="facade" style:background-image={thumb ? `url(${thumb})` : undefined} onclick={() => (playing = true)}
title="Play here (loads from YouTube)">
<span class="play"><svg width="28" height="28" viewBox="0 0 24 24" aria-hidden="true"><path d="M7 4l14 8-14 8z" fill="currentColor" /></svg></span>
<span class="label">
<strong>YouTube</strong>
<span>{start ? `from ${Math.floor(start / 60)}:${String(start % 60).padStart(2, '0')}, ` : ''}click to play</span>
</span>
</button>
{/if}
<a class="open" href={url} target="_blank" rel="noopener noreferrer" title="Open on YouTube"><Icon name="link" size={14} /></a>
</div>
<style>
.yt { position: relative; width: min(480px, 100%); aspect-ratio: 16 / 9; background: #000; border: 1px solid var(--line); }
iframe { display: block; width: 100%; height: 100%; border: 0; }
.facade { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px;
background: #111 center / cover no-repeat; color: #fff; }
.facade::before { content: ''; position: absolute; inset: 0; background: linear-gradient(transparent 40%, rgb(0 0 0 / 0.7)); }
.play { position: relative; width: 64px; height: 44px; display: flex; align-items: center; justify-content: center; background: #e0443e; transition: transform 100ms; }
.facade:hover .play { transform: scale(1.08); }
.label { position: relative; display: flex; flex-direction: column; align-items: center; font-size: 13px; text-shadow: 0 1px 2px #000; }
.label span { color: #ddd; }
.open { position: absolute; top: 6px; right: 6px; width: 26px; height: 26px; display: flex; align-items: center; justify-content: center; background: rgb(0 0 0 / 0.6); color: #fff; }
.open:hover { background: rgb(0 0 0 / 0.85); text-decoration: none; }
</style>
+3
View File
@@ -0,0 +1,3 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
export default { preprocess: vitePreprocess() };
+561
View File
@@ -0,0 +1,561 @@
// Drives the built Electron app against a real Mumble server, with a headless second client.
// npm run build && MUMBLE_TEST_HOST=localhost:64739 npm run test:e2e
// Optional: UPLOAD_HOST + UPLOAD_KEY to also test file sharing, SHOTS_DIR to save screenshots.
import { _electron as electron } from 'playwright-core';
import electronPath from 'electron';
import assert from 'node:assert/strict';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import zlib from 'node:zlib';
import { createCodec } from '../../src/core/proto.ts';
import { encodeVoice, decodeVoice, type VoicePacket } from '../../src/core/voice-packet.ts';
import { MumbleClient } from '../../src/core/client.ts';
import type { Transport } from '../../src/core/transport.ts';
import { openTls } from '../../electron/tls-transport.ts';
import { generateIdentity } from '../../electron/identity.ts';
const target = process.env.MUMBLE_TEST_HOST;
if (!target) {
console.log('MUMBLE_TEST_HOST not set, skipping');
process.exit(0);
}
const [host, port] = target.split(':');
const shots = process.env.SHOTS_DIR;
const root = path.resolve(import.meta.dirname, '../..');
function headless(username: string, password = ''): Promise<MumbleClient> {
const codec = createCodec();
const id = generateIdentity(username);
const client = new MumbleClient(codec);
const t: Transport = { onData: null, onClose: null, send: b => conn.send(b), close: () => conn.close() };
const conn = openTls(host, Number(port), id.certPem, id.keyPem, {
onSecure: () => {}, onData: c => t.onData?.(c), onClose: r => t.onClose?.(r)
});
client.connect(t, { username, password, os: 'test' });
return new Promise((res, rej) => { client.on('synced', () => res(client)); client.on('close', rej); });
}
// Noisy PNG, no image library needed. Noise keeps the JPEG preview big enough (over the
// server's 5000-char text limit) to exercise Murmur's XML check on image messages.
function testPng(w = 400, h = 300): Buffer {
const raw = Buffer.alloc((w * 3 + 1) * h);
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
raw.set([(x * 7 + Math.random() * 90) & 255, (y * 5 + Math.random() * 90) & 255, Math.random() * 255], y * (w * 3 + 1) + 1 + x * 3);
}
const chunk = (type: string, data: Buffer) => {
const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
const td = Buffer.concat([Buffer.from(type), data]);
const crc = Buffer.alloc(4); crc.writeUInt32BE(zlib.crc32(td));
return Buffer.concat([len, td, crc]);
};
const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr.set([8, 2, 0, 0, 0], 8);
return Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), chunk('IHDR', ihdr), chunk('IDAT', zlib.deflateSync(raw)), chunk('IEND', Buffer.alloc(0))]);
}
// Fails with a label instead of hanging when an expected event never comes
function within<T>(p: Promise<T>, label: string, ms = 10000): Promise<T> {
return Promise.race([p, new Promise<T>((_, rej) => setTimeout(() => rej(new Error(`Timed out waiting for: ${label}`)), ms))]);
}
const { ELECTRON_RUN_AS_NODE, ...env } = process.env;
const userData = mkdtempSync(path.join(tmpdir(), 'mumh5-e2e-'));
const app = await electron.launch({
executablePath: electronPath as unknown as string,
// Stay on $DISPLAY (e.g. Xvfb); Electron would otherwise pick the Wayland session automatically
// Fake microphone (a beeping tone) and auto-granted permission for the voice tests
args: [root, `--user-data-dir=${userData}`, '--ozone-platform=x11', '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'],
env: env as Record<string, string>
});
const bob = await headless('bob');
// SuperUser can create channels; needs MUMBLE_SUPERUSER_PASSWORD on the test server
const admin = process.env.MUMBLE_SUPERUSER_PASSWORD ? await headless('SuperUser', process.env.MUMBLE_SUPERUSER_PASSWORD) : null;
try {
const page = await app.firstWindow();
page.on('console', m => { if (m.type() === 'error') console.log('[renderer]', m.text()); });
await page.setViewportSize({ width: 1280, height: 800 });
// First start: identity setup wizard
await page.getByRole('dialog', { name: 'Set up your identity' }).waitFor();
await page.getByRole('button', { name: /Create a new identity/ }).click();
await page.getByLabel('Name').fill('alice');
await page.getByRole('button', { name: 'Create', exact: true }).click();
await page.getByRole('button', { name: 'Skip for now' }).click();
await page.getByRole('button', { name: 'Done' }).click();
await page.getByRole('dialog', { name: 'Set up your identity' }).waitFor({ state: 'detached' });
console.log('ok: identity wizard creates an identity');
// Settings: upload host + key
if (process.env.UPLOAD_HOST && process.env.UPLOAD_KEY) {
await page.getByTitle('Settings').click();
await page.getByRole('tab', { name: 'Chat and files' }).click();
await page.getByLabel('Upload host').fill(process.env.UPLOAD_HOST);
await page.getByLabel('Upload key').fill(process.env.UPLOAD_KEY);
await page.getByRole('button', { name: 'Test key' }).click();
await page.getByText(/^Key works\./).waitFor();
await page.getByRole('button', { name: 'Done', exact: true }).click();
}
// Add and connect to the server
await page.getByTitle('Add a server').click();
await page.getByLabel('Address').fill(host);
await page.getByLabel('Port').fill(port);
await page.getByLabel('Username').fill('alice');
await page.getByLabel('Label').fill('Test Server');
await page.getByRole('button', { name: 'Save and connect' }).click();
await page.getByText(/Connected/).first().waitFor();
console.log('ok: connected');
// Bob is listed in the channel tree
await page.locator('.sidebar').getByText('bob').waitFor();
console.log('ok: sees bob');
// ─── Voice ───────────────────────────────────────────────────────────────
const aliceSession = () => [...bob.users.values()].find(u => u.name === 'alice')!.session;
const collect = (n: number, stopOnLast = false) => new Promise<VoicePacket[]>(res => {
const got: VoicePacket[] = [];
const off = bob.on('voice', raw => {
const p = decodeVoice(raw);
if (!p || p.session !== aliceSession()) return;
got.push(p);
if (got.length >= n || (stopOnLast && p.last)) { off(); res(got); }
});
});
const setMode = async (label: string) => {
await page.getByTitle('Settings').click();
await page.getByRole('radio', { name: new RegExp(label) }).click();
await page.getByRole('button', { name: 'Done', exact: true }).click();
};
// Always on: a steady stream of 20 ms Opus packets
const stream = collect(25);
await setMode('Always on');
const packets = await within(stream, 'voice packets from the app');
assert.ok(packets.every(p => p.opus.length > 0 && !p.last));
const steps = packets.slice(1).map((p, i) => p.frame - packets[i].frame);
assert.ok(steps.every(d => d === 2), `sequence advances 2 frames per 20 ms packet: ${steps.join(',')}`);
console.log(`ok: app sends Opus voice (${packets[0].opus.length} bytes per packet)`);
// Echo alice's own audio back as bob: the app decodes it and shows bob speaking
const echo = packets.map((p, i) => encodeVoice({ target: 0, frame: i * 2, opus: p.opus, last: i === packets.length - 1 }, bob.protobufVoice));
const bobRow = page.locator('.sidebar .user', { hasText: 'bob' });
const pump = setInterval(() => { const pk = echo.shift(); if (pk) bob.sendVoiceTunnel(pk); }, 20);
await bobRow.locator('.avatar.speaking').waitFor({ timeout: 3000 });
console.log('ok: incoming voice decoded, speaker highlighted');
await bobRow.locator('.avatar.speaking').waitFor({ state: 'detached', timeout: 3000 });
clearInterval(pump);
console.log('ok: speaking indicator clears after the terminator');
// Push to talk: silent until the key is held, terminator on release
const ended = collect(1000, true);
await setMode('Push to talk');
const tail = await within(ended, 'terminator after leaving always-on');
assert.equal(tail.at(-1)!.last, true);
let leaked = 0;
const offLeak = bob.on('voice', raw => { if (decodeVoice(raw)?.session === aliceSession()) leaked++; });
await page.waitForTimeout(400);
offLeak();
assert.equal(leaked, 0, 'no voice while the push-to-talk key is up');
await page.locator('.chat h1').click();
const ptt = collect(1000, true);
await page.keyboard.down('Backquote');
await page.waitForTimeout(300);
await page.keyboard.up('Backquote');
const burst = await within(ptt, 'push-to-talk burst');
assert.ok(burst.length >= 8 && burst.length <= 30, `about 300 ms of audio (${burst.length} packets)`);
assert.equal(burst.at(-1)!.last, true);
console.log(`ok: push to talk (${burst.length} packets, ends with terminator)`);
// Voice activity with the fake beeping microphone: transmits in bursts
await setMode('Voice activity');
const vad = await within(collect(10), 'voice activity packets', 8000);
assert.ok(vad.length === 10);
const biggest = Math.max(...vad.map(p => p.opus.length));
assert.ok(biggest > 40, `packets during the tone carry real audio (largest ${biggest} bytes)`);
console.log(`ok: voice activity detection transmits on the tone (up to ${biggest} bytes per packet)`);
// The app's own statistics confirm it decoded the echoed audio
await page.getByTitle('Settings').click();
const statsText = await page.locator('.stats').textContent();
const decoded = Number(/decoded (\d+)/.exec(statsText ?? '')?.[1]);
assert.ok(decoded >= 20, `decoded frames reported: ${statsText}`);
await page.getByRole('button', { name: 'Done', exact: true }).click();
console.log(`ok: ${decoded} frames decoded`);
await setMode('Push to talk');
await page.waitForTimeout(300);
// Alice -> channel
const gotText = new Promise<string>(res => bob.on('text', m => res(m.html)));
await page.getByLabel('Message').fill('hello <from> alice\nsecond line https://example.com/x');
await page.getByLabel('Message').press('Enter');
const html = await within(gotText, 'channel text');
assert.equal(html, 'hello &lt;from&gt; alice<br/>second line <a href="https://example.com/x">https://example.com/x</a>');
console.log('ok: bob received channel text');
// Bob -> channel, rendered sanitized
bob.sendText({ channels: [0] }, 'hi alice <b>bold</b><script>window.pwned=1</script><img src="https://evil.example/t.png">');
await page.locator('.msg', { hasText: 'hi alice' }).waitFor();
assert.equal(await page.evaluate(() => (window as any).pwned), undefined);
assert.equal(await page.locator('.msg b', { hasText: 'bold' }).count(), 1);
assert.equal(await page.locator('img[src^="https://evil"]').count(), 0);
console.log('ok: incoming text rendered and sanitized');
// YouTube links become a click-to-play card; nothing loads from YouTube before the click
bob.sendText({ channels: [0] }, 'watch <a href="https://youtu.be/dQw4w9WgXcQ?t=1m30s">https://youtu.be/dQw4w9WgXcQ?t=1m30s</a>');
const card = page.locator('.msg', { hasText: 'watch' }).locator('.yt');
await card.getByText('from 1:30, click to play').waitFor();
assert.equal(await card.locator('iframe').count(), 0);
assert.equal(await card.locator('.facade').evaluate(el => getComputedStyle(el).backgroundImage), 'none', 'no thumbnail request by default');
await card.locator('.facade').click();
assert.equal(await card.locator('iframe').getAttribute('src'), 'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?autoplay=1&rel=0&start=90');
console.log('ok: YouTube link embedded as click-to-play');
// Bob -> DM
const alice = [...bob.users.values()].find(u => u.name === 'alice')!;
bob.sendText({ users: [alice.session] }, 'psst');
await page.locator('.sidebar').getByText('Direct messages').waitFor();
await page.locator('.dm', { hasText: 'bob' }).click();
await page.locator('.msg', { hasText: 'psst' }).waitFor();
console.log('ok: direct message shown in its own view');
await page.locator('.channel .name').first().dblclick();
// Mute toggles propagate
const muted = new Promise<boolean>(res => bob.on('user', (u, changed) => { if (u.name === 'alice' && changed.includes('selfMute')) res(u.selfMute); }));
await page.locator('.me').getByRole('button', { name: 'Mute', exact: true }).click();
assert.equal(await within(muted, 'mute state'), true);
await page.locator('.me').getByRole('button', { name: 'Unmute', exact: true }).waitFor();
// Right-click on mute / deafen opens quick audio options
await page.locator('.me').getByRole('button', { name: 'Unmute', exact: true }).click({ button: 'right' });
const micPop = page.getByRole('dialog', { name: 'Microphone' });
await micPop.getByLabel('Microphone').waitFor();
await micPop.getByRole('radio', { name: 'Always on' }).waitFor();
await micPop.getByLabel(/Input volume/).fill('1.5');
await micPop.getByText('Input volume: 150%').waitFor();
await page.keyboard.press('Escape');
await micPop.waitFor({ state: 'detached' });
await page.locator('.me').getByRole('button', { name: 'Deafen', exact: true }).click({ button: 'right' });
const outPop = page.getByRole('dialog', { name: 'Output' });
await outPop.getByLabel('Output device').waitFor();
await outPop.getByRole('button', { name: 'Voice settings...' }).click();
await page.getByRole('tab', { name: 'Voice', selected: true }).waitFor();
await page.getByText('Microphone volume: 150%').waitFor();
await page.getByRole('button', { name: 'Done', exact: true }).click();
console.log('ok: quick audio options on right-click');
// Tray icon mirrors the voice state; its menu acts on it
const trayState = () => app.evaluate(() => (globalThis as any).__mumh5Tray && {
tooltip: (globalThis as any).__mumh5Tray.tooltip, items: (globalThis as any).__mumh5Tray.items, icon: (globalThis as any).__mumh5Tray.state.icon
});
const trayUntil = async (label: string, pred: (t: any) => boolean) => {
for (let i = 0; i < 50; i++) { const t = await trayState(); if (t && pred(t)) return t; await page.waitForTimeout(100); }
throw new Error(`Tray never reached: ${label} (${JSON.stringify(await trayState())})`);
};
const tMuted = await trayUntil('muted', t => /muted on Test Server/.test(t.tooltip));
assert.equal(tMuted.icon, 'data:image/png;base64,');
assert.equal(tMuted.items.find((i: any) => i.label === 'Mute').checked, true);
await app.evaluate(() => (globalThis as any).__mumh5Tray.click('Mute'));
await within(new Promise<void>(res => bob.on('user', (u, ch) => { if (u.name === 'alice' && ch.includes('selfMute') && !u.selfMute) res(); })), 'unmuted from the tray');
await trayUntil('connected', t => /connected on Test Server/.test(t.tooltip) && !t.items.find((i: any) => i.label === 'Mute').checked);
await app.evaluate(() => (globalThis as any).__mumh5Tray.click('Mute'));
await trayUntil('muted again', t => /muted/.test(t.tooltip));
console.log('ok: tray icon follows voice state and its menu mutes');
await page.locator('.channel + .users, .users').getByTitle('Muted', { exact: true }).first().waitFor();
console.log('ok: self mute propagated and shown');
if (process.env.UPLOAD_HOST) {
const png = path.join(userData, 'gradient.png');
writeFileSync(png, testPng());
const gotUpload = new Promise<string>(res => bob.on('text', m => res(m.html)));
await page.locator('.chat .composer input[type=file]').setInputFiles(png);
const up = await within(gotUpload, 'upload message');
assert.match(up, /<a href="http[^"]+\/cu\/[A-Za-z0-9_-]{12}\/gradient\.png">gradient\.png<\/a>/);
assert.match(up, /<br\/><img src="data:image\/jpeg;base64,[^"]+"\/>$/);
assert.ok(up.length > 5000, `preview should exceed the text limit (${up.length})`);
await page.locator('.embed img').waitFor();
await page.waitForFunction(() => (document.querySelector('.embed img') as HTMLImageElement)?.naturalWidth === 400);
console.log('ok: upload posted with vanilla thumbnail, embed loads for mumh5');
// Types the upload host does not allow are refused before uploading
const txt = path.join(userData, 'notes.txt');
writeFileSync(txt, 'plain text');
let leaked = false;
const off = bob.on('text', () => { leaked = true; });
await page.locator('.chat .composer input[type=file]').setInputFiles(txt);
await page.getByText('notes.txt: file type not allowed on this upload host').waitFor();
await page.waitForTimeout(500);
off();
assert.equal(leaked, false);
console.log('ok: disallowed file type refused');
}
// Server certificate viewer from the header (the Docker server is self-signed, so no green header)
assert.equal(await page.locator('.sidebar header.trusted').count(), 0);
await page.locator('.sidebar header .title').click();
await page.getByRole('dialog', { name: /Certificate of/ }).getByText('self-signed').waitFor();
await page.getByRole('button', { name: 'Close', exact: true }).last().click();
console.log('ok: server certificate viewer');
// Own description: rich text with a picture, as Mumble-compatible XHTML
await page.locator('.me .profile').click();
await page.getByRole('button', { name: 'Edit description' }).click();
const area = page.locator('.rich .area');
await area.click();
await page.keyboard.type('I test things ');
await page.keyboard.press('Control+b');
await page.keyboard.type('bold');
await page.keyboard.press('Control+b');
await page.keyboard.press('Enter');
await page.keyboard.type('second line');
const pic = path.join(userData, 'avatar.png');
writeFileSync(pic, testPng(300, 200));
await page.locator('.rich input[type=file]').setInputFiles(pic);
await area.locator('img').waitFor();
const aliceId = [...bob.users.values()].find(u => u.name === 'alice')!.session;
const commented = new Promise<string>(res => bob.on('user', (u, changed) => {
if (u.session !== aliceId) return;
if (changed.includes('comment') && u.comment) res(u.comment);
// Long comments are announced by hash only; fetch the text
else if (changed.includes('commentHash')) bob.requestBlob({ comments: [aliceId] });
}));
await page.getByRole('button', { name: 'Save', exact: true }).click();
const comment = await within(commented, 'comment update');
assert.match(comment, /^<p>I test things <span style="font-weight:bold">bold<\/span><\/p><p>second line<img src="data:image\/jpeg;base64,[^"]+"\/><\/p>$/);
assert.ok(comment.length > 5000, 'long enough that the server ran its XML check and accepted it');
await page.locator('.panel .html img').waitFor();
console.log('ok: own description edited');
// Other user's profile and context menu
await page.locator('.sidebar .user', { hasText: 'bob' }).click({ button: 'right' });
for (const item of ['View profile', 'Send message', 'Server mute', 'Kick...', 'Ban...']) {
await page.getByRole('menuitem', { name: item }).waitFor();
}
await page.getByRole('menuitem', { name: 'View profile' }).click();
await page.locator('.panel h3', { hasText: 'bob' }).waitFor();
console.log('ok: user context menu and profile');
// Information: own connection shows client details and the full certificate
await page.locator('.sidebar .user.self').click({ button: 'right' });
await page.getByRole('menuitem', { name: 'Information' }).click();
const info = page.getByRole('dialog', { name: /^Information: alice/ });
await info.getByText('mumh5 0.1.0').waitFor();
await info.getByText('linux', { exact: false }).first().waitFor();
await info.getByText('Online for').waitFor();
await info.getByRole('button', { name: 'View certificate' }).click();
await page.getByRole('dialog', { name: /^Certificate of alice/ }).getByText('alice', { exact: true }).first().waitFor();
await page.getByRole('dialog', { name: /^Certificate of alice/ }).getByRole('button', { name: 'Close', exact: true }).last().click();
await info.getByRole('button', { name: 'Close', exact: true }).last().click();
console.log('ok: own connection information with certificate chain');
// Someone else as a normal user: times and pings, details withheld by the server
await page.locator('.sidebar .user', { hasText: 'bob' }).click({ button: 'right' });
await page.getByRole('menuitem', { name: 'Information' }).click();
const bobInfo = page.getByRole('dialog', { name: 'Information: bob' });
await bobInfo.getByText('Ping (TCP)').waitFor();
await bobInfo.getByText('only shown to admins', { exact: false }).first().waitFor();
assert.equal(await bobInfo.getByText('mumh5 0.1.0').count(), 0);
await bobInfo.getByRole('button', { name: 'Close', exact: true }).last().click();
console.log('ok: other user information respects server permissions');
if (admin) {
// Remove channels left over from an earlier run (the server refuses duplicate names)
for (const c of [...admin.channels.values()]) {
if (c.id !== 0 && c.parent === 0) admin.send('ChannelRemove', { channel_id: c.id });
}
await new Promise(r => setTimeout(r, 300));
// Channels: a long description (only sent as a hash), and a nested pair
const longDesc = 'Welcome to the lobby. ' + 'This description is long enough that the server only announces its hash. '.repeat(3);
const created = new Promise<void>(res => {
let n = 0;
admin.on('channel', c => { if (['Lobby', 'Games', 'Minecraft'].includes(c.name) && ++n === 3) res(); });
});
admin.send('ChannelState', { parent: 0, name: 'Lobby', description: longDesc });
admin.send('ChannelState', { parent: 0, name: 'Games' });
await new Promise(r => setTimeout(r, 300));
const games = [...admin.channels.values()].find(c => c.name === 'Games')!;
admin.send('ChannelState', { parent: games.id, name: 'Minecraft' });
await within(created, 'channels created');
await page.locator('.channel', { hasText: 'Games' }).waitFor();
assert.equal(await page.locator('.channel', { hasText: 'Minecraft' }).count(), 0, 'empty subchannels start collapsed');
console.log('ok: channels collapsed by default');
// Single click previews the description (fetched on demand), without joining
await page.locator('.channel .name', { hasText: 'Lobby' }).click();
await page.locator('.panel .html', { hasText: 'Welcome to the lobby' }).waitFor();
assert.equal(await page.locator('.me .who span').textContent(), 'Root');
console.log('ok: single click shows description without joining');
// Side chat sends to that channel while staying in Root
const lobby = [...bob.channels.values()].find(c => c.name === 'Lobby')!;
await page.locator('.panel [role=tab]', { hasText: 'Chat' }).click();
// Channel messages only reach people in that channel, so listen from inside Lobby
admin.joinChannel(lobby.id);
await new Promise(r => setTimeout(r, 300));
const sideMsg = new Promise<number[]>(res => admin.on('text', m => { if (m.html === 'side hello') res(m.channels); }));
await page.locator('.panel textarea').fill('side hello');
await page.locator('.panel textarea').press('Enter');
assert.deepEqual(await within(sideMsg, 'side chat message'), [lobby.id]);
await page.locator('.panel .msg', { hasText: 'side hello' }).waitFor();
assert.equal(await page.locator('.chat .msg', { hasText: 'side hello' }).count(), 0);
console.log('ok: side chat sends to the previewed channel');
// Double click joins; the joined channel opens up
await page.locator('.channel .name', { hasText: 'Games' }).dblclick();
await page.locator('.me .who span', { hasText: 'Games' }).waitFor();
await page.locator('.channel', { hasText: 'Minecraft' }).waitFor();
console.log('ok: double click joins and expands');
// Channel context menu
await page.locator('.channel .name', { hasText: 'Lobby' }).click({ button: 'right' });
await page.getByRole('menuitem', { name: 'Join channel' }).click();
await page.locator('.me .who span', { hasText: 'Lobby' }).waitFor();
console.log('ok: channel context menu joins');
// Side panel can be resized by dragging its left edge
const panelBox = (await page.locator('aside.panel').boundingBox())!;
const handle = (await page.locator('aside.panel .resize').boundingBox())!;
await page.mouse.move(handle.x + 4, handle.y + 200);
await page.mouse.down();
await page.mouse.move(handle.x - 150, handle.y + 200, { steps: 5 });
await page.mouse.up();
const grown = (await page.locator('aside.panel').boundingBox())!;
assert.ok(Math.abs(grown.width - (panelBox.width + 154)) < 6, `panel resized ${panelBox.width} -> ${grown.width}`);
console.log('ok: side panel resizable');
}
// Without an upload host, images are sent inline within Mumble's image limit
await page.locator('.channel .name', { hasText: 'Root' }).dblclick();
await page.locator('.me .who span', { hasText: 'Root' }).waitFor();
await page.getByTitle('Settings').click();
await page.getByRole('tab', { name: 'Chat and files' }).click();
await page.getByLabel('Upload host').fill('');
await page.getByRole('button', { name: 'Done', exact: true }).click();
const big = path.join(userData, 'big.png');
writeFileSync(big, testPng(1600, 1200));
const gotInline = new Promise<string>(res => bob.on('text', m => { if (m.html.startsWith('<img')) res(m.html); }));
await page.locator('.chat .composer input[type=file]').setInputFiles(big);
const inline = await within(gotInline, 'inline image');
assert.match(inline, /^<img src="data:image\/jpeg;base64,[^"]+"\/>$/);
assert.ok(inline.length <= 131072, `inline image within the server limit (${inline.length})`);
console.log(`ok: inline image without upload host (${inline.length} chars)`);
// ─── Several servers at once ─────────────────────────────────────────────
const userState = (name: string) => [...bob.users.values()].find(u => u.name === name);
const until2 = (label: string, cond: () => boolean) => within(new Promise<void>(res => {
if (cond()) return res();
const off = bob.on('user', () => { if (cond()) { off(); res(); } });
}), label);
await page.getByTitle('Add a server').click();
await page.getByLabel('Address').fill(host);
await page.getByLabel('Port').fill(port);
await page.getByLabel('Username').fill('alice-b');
await page.getByLabel('Label').fill('Second');
await page.getByRole('button', { name: 'Save and connect' }).click();
await page.locator('.rail .tile.live').nth(1).waitFor();
assert.equal(await page.locator('.rail .tile.live').count(), 2, 'both servers connected');
await until2('background server shows deafened', () => !!userState('alice-b')?.selfDeaf);
assert.equal(await page.locator('.rail .tile.voice').getAttribute('title').then(t => t?.startsWith('Test Server')), true);
await page.getByText('Voice is on Test Server').waitFor();
console.log('ok: second server connected, deafened in the background');
// Joining a channel on the second server moves voice there
await page.locator('.channel .name', { hasText: 'Root' }).dblclick();
await until2('voice moved to the second server', () => !userState('alice-b')?.selfDeaf && !!userState('alice')?.selfDeaf);
assert.equal(await page.locator('.rail .tile.voice').getAttribute('title').then(t => t?.startsWith('Second')), true);
console.log('ok: voice follows the channel you join');
// Messages on the server not on screen show as unread on its tile
bob.sendText({ channels: [0] }, 'ping both');
await page.locator('.rail .tile[title^="Test Server"] .unread').waitFor();
console.log('ok: unread badge on the background server');
// Reorder by dragging Second above Test Server
await page.locator('.rail .tile[title^="Second"]').dragTo(page.locator('.rail .tile[title^="Test Server"]'), { targetPosition: { x: 22, y: 4 } });
assert.match(await page.locator('.rail .tile').first().getAttribute('title') ?? '', /^Second/);
console.log('ok: servers reordered by drag and drop');
// Custom icon from the tile's context menu: square-cropped and shown instead of initials
const iconFile = path.join(userData, 'icon.png');
writeFileSync(iconFile, testPng(300, 200));
await page.locator('.rail .tile[title^="Second"]').click({ button: 'right' });
const chooser = page.waitForEvent('filechooser');
await page.getByRole('menuitem', { name: 'Change icon...' }).click();
await (await chooser).setFiles(iconFile);
const icon = page.locator('.rail .tile[title^="Second"] img.icon');
await icon.waitFor();
assert.deepEqual(await icon.evaluate((i: HTMLImageElement) => [i.naturalWidth, i.naturalHeight, i.src.slice(0, 15)]), [128, 128, 'data:image/webp']);
console.log('ok: custom server icon');
// Disconnecting the voice server hands voice back and restores the earlier state
await page.locator('.rail .tile[title^="Second"]').click({ button: 'right' });
await page.getByRole('menuitem', { name: 'Disconnect' }).click();
await until2('voice back on the first server', () => !userState('alice-b') && !userState('alice')?.selfDeaf);
assert.equal(userState('alice')?.selfMute, true, 'earlier self-mute kept');
await page.locator('.rail .tile[title^="Test Server"]').click();
await page.locator('.rail .tile[title^="Test Server"] .unread').waitFor({ state: 'detached' });
console.log('ok: voice returns when the other server disconnects');
// Change name: Mumble needs a reconnect, the app does it and remembers the name.
// Unique per run: a reused test server may still have an earlier run's name registered.
const newName = `alice${Date.now() % 100000}`;
await page.locator('.me .profile').click();
await page.getByRole('button', { name: 'Change name' }).click();
await page.getByLabel('New name').fill(newName);
await page.getByRole('button', { name: 'Reconnect with this name' }).click();
await page.locator('.me .who strong', { hasText: newName }).waitFor();
await within(new Promise<void>(res => {
if ([...bob.users.values()].some(u => u.name === newName)) return res();
bob.on('user', u => { if (u.name === newName) res(); });
}), 'bob sees the new name');
console.log('ok: name changed by reconnecting');
// Self-registration: guest badge becomes registered
await page.locator('.me .profile').click();
await page.locator('.panel .badge-reg', { hasText: 'Guest' }).waitFor();
await page.locator('.panel .register').click();
await page.locator('.panel .badge-reg.registered', { hasText: 'Registered' }).waitFor();
await page.locator('.sidebar .user', { hasText: newName }).locator('.reg').waitFor();
console.log('ok: registered on the server');
if (shots) {
await page.waitForTimeout(300);
for (const [name, w, h] of [['wide', 1280, 800], ['medium', 900, 700], ['narrow', 400, 780]] as const) {
await page.setViewportSize({ width: w, height: h });
await page.waitForTimeout(200);
await page.screenshot({ path: path.join(shots, `${name}.png`) });
}
await page.setViewportSize({ width: 1280, height: 900 });
await page.locator('.me').getByRole('button', { name: /^(Mute|Unmute)$/ }).click({ button: 'right' });
await page.waitForTimeout(300);
await page.screenshot({ path: path.join(shots, 'quick-audio.png') });
await page.keyboard.press('Escape');
await page.locator('.me .profile').click();
await page.getByRole('button', { name: 'Edit description' }).click();
await page.waitForTimeout(300);
await page.screenshot({ path: path.join(shots, 'editor.png') });
await page.getByRole('button', { name: 'Cancel' }).click();
await page.getByTitle('Settings').click();
await page.waitForTimeout(600);
await page.screenshot({ path: path.join(shots, 'settings-voice.png') });
await page.getByRole('button', { name: 'Done', exact: true }).click();
await page.locator('.sidebar .user', { hasText: 'bob' }).click();
await page.waitForTimeout(400);
await page.screenshot({ path: path.join(shots, 'profile.png') });
await page.setViewportSize({ width: 400, height: 780 });
await page.waitForTimeout(200);
await page.getByLabel('Open channels').click();
await page.waitForTimeout(200);
await page.screenshot({ path: path.join(shots, 'narrow-drawer.png') });
}
console.log('E2E PASSED');
} catch (e) {
if (shots) await (await app.firstWindow()).screenshot({ path: path.join(shots, 'failure.png') });
throw e;
} finally {
bob.disconnect();
admin?.disconnect();
await app.close();
}
+137
View File
@@ -0,0 +1,137 @@
// Generates the README screenshots in docs/screenshots from a scripted scene.
// docker run -d --name mumh5-shots -p 64740:64738 -e MUMBLE_SUPERUSER_PASSWORD=shots \
// -e MUMBLE_CONFIG_AUTOBANATTEMPTS=0 -e "MUMBLE_CONFIG_WELCOMETEXT=Welcome to the community voice server." \
// mumblevoip/mumble-server
// npm run build && MUMBLE_TEST_HOST=localhost:64740 MUMBLE_SUPERUSER_PASSWORD=shots npm run screenshots
import { _electron as electron } from 'playwright-core';
import electronPath from 'electron';
import { mkdtempSync, mkdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { createCodec } from '../../src/core/proto.ts';
import { MumbleClient } from '../../src/core/client.ts';
import type { Transport } from '../../src/core/transport.ts';
import { openTls } from '../../electron/tls-transport.ts';
import { generateIdentity } from '../../electron/identity.ts';
const target = process.env.MUMBLE_TEST_HOST;
const superPw = process.env.MUMBLE_SUPERUSER_PASSWORD;
if (!target || !superPw) {
console.log('Set MUMBLE_TEST_HOST and MUMBLE_SUPERUSER_PASSWORD');
process.exit(1);
}
const [host, port] = target.split(':');
const root = path.resolve(import.meta.dirname, '../..');
const out = path.join(root, 'docs', 'screenshots');
mkdirSync(out, { recursive: true });
const wait = (ms: number) => new Promise(r => setTimeout(r, ms));
function headless(username: string, password = ''): Promise<MumbleClient> {
const id = generateIdentity(username);
const client = new MumbleClient(createCodec());
const t: Transport = { onData: null, onClose: null, send: b => conn.send(b), close: () => conn.close() };
const conn = openTls(host, Number(port), id.certPem, id.keyPem, {
onSecure: () => {}, onData: c => t.onData?.(c), onClose: r => t.onClose?.(r)
});
client.connect(t, { username, password, os: 'Linux' });
return new Promise((res, rej) => { client.on('synced', () => res(client)); client.on('close', rej); });
}
const byName = (c: MumbleClient, name: string) => [...c.channels.values()].find(x => x.name === name)!;
// ─── Server scene ─────────────────────────────────────────────────────────────
const admin = await headless('SuperUser', superPw);
// Start from an empty tree when the server is reused
for (const c of [...admin.channels.values()]) if (c.id !== 0 && c.parent === 0) admin.send('ChannelRemove', { channel_id: c.id });
await wait(500);
admin.send('ChannelState', { parent: 0, name: 'Lobby', position: 0, description: '<p><b>Welcome!</b> Hang out, say hi, and drop your clips here.</p><p>Be nice. Push to talk is appreciated in busy rooms.</p>' });
admin.send('ChannelState', { parent: 0, name: 'Gaming', position: 1 });
admin.send('ChannelState', { parent: 0, name: 'Music', position: 2, description: 'Listening parties every Friday.' });
admin.send('ChannelState', { parent: 0, name: 'AFK', position: 3 });
await wait(1500);
admin.send('ChannelState', { parent: byName(admin, 'Gaming').id, name: 'Minecraft' });
admin.send('ChannelState', { parent: byName(admin, 'Gaming').id, name: 'Valorant' });
await wait(1500);
const lobby = byName(admin, 'Lobby').id;
admin.disconnect();
const sarah = await headless('sarah');
const max = await headless('max');
const jonas = await headless('jonas');
const lena = await headless('lena');
sarah.joinChannel(lobby);
max.joinChannel(lobby);
await wait(300);
jonas.joinChannel(byName(jonas, 'Minecraft').id);
lena.joinChannel(byName(lena, 'Music').id);
await wait(1200);
lena.setSelfMute(true);
max.setSelfDeaf(true);
sarah.setComment('<p><b>sarah</b> <span style="color:#e8b23c">streamer, night owl</span></p><p>Mostly playing <i>Minecraft</i> and <i>Valorant</i>. Ask me about modpacks.</p><ul><li>EU evenings</li><li>Push to talk</li></ul>');
// ─── App ──────────────────────────────────────────────────────────────────────
const { ELECTRON_RUN_AS_NODE, ...env } = process.env;
const app = await electron.launch({
executablePath: electronPath as unknown as string,
args: [root, `--user-data-dir=${mkdtempSync(path.join(tmpdir(), 'mumh5-e2e-'))}`, '--ozone-platform=x11',
'--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'],
env: env as Record<string, string>
});
const page = await app.firstWindow();
await page.setViewportSize({ width: 1440, height: 900 });
await page.getByRole('button', { name: /Create a new identity/ }).click();
await page.getByLabel('Name').fill('kibi');
await page.getByRole('button', { name: 'Create', exact: true }).click();
await page.getByRole('button', { name: 'Skip for now' }).click();
await page.getByRole('button', { name: 'Done' }).click();
await page.getByTitle('Settings').click();
await page.getByRole('tab', { name: 'Chat and files' }).click();
await page.getByLabel('YouTube links').selectOption('thumbnail');
await page.getByRole('tab', { name: 'Voice' }).click();
await page.getByRole('radio', { name: /Always on/ }).click();
await page.getByRole('button', { name: 'Done', exact: true }).click();
await page.getByTitle('Add a server').click();
await page.getByLabel('Address').fill(host);
await page.getByLabel('Port').fill(port);
await page.getByLabel('Username').fill('kibi');
await page.getByLabel('Label').fill('Community');
await page.getByRole('button', { name: 'Save and connect' }).click();
await page.getByText(/Connected/).first().waitFor();
await page.locator('.channel .name', { hasText: 'Lobby' }).dblclick();
await page.locator('.me .who span', { hasText: 'Lobby' }).waitFor();
await wait(800);
sarah.sendText({ channels: [lobby] }, 'anyone up for a round later?');
await wait(1200);
max.sendText({ channels: [lobby] }, 'yes, after dinner. 8pm?');
await wait(1200);
await page.getByLabel('Message').fill('works for me. meanwhile, a classic: https://www.youtube.com/watch?v=dQw4w9WgXcQ');
await page.getByLabel('Message').press('Enter');
await wait(1200);
sarah.sendText({ channels: [lobby] }, '<b>no.</b> not again <i>(ok it is a good song)</i>');
await wait(3000);
await page.screenshot({ path: path.join(out, 'main.png') });
await page.locator('.sidebar .user', { hasText: 'sarah' }).click();
await page.locator('.panel .html', { hasText: 'streamer' }).waitFor();
await wait(500);
await page.screenshot({ path: path.join(out, 'profile.png') });
await page.getByTitle('Settings').click();
await wait(800);
await page.screenshot({ path: path.join(out, 'voice-settings.png') });
await page.getByRole('button', { name: 'Done', exact: true }).click();
await page.setViewportSize({ width: 390, height: 844 });
await page.locator('.scrim').first().click().catch(() => {});
await wait(500);
await page.screenshot({ path: path.join(out, 'mobile.png') });
await app.close();
for (const c of [sarah, max, jonas, lena]) c.disconnect();
console.log(`Screenshots written to ${out}`);
process.exit(0);
+70
View File
@@ -0,0 +1,70 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { generateIdentity, identityFromP12, identityToP12 } from '../electron/identity.ts';
import { IdentityStore } from '../electron/identity-store.ts';
const tmp = () => mkdtempSync(path.join(tmpdir(), 'mumh5-id-'));
test('PKCS#12 export and import round trip', () => {
const id = generateIdentity('roundtrip', 'rt@example.com');
const p12 = identityToP12(id, 'hunter22', 'roundtrip');
const back = identityFromP12(p12, 'hunter22');
assert.equal(back.fingerprint, id.fingerprint);
assert.throws(() => identityFromP12(p12, 'wrong'), /Wrong password/);
});
test('store migrates the legacy identity and still asks for setup', async () => {
const dir = tmp();
const legacy = generateIdentity('legacy user');
writeFileSync(path.join(dir, 'identity.json'), JSON.stringify(legacy));
const store = new IdentityStore(dir);
const { identities, setupDone } = await store.list();
assert.equal(setupDone, false);
assert.equal(identities.length, 1);
assert.equal(identities[0].fingerprint, legacy.fingerprint);
assert.equal(identities[0].name, 'legacy user');
assert.ok(identities[0].isDefault);
assert.equal(statSync(path.join(dir, 'identities.json')).mode & 0o077, 0, 'identities file is owner-only');
await store.finishSetup();
assert.equal((await new IdentityStore(dir).list()).setupDone, true);
});
test('store create, import, duplicates, default and remove', async () => {
const store = new IdentityStore(tmp());
const a = await store.create('alice', '');
const other = generateIdentity('bob');
const b = await store.importP12(identityToP12(other, 'pw', 'bob'), 'pw', '');
assert.equal(b.name, 'bob');
await assert.rejects(store.importP12(identityToP12(other, 'x', 'bob'), 'x', ''), /already stored/);
assert.equal((await store.get()).id, a.id, 'first identity is the default');
assert.equal((await store.get(b.id)).fingerprint, other.fingerprint);
await store.setDefault(b.id);
await store.remove(b.id);
const { identities } = await store.list();
assert.deepEqual(identities.map(i => [i.name, i.isDefault]), [['alice', true]]);
});
test('imports the desktop Mumble client certificate', async () => {
const cfg = tmp();
const mumble = generateIdentity('Mumble User');
mkdirSync(path.join(cfg, 'Mumble', 'Mumble'), { recursive: true });
const b64 = Buffer.from(identityToP12(mumble, '', 'Mumble User')).toString('base64');
writeFileSync(path.join(cfg, 'Mumble', 'Mumble', 'mumble_settings.json'), JSON.stringify({ certificate: b64 }));
const prev = { xdg: process.env.XDG_CONFIG_HOME, appdata: process.env.APPDATA, home: process.env.HOME };
process.env.XDG_CONFIG_HOME = cfg;
process.env.APPDATA = path.join(cfg, 'none');
process.env.HOME = path.join(cfg, 'none');
try {
const store = new IdentityStore(tmp());
assert.deepEqual(await store.findMumbleCertificate().then(f => f?.name), 'Mumble User');
const imported = await store.importFromMumble('');
assert.equal(imported.fingerprint, mumble.fingerprint);
} finally {
for (const [k, v] of Object.entries({ XDG_CONFIG_HOME: prev.xdg, APPDATA: prev.appdata, HOME: prev.home })) {
if (v === undefined) delete process.env[k]; else process.env[k] = v;
}
}
});
+26
View File
@@ -0,0 +1,26 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createCodec, FrameReader } from '../src/core/proto.ts';
const codec = createCodec();
test('encode/decode roundtrip with framing split across chunks', () => {
const a = codec.encode('TextMessage', { channel_id: [0], message: 'hello <b>world</b>' });
const b = codec.encode('Ping', { timestamp: 1790796000000 });
const stream = new Uint8Array([...a, ...b]);
const reader = new FrameReader();
const out: any[] = [];
for (let i = 0; i < stream.length; i += 3) {
reader.push(stream.subarray(i, i + 3), (id, body) => out.push(codec.decode(id, body)));
}
assert.equal(out.length, 2);
assert.equal(out[0].name, 'TextMessage');
assert.equal(out[0].msg.message, 'hello <b>world</b>');
assert.deepEqual(out[0].msg.channel_id, [0]);
assert.equal(out[1].name, 'Ping');
assert.equal(out[1].msg.timestamp, 1790796000000);
});
test('encode rejects invalid payloads', () => {
assert.throws(() => codec.encode('ChannelRemove', { channel_id: 'nope' }));
});
+141
View File
@@ -0,0 +1,141 @@
// Integration test against a real Mumble server. Skipped unless MUMBLE_TEST_HOST is set, e.g.
// docker run -d -p 64739:64738 mumblevoip/mumble-server && MUMBLE_TEST_HOST=localhost:64739 npm test
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createCodec } from '../src/core/proto.ts';
import { MumbleClient } from '../src/core/client.ts';
import type { Transport } from '../src/core/transport.ts';
import { openTls } from '../electron/tls-transport.ts';
import { generateIdentity } from '../electron/identity.ts';
import { encodeVoice, decodeVoice } from '../src/core/voice-packet.ts';
const target = process.env.MUMBLE_TEST_HOST;
const codec = createCodec();
function connect(name: string): Promise<MumbleClient> {
const [host, port] = target!.split(':');
const id = generateIdentity(name);
const client = new MumbleClient(codec);
const transport: Transport = { onData: null, onClose: null, send: b => conn.send(b), close: () => conn.close() };
const conn = openTls(host, Number(port || 64738), id.certPem, id.keyPem, {
onSecure: () => {},
onData: c => transport.onData?.(c),
onClose: r => transport.onClose?.(r)
});
client.connect(transport, { username: name, os: 'test' });
return new Promise((resolve, reject) => {
client.on('synced', () => resolve(client));
client.on('close', reject);
});
}
const until = <T>(fn: (resolve: (v: T) => void) => void, ms = 5000) =>
new Promise<T>((resolve, reject) => {
const t = setTimeout(() => reject(new Error('timeout')), ms);
fn(v => { clearTimeout(t); resolve(v); });
});
test('two clients sync, see each other, exchange text, and update state', { skip: !target }, async () => {
const alice = await connect('alice' + Date.now() % 1000);
const bob = await connect('bob' + Date.now() % 1000);
try {
assert.ok(alice.channels.has(0), 'root channel present');
assert.ok(alice.session != null && bob.session != null);
// Alice learns about Bob via UserState
await until<void>(res => {
if (alice.users.has(bob.session!)) return res();
alice.on('user', u => { if (u.session === bob.session) res(); });
});
// Channel text message
const got = until<string>(res => bob.on('text', m => res(m.html)));
alice.sendText({ channels: [0] }, 'hi from <b>alice</b>');
assert.equal(await got, 'hi from <b>alice</b>');
// Self-mute propagates to other clients
const muted = until<boolean>(res => bob.on('user', (u, changed) => {
if (u.session === alice.session && changed.includes('selfMute')) res(u.selfMute);
}));
alice.setSelfMute(true);
assert.equal(await muted, true);
// Plugin data between clients (used for rich chat)
const pd = until<string>(res => bob.on('pluginData', (s, id, data) => res(`${s}:${id}:${new TextDecoder().decode(data)}`)));
alice.sendPluginData([bob.session!], 'mumh5', new TextEncoder().encode('{"t":"typing"}'));
assert.equal(await pd, `${alice.session}:mumh5:{"t":"typing"}`);
// Disconnect is seen as UserRemove
const gone = until<number>(res => bob.on('userRemove', u => res(u.session)));
const aliceSession = alice.session;
alice.disconnect();
assert.equal(await gone, aliceSession);
} finally {
alice.disconnect();
bob.disconnect();
}
});
test('image messages over the text limit must be well-formed XML', { skip: !target }, async () => {
const alice = await connect('imgA' + Date.now() % 1000);
const bob = await connect('imgB' + Date.now() % 1000);
try {
// Longer than the default 5000-char text limit, under the 128 KB image limit
const img = `<img src="data:image/jpeg;base64,${'A'.repeat(8000)}"/>`;
const denied = until<number>(res => alice.on('permissionDenied', d => res(d.type)));
alice.sendText({ channels: [0] }, `<a href="https://x.example/f.png">f.png</a><br>${img}`);
assert.equal(await denied, 4, 'unclosed <br> is invalid XML, server denies as TextTooLong');
const got = until<string>(res => bob.on('text', m => res(m.html)));
const ok = `<a href="https://x.example/f.png">f.png</a><br/>${img}`;
alice.sendText({ channels: [0] }, ok);
assert.equal(await got, ok);
} finally {
alice.disconnect();
bob.disconnect();
}
});
test('bursts of messages are paced instead of dropped by the server', { skip: !target }, async () => {
const alice = await connect('burstA' + Date.now() % 1000);
const bob = await connect('burstB' + Date.now() % 1000);
try {
const got: string[] = [];
const all = until<void>(res => bob.on('text', m => { got.push(m.html); if (got.length === 9) res(); }), 15000);
for (let i = 0; i < 9; i++) alice.sendText({ channels: [0] }, `msg ${i}`);
assert.ok(alice.queued > 0, 'some messages wait for the rate limit');
await all;
assert.deepEqual(got, Array.from({ length: 9 }, (_, i) => `msg ${i}`));
} finally {
alice.disconnect();
bob.disconnect();
}
});
test('voice packets are relayed through the TCP tunnel in the negotiated format', { skip: !target }, async () => {
const alice = await connect('voiceA' + Date.now() % 1000);
const bob = await connect('voiceB' + Date.now() % 1000);
try {
assert.equal(alice.protobufVoice, true, '1.5 server uses the protobuf voice format');
const frames: { session: number; frame: number; last: boolean; bytes: number }[] = [];
const done = until<void>(res => bob.on('voice', raw => {
const p = decodeVoice(raw);
if (!p) return;
frames.push({ session: p.session, frame: p.frame, last: p.last, bytes: p.opus.length });
if (p.last) res();
}));
// Opus TOC byte 0x78: SILK wideband 20 ms frame, followed by filler
const opus = new Uint8Array([0x78, ...new Array(40).fill(7)]);
for (let i = 0; i < 5; i++) alice.sendVoiceTunnel(encodeVoice({ target: 0, frame: i * 2, opus, last: i === 4 }, alice.protobufVoice));
await done;
assert.equal(frames.length, 5);
assert.ok(frames.every(f => f.session === alice.session && f.bytes === opus.length));
assert.deepEqual(frames.map(f => f.frame), [0, 2, 4, 6, 8]);
assert.equal(frames[4].last, true);
} finally {
alice.disconnect();
bob.disconnect();
}
});
+38
View File
@@ -0,0 +1,38 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { writeVarint, readVarint, encodeVoice, decodeVoice, relayAsServer } from '../src/core/voice-packet.ts';
test('Mumble varints round trip at every size boundary', () => {
for (const v of [0, 1, 0x7f, 0x80, 0x3fff, 0x4000, 0x1fffff, 0x200000, 0xfffffff, 0x10000000, 0xffffffff, 0x100000000, 2 ** 40 + 5]) {
const out: number[] = [];
writeVarint(out, v);
assert.deepEqual(readVarint(new Uint8Array(out), 0), [v, out.length], `value ${v}`);
}
const small: number[] = [];
writeVarint(small, 300);
assert.deepEqual(small, [0x81, 0x2c], 'matches PacketDataStream encoding');
});
for (const protobuf of [false, true]) {
test(`${protobuf ? 'protobuf' : 'legacy'} voice packets survive a server relay`, () => {
const opus = new Uint8Array([0xfc, 1, 2, 3, 250]);
for (const last of [false, true]) {
const sent = encodeVoice({ target: 0, frame: 1234, opus, last }, protobuf);
const got = decodeVoice(relayAsServer(sent, 42, protobuf));
assert.deepEqual(got, { session: 42, target: 0, frame: 1234, opus, last });
}
});
}
test('legacy header layout: opus type, terminator bit in the size field', () => {
const p = encodeVoice({ target: 3, frame: 5, opus: new Uint8Array(10), last: true }, false);
assert.equal(p[0], (4 << 5) | 3);
assert.equal(p[1], 5);
assert.deepEqual([p[2], p[3]], [0x80 | 0x20, 10], 'size 10 | 0x2000 as a 2-byte varint');
});
test('non-audio packets are ignored', () => {
assert.equal(decodeVoice(new Uint8Array([1, 2, 3])), null, 'protobuf ping');
assert.equal(decodeVoice(new Uint8Array([0x20, 5])), null, 'legacy ping');
assert.equal(decodeVoice(new Uint8Array([0x80, 1])), null, 'truncated');
});
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"erasableSyntaxOnly": true,
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"types": ["node", "svelte", "vite/client"]
},
"include": ["src/**/*", "electron/**/*", "test/**/*"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({
base: './',
plugins: [svelte()],
build: { target: 'chrome120', outDir: 'dist', emptyOutDir: true }
});