Link previews voice tiles #3

Merged
kibi merged 2 commits from link-previews-voice-tiles into main 2026-09-30 23:40:16 +00:00
31 changed files with 902 additions and 91 deletions
+40
View File
@@ -0,0 +1,40 @@
# Builds the Linux packages and the Windows installer when a version tag (v1.2.3) is pushed,
# and attaches them to a Gitea release of that tag. macOS builds need a Mac and are not made here.
name: release
on:
push:
tags: ['v*']
jobs:
build:
runs-on: ubuntu-latest
# Has Wine, so the Windows installer builds on Linux
container: electronuserland/builder:wine
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
- run: npm ci
- run: npm run check
- run: npm test
- run: npm run build
- run: npx electron-builder --linux --win --publish never
- name: Upload to the release
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
TAG: ${{ github.ref_name }}
run: |
set -eu
release=$(curl -sf -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" || true)
if [ -z "$release" ]; then
release=$(curl -sf -X POST -H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \
-d "{\"tag_name\":\"$TAG\",\"name\":\"mumh5 $TAG\"}" "$API/releases")
fi
id=$(printf '%s' "$release" | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>console.log(JSON.parse(s).id))")
for f in release/*.AppImage release/*.deb release/*.tar.gz release/*.exe; do
curl -sf -X POST -H "Authorization: token $TOKEN" -F "attachment=@$f" "$API/releases/$id/assets?name=$(basename "$f")" >/dev/null
echo "uploaded $(basename "$f")"
done
+1 -1
View File
@@ -37,7 +37,7 @@ A reused test server keeps registrations and channels from earlier runs; tests m
## Architecture ## 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). - `electron/` (Node, main process): window, TLS sockets to Mumble servers (`tls-transport.ts`), encrypted UDP voice (`udp-voice.ts`, `ocb2.ts`, tested against Mumble's OCB2 vectors), 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/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/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). - `src/ui/`: Svelte components. `App.svelte` owns layout and global dialogs (`ui.svelte.ts` store).
+16 -5
View File
@@ -27,7 +27,7 @@ mumh5 keeps the foundation and replaces the experience.
| | Mumble desktop | mumh5 | | | Mumble desktop | mumh5 |
|---|---|---| |---|---|---|
| Interface | Qt widgets, dense dialogs | Modern layout: server rail, channel tree, chat, side panels | | Interface | Qt widgets, dense dialogs | Modern layout (server rail, channel tree, chat, side panels), or the classic and stacked layouts of the desktop client |
| Several servers at the same time | One connection | Many; voice on one, text on all, unread badges | | 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 | | 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 | | YouTube links | Plain links | Click-to-play player (privacy mode), optional thumbnails |
@@ -41,7 +41,7 @@ mumh5 keeps the foundation and replaces the experience.
| Look | One Qt style (plus skins) | Five built-in color schemes, including Windows 95 | | Look | One Qt style (plus skins) | Five built-in color schemes, including Windows 95 |
| Channel and permission editing | Dialogs, drag and drop | Same power: create, edit, link, drag and drop, rule and group editor | | Channel and permission editing | Dialogs, drag and drop | Same power: create, edit, link, drag and drop, rule and group editor |
**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 ban list and registered-user editors. These are on the roadmap below. **Where the desktop client is still ahead, today:** system-wide push-to-talk, whisper and shout, positional audio, the in-game overlay, recording, and the ban list and registered-user editors. These are on the roadmap below.
--- ---
@@ -55,6 +55,7 @@ mumh5 keeps the foundation and replaces the experience.
- Per-person volume (0 to 300%) and "mute for me" - Per-person volume (0 to 300%) and "mute for me"
- Speaking indicators in the channel tree, member list and your own panel - Speaking indicators in the channel tree, member list and your own panel
- On small windows, your voice channel appears as tiles above the chat, lighting up as people talk, with mute and deafen at hand - On small windows, your voice channel appears as tiles above the chat, lighting up as people talk, with mute and deafen at hand
- Low-latency voice over encrypted UDP (Mumble's OCB2-AES128), with automatic fallback to the TCP connection and a TCP-only switch
- Bitrate automatically limited to what the server allows - Bitrate automatically limited to what the server allows
- Right-click the mute or deafen button for quick device and volume options - Right-click the mute or deafen button for quick device and volume options
- Hear-yourself microphone test - Hear-yourself microphone test
@@ -110,7 +111,18 @@ Dark, Light, Midnight (true black), Frost, and a faithful **Windows 95** theme w
## Getting started ## Getting started
There are no release builds yet. For now, build it yourself; it takes a few minutes. Download a build from the [releases page](https://git.lat/kibi/mumh5/releases):
| System | File |
|---|---|
| Windows | `mumh5-<version>-win-x64.exe` (installer; not signed yet, so Windows SmartScreen warns once) |
| Linux, any distribution | `mumh5-<version>-linux-x86_64.AppImage` (make it executable, then run it) |
| Debian, Ubuntu | `mumh5-<version>-linux-amd64.deb` |
| Linux, portable | `mumh5-<version>-linux-x64.tar.gz` |
There is no macOS build yet; build it yourself on a Mac (below).
### Running from source
You need [Node.js](https://nodejs.org/) 22 or newer and git. You need [Node.js](https://nodejs.org/) 22 or newer and git.
@@ -131,7 +143,7 @@ npm run dist:win # NSIS installer
npm run dist:mac # .dmg npm run dist:mac # .dmg
``` ```
Installers are written to `release/`. Build each platform on that platform for the best results. Installers are written to `release/`. The Windows installer also builds on Linux with Wine installed; the macOS one needs a Mac. Pushing a version tag (`git tag v0.1.0 && git push origin v0.1.0`) makes the Gitea workflow in `.gitea/workflows/release.yml` build the Linux and Windows files and attach them to a release.
--- ---
@@ -164,7 +176,6 @@ Without an upload host, mumh5 still sends images, scaled to fit the server's lim
## Roadmap ## Roadmap
- UDP voice with Mumble's OCB2-AES128 encryption, for the lowest latency
- System-wide push to talk - System-wide push to talk
- Whisper and shout - Whisper and shout
- Rich chat between mumh5 users: replies, reactions, edits, typing indicators - Rich chat between mumh5 users: replies, reactions, edits, typing indicators
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
<!-- mumh5 app icon: the accent square with the headphones glyph, square caps like the UI icons -->
<rect width="1024" height="1024" fill="#7c6cff"/>
<rect x="0" y="880" width="1024" height="144" fill="#6a5ae8"/>
<g transform="translate(176 150) scale(28)" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter">
<path d="M3 14v-2a9 9 0 0 1 18 0v2"/>
<path d="M21 14v7h-4v-7z" fill="#ffffff"/>
<path d="M3 14v7h4v-7z" fill="#ffffff"/>
</g>
<rect x="424" y="700" width="176" height="36" fill="#3bd16f"/>
</svg>

After

Width:  |  Height:  |  Size: 665 B

+27 -3
View File
@@ -8,6 +8,7 @@ import * as tray from './tray.ts';
import { fetchLinkPreview } from './link-preview.ts'; import { fetchLinkPreview } from './link-preview.ts';
import { fetchPublicListWith, pingServer } from './publist.ts'; import { fetchPublicListWith, pingServer } from './publist.ts';
import { openTls } from './tls-transport.ts'; import { openTls } from './tls-transport.ts';
import { udpChannel } from './udp-voice.ts';
const devUrl = process.env.VITE_DEV_SERVER_URL; const devUrl = process.env.VITE_DEV_SERVER_URL;
@@ -16,7 +17,7 @@ const identities = () => (identityStore ??= new IdentityStore(app.getPath('userD
// ─── Mumble TLS connections, one per renderer request ───────────────────────── // ─── Mumble TLS connections, one per renderer request ─────────────────────────
type Conn = ReturnType<typeof openTls> & { owner: WebContents }; type Conn = ReturnType<typeof openTls> & { owner: WebContents; udp?: ReturnType<typeof udpChannel> };
const conns = new Map<string, Conn>(); const conns = new Map<string, Conn>();
// ─── Identities (client certificates) ───────────────────────────────────────── // ─── Identities (client certificates) ─────────────────────────────────────────
@@ -54,9 +55,19 @@ ipcMain.handle('mumble:open', async (e, connId: string, host: string, port: numb
const owner = e.sender; const owner = e.sender;
const emit = (channel: string, ...args: unknown[]) => { if (!owner.isDestroyed()) owner.send(channel, connId, ...args); }; 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, { const conn = openTls(String(host), Number(port) || 64738, id.certPem, id.keyPem, {
onSecure: info => emit('mumble:secure', info), onSecure: info => {
// Encrypted UDP voice to the address the TLS connection actually reached
const udp = udpChannel(info.address, info.port);
udp.onVoice = p => emit('mumble:udpVoice', p);
udp.onState = (ok, rtt) => emit('mumble:udpState', ok, rtt);
udp.onResync = () => emit('mumble:udpResync');
const c = conns.get(connId);
if (c) c.udp = udp;
else udp.close();
emit('mumble:secure', info);
},
onData: chunk => emit('mumble:data', chunk), onData: chunk => emit('mumble:data', chunk),
onClose: reason => { conns.delete(connId); emit('mumble:close', reason); } onClose: reason => { conns.get(connId)?.udp?.close(); conns.delete(connId); emit('mumble:close', reason); }
}); });
conns.set(connId, Object.assign(conn, { owner })); conns.set(connId, Object.assign(conn, { owner }));
owner.once('destroyed', () => conn.close()); owner.once('destroyed', () => conn.close());
@@ -67,6 +78,17 @@ ipcMain.on('mumble:send', (e, connId: string, bytes: Uint8Array) => {
if (conn && conn.owner === e.sender) conn.send(bytes); if (conn && conn.owner === e.sender) conn.send(bytes);
}); });
// UDP voice: keys from the server's CryptSetup, voice packets, nonce resync
const ownUdp = (e: Electron.IpcMainEvent | Electron.IpcMainInvokeEvent, connId: string) => {
const conn = conns.get(connId);
return conn && conn.owner === e.sender ? conn.udp : undefined;
};
ipcMain.on('mumble:udpSetup', (e, connId: string, key: Uint8Array, cn: Uint8Array, sn: Uint8Array, protobuf: boolean) =>
ownUdp(e, connId)?.setup(new Uint8Array(key), new Uint8Array(cn), new Uint8Array(sn), !!protobuf));
ipcMain.on('mumble:udpNonce', (e, connId: string, sn: Uint8Array) => ownUdp(e, connId)?.setServerNonce(new Uint8Array(sn)));
ipcMain.on('mumble:udpSend', (e, connId: string, bytes: Uint8Array) => ownUdp(e, connId)?.send(new Uint8Array(bytes)));
ipcMain.handle('mumble:udpClientNonce', (e, connId: string) => ownUdp(e, connId)?.clientNonce() ?? null);
ipcMain.on('mumble:close', (e, connId: string) => { ipcMain.on('mumble:close', (e, connId: string) => {
const conn = conns.get(connId); const conn = conns.get(connId);
if (conn && conn.owner === e.sender) conn.close(); if (conn && conn.owner === e.sender) conn.close();
@@ -114,6 +136,8 @@ function createWindow() {
minHeight: 480, minHeight: 480,
backgroundColor: '#111214', backgroundColor: '#111214',
title: 'mumh5', title: 'mumh5',
// Window and taskbar icon on Linux (Windows and macOS take it from the package)
icon: path.join(__dirname, '../dist/icon.png'),
autoHideMenuBar: true, autoHideMenuBar: true,
webPreferences: { webPreferences: {
preload: path.join(__dirname, 'preload.cjs'), preload: path.join(__dirname, 'preload.cjs'),
+199
View File
@@ -0,0 +1,199 @@
import { createCipheriv, createDecipheriv } from 'node:crypto';
// Mumble's UDP encryption: OCB2-AES128 with 4-byte packet headers (IV byte + 3 tag bytes),
// ported from Mumble's CryptStateOCB2.cpp including its counter-measures against the
// XEX* attack (https://eprint.iacr.org/2019/311, section 9).
const BLOCK = 16;
function aes(key: Buffer, block: Buffer): Buffer {
const c = createCipheriv('aes-128-ecb', key, null);
c.setAutoPadding(false);
return c.update(block);
}
function aesDecrypt(key: Buffer, block: Buffer): Buffer {
const d = createDecipheriv('aes-128-ecb', key, null);
d.setAutoPadding(false);
return d.update(block);
}
function xor(a: Buffer, b: Buffer): Buffer {
const out = Buffer.alloc(BLOCK);
for (let i = 0; i < BLOCK; i++) out[i] = a[i] ^ b[i];
return out;
}
// Multiply by x in GF(2^128), big endian
function times2(b: Buffer): Buffer {
const out = Buffer.alloc(BLOCK);
const carry = b[0] >> 7;
for (let i = 0; i < BLOCK - 1; i++) out[i] = ((b[i] << 1) | (b[i + 1] >> 7)) & 0xff;
out[BLOCK - 1] = ((b[BLOCK - 1] << 1) ^ (carry * 0x87)) & 0xff;
return out;
}
const times3 = (b: Buffer) => xor(b, times2(b));
// Returns [ciphertext, tag, ok]; ok is false only when an attack pattern was seen and
// modifyOnAttack is off (used by tests)
export function ocbEncrypt(key: Buffer, plain: Buffer, nonce: Buffer, modifyOnAttack = true): [Buffer, Buffer, boolean] {
let delta: Buffer = aes(key, nonce);
let checksum: Buffer = Buffer.alloc(BLOCK);
const out = Buffer.alloc(plain.length);
let ok = true;
let off = 0;
let len = plain.length;
while (len > BLOCK) {
const block = plain.subarray(off, off + BLOCK);
let flip = false;
if (len - BLOCK <= BLOCK) {
let sum = 0;
for (let i = 0; i < BLOCK - 1; i++) sum |= block[i];
if (sum === 0) {
if (modifyOnAttack) flip = true;
else ok = false;
}
}
delta = times2(delta);
const tmp = xor(delta, block);
if (flip) tmp[0] ^= 1;
xor(delta, aes(key, tmp)).copy(out, off);
checksum = xor(checksum, block);
if (flip) checksum[0] ^= 1;
len -= BLOCK;
off += BLOCK;
}
delta = times2(delta);
const lenBlock = Buffer.alloc(BLOCK);
lenBlock[BLOCK - 1] = (len * 8) & 0xff;
const pad = aes(key, xor(lenBlock, delta));
const tmp = Buffer.from(pad);
plain.copy(tmp, 0, off, off + len);
checksum = xor(checksum, tmp);
xor(pad, tmp).copy(out, off, 0, len);
delta = times3(delta);
const tag = aes(key, xor(delta, checksum));
return [out, tag, ok];
}
export function ocbDecrypt(key: Buffer, encrypted: Buffer, nonce: Buffer): [Buffer, Buffer, boolean] {
let delta: Buffer = aes(key, nonce);
let checksum: Buffer = Buffer.alloc(BLOCK);
const out = Buffer.alloc(encrypted.length);
let off = 0;
let len = encrypted.length;
while (len > BLOCK) {
delta = times2(delta);
const plainBlock = xor(delta, aesDecrypt(key, xor(delta, encrypted.subarray(off, off + BLOCK))));
plainBlock.copy(out, off);
checksum = xor(checksum, plainBlock);
len -= BLOCK;
off += BLOCK;
}
delta = times2(delta);
const lenBlock = Buffer.alloc(BLOCK);
lenBlock[BLOCK - 1] = (len * 8) & 0xff;
const pad = aes(key, xor(lenBlock, delta));
const tmp = Buffer.alloc(BLOCK);
encrypted.copy(tmp, 0, off, off + len);
const last = xor(tmp, pad);
checksum = xor(checksum, last);
last.copy(out, off, 0, len);
// Attack check: the decrypted last block must not equal delta (all but the length byte)
const ok = !last.subarray(0, BLOCK - 1).equals(delta.subarray(0, BLOCK - 1));
delta = times3(delta);
const tag = aes(key, xor(delta, checksum));
return [out, tag, ok];
}
export class CryptState {
private key = Buffer.alloc(BLOCK);
encryptIv = Buffer.alloc(BLOCK);
decryptIv = Buffer.alloc(BLOCK);
private history = new Uint8Array(256);
valid = false;
good = 0;
late = 0;
lost = 0;
resync = 0;
lastGood = 0;
setKey(key: Uint8Array, clientNonce: Uint8Array, serverNonce: Uint8Array): void {
this.key = Buffer.from(key);
this.encryptIv = Buffer.from(clientNonce);
this.decryptIv = Buffer.from(serverNonce);
this.history.fill(0);
this.valid = this.key.length === BLOCK && this.encryptIv.length === BLOCK && this.decryptIv.length === BLOCK;
}
setDecryptIv(iv: Uint8Array): void {
this.decryptIv = Buffer.from(iv);
this.resync++;
}
encrypt(plain: Uint8Array): Buffer {
for (let i = 0; i < BLOCK; i++) {
this.encryptIv[i] = (this.encryptIv[i] + 1) & 0xff;
if (this.encryptIv[i]) break;
}
const [ct, tag] = ocbEncrypt(this.key, Buffer.from(plain), this.encryptIv);
const out = Buffer.alloc(ct.length + 4);
out[0] = this.encryptIv[0];
tag.copy(out, 1, 0, 3);
ct.copy(out, 4);
return out;
}
// Mirrors CryptStateOCB2::decrypt: accepts late and out-of-order packets, rejects replays
decrypt(packet: Uint8Array): Buffer | null {
if (!this.valid || packet.length < 4) return null;
const src = Buffer.from(packet);
const save = Buffer.from(this.decryptIv);
const ivbyte = src[0];
const iv = this.decryptIv;
let restore = false;
let late = 0, lost = 0;
if (((iv[0] + 1) & 0xff) === ivbyte) {
if (ivbyte > iv[0]) iv[0] = ivbyte;
else if (ivbyte < iv[0]) {
iv[0] = ivbyte;
for (let i = 1; i < BLOCK; i++) if ((iv[i] = (iv[i] + 1) & 0xff)) break;
} else return null;
} else {
let diff = ivbyte - iv[0];
if (diff > 128) diff -= 256;
else if (diff < -128) diff += 256;
if (ivbyte < iv[0] && diff > -30 && diff < 0) {
late = 1; lost = -1; iv[0] = ivbyte; restore = true;
} else if (ivbyte > iv[0] && diff > -30 && diff < 0) {
late = 1; lost = -1; iv[0] = ivbyte;
for (let i = 1; i < BLOCK; i++) { const was = iv[i]; iv[i] = (was - 1) & 0xff; if (was) break; }
restore = true;
} else if (ivbyte > iv[0] && diff > 0) {
lost = ivbyte - iv[0] - 1; iv[0] = ivbyte;
} else if (ivbyte < iv[0] && diff > 0) {
lost = 256 - iv[0] + ivbyte - 1; iv[0] = ivbyte;
for (let i = 1; i < BLOCK; i++) if ((iv[i] = (iv[i] + 1) & 0xff)) break;
} else return null;
if (this.history[iv[0]] === iv[1]) {
save.copy(this.decryptIv);
return null;
}
}
const [plain, tag, ok] = ocbDecrypt(this.key, src.subarray(4), iv);
if (!ok || !tag.subarray(0, 3).equals(src.subarray(1, 4))) {
save.copy(this.decryptIv);
return null;
}
this.history[iv[0]] = iv[1];
if (restore) save.copy(this.decryptIv);
this.good++;
this.late = Math.max(0, this.late + late);
this.lost = Math.max(0, this.lost + lost);
this.lastGood = Date.now();
return plain;
}
}
+8 -1
View File
@@ -1,7 +1,10 @@
import { contextBridge, ipcRenderer } from 'electron'; import { contextBridge, ipcRenderer } from 'electron';
type Listener = (connId: string, ...args: any[]) => void; type Listener = (connId: string, ...args: any[]) => void;
const listeners = { secure: new Set<Listener>(), data: new Set<Listener>(), close: new Set<Listener>() }; const listeners = {
secure: new Set<Listener>(), data: new Set<Listener>(), close: new Set<Listener>(),
udpVoice: new Set<Listener>(), udpState: new Set<Listener>(), udpResync: new Set<Listener>()
};
for (const key of Object.keys(listeners) as (keyof typeof listeners)[]) { for (const key of Object.keys(listeners) as (keyof typeof listeners)[]) {
ipcRenderer.on(`mumble:${key}`, (_e, connId: string, ...args: any[]) => { ipcRenderer.on(`mumble:${key}`, (_e, connId: string, ...args: any[]) => {
for (const fn of listeners[key]) fn(connId, ...args); for (const fn of listeners[key]) fn(connId, ...args);
@@ -34,6 +37,10 @@ contextBridge.exposeInMainWorld('mumh5Native', {
open: (connId: string, host: string, port: number, identityId?: string) => ipcRenderer.invoke('mumble:open', connId, host, port, identityId), 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), send: (connId: string, bytes: Uint8Array) => ipcRenderer.send('mumble:send', connId, bytes),
close: (connId: string) => ipcRenderer.send('mumble:close', connId), close: (connId: string) => ipcRenderer.send('mumble:close', connId),
udpSetup: (connId: string, key: Uint8Array, cn: Uint8Array, sn: Uint8Array, protobuf: boolean) => ipcRenderer.send('mumble:udpSetup', connId, key, cn, sn, protobuf),
udpNonce: (connId: string, sn: Uint8Array) => ipcRenderer.send('mumble:udpNonce', connId, sn),
udpSend: (connId: string, bytes: Uint8Array) => ipcRenderer.send('mumble:udpSend', connId, bytes),
udpClientNonce: (connId: string) => ipcRenderer.invoke('mumble:udpClientNonce', connId),
on: (event: keyof typeof listeners, fn: Listener) => { on: (event: keyof typeof listeners, fn: Listener) => {
listeners[event].add(fn); listeners[event].add(fn);
return () => listeners[event].delete(fn); return () => listeners[event].delete(fn);
+4 -1
View File
@@ -2,7 +2,7 @@ import tls from 'node:tls';
import { describeCert, type CertDetails } from './certs.ts'; import { describeCert, type CertDetails } from './certs.ts';
export interface TlsHandlers { export interface TlsHandlers {
onSecure(info: { fingerprint: string; authorized: boolean; authError: string | null; chain: CertDetails[] }): void; onSecure(info: { fingerprint: string; authorized: boolean; authError: string | null; chain: CertDetails[]; address: string; port: number }): void;
onData(chunk: Uint8Array): void; onData(chunk: Uint8Array): void;
onClose(reason: string): void; onClose(reason: string): void;
} }
@@ -35,6 +35,9 @@ export function openTls(host: string, port: number, cert: string, key: string, h
} }
h.onSecure({ h.onSecure({
chain, chain,
// The server's actual IP, so UDP voice goes to the same machine as the TCP connection
address: socket.remoteAddress ?? host,
port: socket.remotePort ?? port,
fingerprint: chain[0]?.fingerprint256 ?? '', fingerprint: chain[0]?.fingerprint256 ?? '',
authorized: socket.authorized, authorized: socket.authorized,
authError: socket.authorizationError ? String(socket.authorizationError) : null authError: socket.authorizationError ? String(socket.authorizationError) : null
+145
View File
@@ -0,0 +1,145 @@
import dgram from 'node:dgram';
import net from 'node:net';
import { CryptState } from './ocb2.ts';
import { writeVarint, readVarint } from '../src/core/voice-packet.ts';
import { MumbleUDP } from '../src/core/mumble-udp-pb.js';
import type { UdpChannel } from '../src/core/transport.ts';
// Encrypted UDP voice channel to a Mumble server. Voice uses UDP only while the server
// answers our UDP pings; until then (and if it stops) the client keeps using the TCP tunnel.
export interface UdpCallbacks {
onVoice(plain: Uint8Array): void;
onState(ok: boolean, rtt: number): void;
onResync(): void; // too many decrypt failures: ask the server for a fresh nonce
}
const PING_MS = 2000;
const DEAD_MS = 8000;
export class UdpVoice {
readonly crypt = new CryptState();
ok = false;
rtt = 0;
private sock: dgram.Socket;
private protobuf = true;
private pingTimer: ReturnType<typeof setInterval> | null = null;
private lastPong = 0;
private failures = 0;
private lastResync = 0;
private closed = false;
private host: string;
private port: number;
private cb: UdpCallbacks;
constructor(host: string, port: number, cb: UdpCallbacks) {
this.host = host;
this.port = port;
this.cb = cb;
this.sock = dgram.createSocket(net.isIPv6(host) ? 'udp6' : 'udp4');
this.sock.on('message', msg => this.receive(msg));
this.sock.on('error', () => this.setOk(false));
}
setup(key: Uint8Array, clientNonce: Uint8Array, serverNonce: Uint8Array, protobuf: boolean): void {
this.crypt.setKey(key, clientNonce, serverNonce);
this.protobuf = protobuf;
if (!this.pingTimer) {
this.ping();
this.pingTimer = setInterval(() => this.ping(), PING_MS);
}
}
setServerNonce(nonce: Uint8Array): void {
this.crypt.setDecryptIv(nonce);
this.failures = 0;
}
send(plain: Uint8Array): void {
if (this.closed || !this.crypt.valid) return;
const packet = this.crypt.encrypt(plain);
this.sock.send(packet, this.port, this.host);
}
private ping(): void {
if (this.lastPong && Date.now() - this.lastPong > DEAD_MS) this.setOk(false);
const ts = Date.now();
if (this.protobuf) {
const body: Uint8Array = MumbleUDP.Ping.encode(MumbleUDP.Ping.fromObject({ timestamp: ts })).finish();
const out = new Uint8Array(body.length + 1);
out[0] = 1;
out.set(body, 1);
this.send(out);
} else {
const out: number[] = [1 << 5];
writeVarint(out, ts);
this.send(new Uint8Array(out));
}
}
private receive(msg: Buffer): void {
const plain = this.crypt.decrypt(msg);
if (!plain) {
// Repeated failures mean the nonces drifted apart; ask for a resync now and then
if (++this.failures > 8 && Date.now() - this.lastResync > 5000) {
this.lastResync = Date.now();
this.failures = 0;
this.cb.onResync();
}
return;
}
this.failures = 0;
const pingTs = this.pingTimestamp(plain);
if (pingTs != null) {
this.lastPong = Date.now();
this.rtt = Math.max(0, Date.now() - pingTs);
this.setOk(true);
return;
}
this.cb.onVoice(new Uint8Array(plain));
}
private pingTimestamp(p: Buffer): number | null {
try {
if (p[0] === 1 && this.protobuf) return Number(MumbleUDP.Ping.toObject(MumbleUDP.Ping.decode(p.subarray(1)), { longs: Number }).timestamp);
if (p[0] >> 5 === 1 && !this.protobuf) return readVarint(p, 1)[0];
} catch { /* not a ping */ }
return null;
}
private setOk(ok: boolean): void {
if (ok === this.ok) {
if (ok) this.cb.onState(true, this.rtt);
return;
}
this.ok = ok;
this.cb.onState(ok, this.rtt);
}
close(): void {
this.closed = true;
if (this.pingTimer) clearInterval(this.pingTimer);
try { this.sock.close(); } catch { /* closed */ }
}
}
// UdpVoice behind the client's UdpChannel interface (used directly in Node, bridged over IPC in the app)
export function udpChannel(host: string, port: number): UdpChannel & { close(): void; voice: UdpVoice } {
const ch: UdpChannel & { close(): void; voice: UdpVoice } = {
onVoice: null, onState: null, onResync: null,
setup: (k, c, s, p) => voice.setup(k, c, s, p),
setServerNonce: n => voice.setServerNonce(n),
clientNonce: () => Promise.resolve(voice.crypt.valid ? new Uint8Array(voice.crypt.encryptIv) : null),
send: p => voice.send(p),
close: () => voice.close(),
voice: null as unknown as UdpVoice
};
const voice = new UdpVoice(host, port, {
onVoice: p => ch.onVoice?.(p),
onState: (ok, rtt) => ch.onState?.(ok, rtt),
onResync: () => ch.onResync?.()
});
ch.voice = voice;
return ch;
}
+41 -7
View File
@@ -2,7 +2,7 @@
"name": "mumh5", "name": "mumh5",
"productName": "mumh5", "productName": "mumh5",
"version": "0.1.0", "version": "0.1.0",
"description": "Modern Mumble client with rich chat, file sharing and video", "description": "A modern Mumble client: Discord-like interface, file sharing, voice and more, for any Mumble server",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
"main": "dist-electron/main.cjs", "main": "dist-electron/main.cjs",
@@ -26,24 +26,37 @@
"files": [ "files": [
"dist/**", "dist/**",
"dist-electron/**", "dist-electron/**",
"!dist-electron/*.map",
"package.json", "package.json",
"LICENSE", "LICENSE",
"THIRD_PARTY_NOTICES.md" "THIRD_PARTY_NOTICES.md"
], ],
"directories": { "directories": {
"output": "release" "output": "release",
"buildResources": "build"
}, },
"linux": { "linux": {
"target": [ "target": [
"AppImage", "AppImage",
"deb" "deb",
"tar.gz"
], ],
"category": "Network" "category": "Network",
"executableName": "mumh5",
"synopsis": "Modern Mumble client",
"desktop": {
"entry": {
"StartupWMClass": "mumh5",
"Keywords": "mumble;voice;chat;voip;"
}
},
"syncDesktopName": true
}, },
"win": { "win": {
"target": [ "target": [
"nsis" "nsis"
] ],
"icon": "build/icon.png"
}, },
"mac": { "mac": {
"target": [ "target": [
@@ -54,6 +67,22 @@
"NSMicrophoneUsageDescription": "mumh5 uses the microphone for voice chat.", "NSMicrophoneUsageDescription": "mumh5 uses the microphone for voice chat.",
"NSCameraUsageDescription": "mumh5 uses the camera for video chat." "NSCameraUsageDescription": "mumh5 uses the camera for video chat."
} }
},
"icon": "build/icon.png",
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
"deb": {
"depends": [
"libnotify4",
"libxtst6",
"libnss3",
"libasound2 | libasound2t64"
]
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"perMachine": false,
"shortcutName": "mumh5"
} }
}, },
"devDependencies": { "devDependencies": {
@@ -81,9 +110,14 @@
"esbuild@0.28.2": true, "esbuild@0.28.2": true,
"electron-winstaller": false "electron-winstaller": false
}, },
"author": "Kibi Kelburton", "author": {
"name": "Kibi Kelburton",
"email": "git@f0ck.it"
},
"repository": { "repository": {
"type": "git", "type": "git",
"url": "gitea@git.lat:kibi/mumh5.git" "url": "gitea@git.lat:kibi/mumh5.git"
} },
"homepage": "https://git.lat/kibi/mumh5",
"desktopName": "mumh5.desktop"
} }
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

+45 -11
View File
@@ -14,6 +14,7 @@
import UserInfoDialog from './ui/UserInfoDialog.svelte'; import UserInfoDialog from './ui/UserInfoDialog.svelte';
import ChannelDialog from './ui/ChannelDialog.svelte'; import ChannelDialog from './ui/ChannelDialog.svelte';
import PublicServers from './ui/PublicServers.svelte'; import PublicServers from './ui/PublicServers.svelte';
import ResizeHandle from './ui/ResizeHandle.svelte';
import { identities } from './lib/identities.svelte.ts'; import { identities } from './lib/identities.svelte.ts';
import { session } from './lib/session.svelte.ts'; import { session } from './lib/session.svelte.ts';
import { ui } from './lib/ui.svelte.ts'; import { ui } from './lib/ui.svelte.ts';
@@ -23,6 +24,13 @@
// wide: rail + sidebar + chat + panel; medium: panel as an overlay; narrow: everything but chat in drawers // 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'); const layout = $derived(width >= 1100 ? 'wide' : width >= 760 ? 'medium' : 'narrow');
// Arrangement (Appearance setting): side is the default, classic puts the channel list right
// of the chat, stacked above it. Phones always use drawers.
const arrangement = $derived(layout === 'narrow' ? 'side' : store.settings.arrangement ?? 'side');
// The member panel sits inline only in the side arrangement on wide windows, otherwise it overlays
const panelInline = $derived(layout === 'wide' && arrangement === 'side');
let treeHeight = $state(Math.max(120, Number((() => { try { return localStorage.getItem('mumh5.treeHeight'); } catch { return null; } })()) || 280));
let navOpen = $state(false); let navOpen = $state(false);
let editing = $state<{ server: SavedServer | null } | null>(null); let editing = $state<{ server: SavedServer | null } | null>(null);
@@ -35,12 +43,13 @@
else delete document.documentElement.dataset.theme; else delete document.documentElement.dataset.theme;
}); });
// The panel starts open on wide layouts only // The panel starts open only where it sits inline
let lastLayout = ''; let lastLayout = '';
$effect(() => { $effect(() => {
if (layout === lastLayout) return; const key = `${layout}/${arrangement}`;
ui.panelOpen = layout === 'wide'; if (key === lastLayout) return;
lastLayout = layout; ui.panelOpen = panelInline;
lastLayout = key;
}); });
// On phones one drawer at a time // On phones one drawer at a time
$effect(() => { if (layout === 'narrow' && ui.panelOpen) navOpen = false; }); $effect(() => { if (layout === 'narrow' && ui.panelOpen) navOpen = false; });
@@ -53,26 +62,48 @@
const closeOverlays = () => { const closeOverlays = () => {
navOpen = false; navOpen = false;
if (layout !== 'wide') ui.panelOpen = false; if (!panelInline) ui.panelOpen = false;
}; };
</script> </script>
<svelte:window bind:innerWidth={width} /> <svelte:window bind:innerWidth={width} />
<div class="app {layout}"> {#snippet rail()}
{#if layout !== 'narrow' || navOpen}
<div class="nav" class:drawer={layout === 'narrow'}>
<ServerRail onadd={() => (editing = { server: null })} onedit={s => (editing = { server: s })} onbrowse={() => (ui.publicServers = true)} <ServerRail onadd={() => (editing = { server: null })} onedit={s => (editing = { server: s })} onbrowse={() => (ui.publicServers = true)}
onsettings={() => (ui.settingsOpen = true)} /> onsettings={() => (ui.settingsOpen = true)} />
{/snippet}
{#snippet chat()}
<Chat showMenu={layout === 'narrow'} showStage={!panelInline && arrangement === 'side'} onmenu={() => (navOpen = true)} onpanel={togglePanel} />
{/snippet}
<div class="app {layout} {arrangement}">
{#if arrangement === 'classic'}
{@render rail()}
{@render chat()}
<Sidebar handleEdge="left" onnavigate={() => {}} />
{:else if arrangement === 'stacked'}
{@render rail()}
<div class="stack">
<div class="tree" style:height="{treeHeight}px">
<Sidebar fill onnavigate={() => {}} />
<ResizeHandle bind:width={treeHeight} edge="bottom" min={120} max={() => window.innerHeight - 220} initial={280}
storageKey="mumh5.treeHeight" label="Resize channel list" />
</div>
{@render chat()}
</div>
{:else}
{#if layout !== 'narrow' || navOpen}
<div class="nav" class:drawer={layout === 'narrow'}>
{@render rail()}
<Sidebar resizable={layout !== 'narrow'} onnavigate={() => { if (layout === 'narrow') navOpen = false; }} /> <Sidebar resizable={layout !== 'narrow'} onnavigate={() => { if (layout === 'narrow') navOpen = false; }} />
</div> </div>
{#if layout === 'narrow'}<button class="scrim" aria-label="Close" onclick={() => (navOpen = false)}></button>{/if} {#if layout === 'narrow'}<button class="scrim" aria-label="Close" onclick={() => (navOpen = false)}></button>{/if}
{/if} {/if}
{@render chat()}
<Chat showMenu={layout === 'narrow'} showStage={layout !== 'wide'} onmenu={() => (navOpen = true)} onpanel={togglePanel} /> {/if}
{#if showPanel} {#if showPanel}
{#if layout === 'wide'} {#if panelInline}
<RightPanel onnavigate={() => {}} onclose={() => (ui.panelOpen = false)} /> <RightPanel onnavigate={() => {}} onclose={() => (ui.panelOpen = false)} />
{:else} {:else}
<button class="scrim" aria-label="Close" onclick={() => (ui.panelOpen = false)}></button> <button class="scrim" aria-label="Close" onclick={() => (ui.panelOpen = false)}></button>
@@ -104,6 +135,9 @@
.nav { display: flex; flex: none; min-height: 0; } .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 { 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)); } .nav.drawer :global(.sidebar) { width: min(260px, calc(100vw - 68px - 48px)); }
.stack { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.stack > :global(.chat) { flex: 1; min-height: 0; }
.tree { position: relative; z-index: 1; flex: none; display: flex; min-height: 0; border-bottom: 1px solid var(--line); }
.scrim { position: absolute; inset: 0; z-index: 15; background: rgb(0 0 0 / 0.5); } .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); } .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> </style>
+37
View File
@@ -156,6 +156,7 @@ type ClientEvents = {
reject: (type: number, reason: string) => void; reject: (type: number, reason: string) => void;
voice: (packet: Uint8Array) => void; voice: (packet: Uint8Array) => void;
userStats: (stats: any) => void; userStats: (stats: any) => void;
udp: (ok: boolean, rtt: number) => void;
acl: (acl: ChannelAcl) => void; acl: (acl: ChannelAcl) => void;
userNames: (names: Map<number, string>) => void; userNames: (names: Map<number, string>) => void;
permissions: (channelId: number | null, bits: number) => void; permissions: (channelId: number | null, bits: number) => void;
@@ -178,6 +179,11 @@ export class MumbleClient extends Emitter<ClientEvents> {
serverVersion = ''; serverVersion = '';
// Numeric server version (major << 16 | minor << 8 | patch), 0 until known // Numeric server version (major << 16 | minor << 8 | patch), 0 until known
serverVersionNum = 0; serverVersionNum = 0;
// Voice goes over encrypted UDP while the server answers our UDP pings, otherwise over TCP
udpOk = false;
udpRtt = 0;
// Force the TCP tunnel even when UDP would work (for networks that mangle UDP)
forceTcp = false;
rtt = 0; rtt = 0;
config: ServerConfig = { allowHtml: true, messageLength: 5000, imageMessageLength: 131072, maxUsers: 0, recordingAllowed: true }; config: ServerConfig = { allowHtml: true, messageLength: 5000, imageMessageLength: 131072, maxUsers: 0, recordingAllowed: true };
@@ -203,6 +209,18 @@ export class MumbleClient extends Emitter<ClientEvents> {
connect(transport: Transport, opts: ConnectOptions): void { connect(transport: Transport, opts: ConnectOptions): void {
this.transport = transport; this.transport = transport;
const udp = transport.udp;
if (udp) {
udp.onVoice = plain => { if (!this.closed) this.emit('voice', plain); };
udp.onState = (ok, rtt) => {
const changed = ok !== this.udpOk;
this.udpOk = ok;
this.udpRtt = rtt;
if (changed) this.emit('udp', ok, rtt);
};
// Ask the server for a fresh nonce (an empty CryptSetup)
udp.onResync = () => this.send('CryptSetup', {});
}
transport.onData = chunk => { transport.onData = chunk => {
try { try {
this.reader.push(chunk, (id, body) => this.handleFrame(id, body)); this.reader.push(chunk, (id, body) => this.handleFrame(id, body));
@@ -409,6 +427,12 @@ export class MumbleClient extends Emitter<ClientEvents> {
}); });
} }
// Voice over UDP when it works, else through the TCP tunnel
sendVoice(packet: Uint8Array): void {
if (this.udpOk && !this.forceTcp && this.transport?.udp) this.transport.udp.send(packet);
else this.sendVoiceTunnel(packet);
}
// Voice over TCP: the UDPTunnel body is the raw voice packet, not a protobuf message // Voice over TCP: the UDPTunnel body is the raw voice packet, not a protobuf message
sendVoiceTunnel(packet: Uint8Array): void { sendVoiceTunnel(packet: Uint8Array): void {
if (!this.transport || this.closed) return; if (!this.transport || this.closed) return;
@@ -529,6 +553,19 @@ export class MumbleClient extends Emitter<ClientEvents> {
case 'UserStats': case 'UserStats':
this.emit('userStats', msg); this.emit('userStats', msg);
break; break;
case 'CryptSetup': {
const udp = this.transport?.udp;
if (!udp || this.forceTcp) break;
if (msg.key?.length && msg.client_nonce?.length && msg.server_nonce?.length) {
udp.setup(msg.key, msg.client_nonce, msg.server_nonce, this.protobufVoice);
} else if (msg.server_nonce?.length) {
udp.setServerNonce(msg.server_nonce);
} else {
// The server asks for our nonce to resync its side
udp.clientNonce().then(n => { if (n) this.send('CryptSetup', { client_nonce: n }); });
}
break;
}
case 'ACL': { case 'ACL': {
// proto2 defaults: missing booleans are true // proto2 defaults: missing booleans are true
const t = (v: unknown) => v !== false; const t = (v: unknown) => v !== false;
+12
View File
@@ -4,6 +4,18 @@ export interface Transport {
close(): void; close(): void;
onData: ((chunk: Uint8Array) => void) | null; onData: ((chunk: Uint8Array) => void) | null;
onClose: ((reason: string) => void) | null; onClose: ((reason: string) => void) | null;
// Encrypted UDP for voice, where the platform has it (desktop); absent on the web
udp?: UdpChannel;
}
export interface UdpChannel {
setup(key: Uint8Array, clientNonce: Uint8Array, serverNonce: Uint8Array, protobuf: boolean): void;
setServerNonce(nonce: Uint8Array): void;
clientNonce(): Promise<Uint8Array | null>;
send(plain: Uint8Array): void;
onVoice: ((plain: Uint8Array) => void) | null;
onState: ((ok: boolean, rtt: number) => void) | null;
onResync: (() => void) | null;
} }
export interface ConnectTarget { export interface ConnectTarget {
+3 -1
View File
@@ -21,6 +21,7 @@ export interface VoiceSettings {
bitrate: number; // Opus bits per second bitrate: number; // Opus bits per second
frameMs: 10 | 20 | 40 | 60; frameMs: 10 | 20 | 40 | 60;
jitterMs: number; jitterMs: number;
forceTcp: boolean; // send voice through the TCP connection even when UDP works
// Per-user local volume and mute, keyed by certificate hash (or name without one) // Per-user local volume and mute, keyed by certificate hash (or name without one)
userVolumes: Record<string, number>; userVolumes: Record<string, number>;
localMutes: Record<string, boolean>; localMutes: Record<string, boolean>;
@@ -41,6 +42,7 @@ const defaults: VoiceSettings = {
bitrate: 40000, bitrate: 40000,
frameMs: 20, frameMs: 20,
jitterMs: 60, jitterMs: 60,
forceTcp: false,
userVolumes: {}, userVolumes: {},
localMutes: {} localMutes: {}
}; };
@@ -337,7 +339,7 @@ class VoiceEngine {
private sendPacket(opus: Uint8Array, last: boolean): void { private sendPacket(opus: Uint8Array, last: boolean): void {
const client = this.client; const client = this.client;
if (!client) return; if (!client) return;
client.sendVoiceTunnel(encodeVoice({ target: 0, frame: this.frameCounter, opus, last }, client.protobufVoice)); client.sendVoice(encodeVoice({ target: 0, frame: this.frameCounter, opus, last }, client.protobufVoice));
this.frameCounter += this.settings.frameMs / 10; this.frameCounter += this.settings.frameMs / 10;
this.stats.sent++; this.stats.sent++;
} }
+19 -2
View File
@@ -1,4 +1,4 @@
import type { Transport, ServerCertInfo, CertDetails } from '../core/transport.ts'; import type { Transport, ServerCertInfo, CertDetails, UdpChannel } from '../core/transport.ts';
interface NativeApi { interface NativeApi {
platformInfo(): Promise<{ os: string; osVersion: string }>; platformInfo(): Promise<{ os: string; osVersion: string }>;
@@ -25,7 +25,11 @@ interface NativeApi {
open(connId: string, host: string, port: number, identityId?: string): Promise<void>; open(connId: string, host: string, port: number, identityId?: string): Promise<void>;
send(connId: string, bytes: Uint8Array): void; send(connId: string, bytes: Uint8Array): void;
close(connId: string): void; close(connId: string): void;
on(event: 'secure' | 'data' | 'close', fn: (connId: string, ...args: any[]) => void): () => void; on(event: 'secure' | 'data' | 'close' | 'udpVoice' | 'udpState' | 'udpResync', fn: (connId: string, ...args: any[]) => void): () => void;
udpSetup(connId: string, key: Uint8Array, cn: Uint8Array, sn: Uint8Array, protobuf: boolean): void;
udpNonce(connId: string, sn: Uint8Array): void;
udpSend(connId: string, bytes: Uint8Array): void;
udpClientNonce(connId: string): Promise<Uint8Array | null>;
} }
// What Electron reports for a right-click without a custom menu // What Electron reports for a right-click without a custom menu
@@ -76,6 +80,7 @@ export class ElectronTransport implements Transport {
onData: ((chunk: Uint8Array) => void) | null = null; onData: ((chunk: Uint8Array) => void) | null = null;
onClose: ((reason: string) => void) | null = null; onClose: ((reason: string) => void) | null = null;
readonly secure: Promise<ServerCertInfo>; readonly secure: Promise<ServerCertInfo>;
udp: UdpChannel;
private id = crypto.randomUUID(); private id = crypto.randomUUID();
private offs: (() => void)[] = []; private offs: (() => void)[] = [];
private api: NativeApi; private api: NativeApi;
@@ -86,7 +91,19 @@ export class ElectronTransport implements Transport {
let rejectSecure!: (e: Error) => void; let rejectSecure!: (e: Error) => void;
this.secure = new Promise((res, rej) => { resolveSecure = res; rejectSecure = rej; }); this.secure = new Promise((res, rej) => { resolveSecure = res; rejectSecure = rej; });
const id = this.id; const id = this.id;
// UDP voice lives in the main process; this bridges it to the client's UdpChannel
const udp: UdpChannel = {
onVoice: null, onState: null, onResync: null,
setup: (k, c, s, p) => api.udpSetup(id, k, c, s, p),
setServerNonce: n => api.udpNonce(id, n),
clientNonce: () => api.udpClientNonce(id),
send: p => api.udpSend(id, p)
};
this.udp = udp;
this.offs.push( this.offs.push(
api.on('udpVoice', (cid, p) => { if (cid === id) udp.onVoice?.(p); }),
api.on('udpState', (cid, ok, rtt) => { if (cid === id) udp.onState?.(ok, rtt); }),
api.on('udpResync', cid => { if (cid === id) udp.onResync?.(); }),
api.on('secure', (cid, info) => { if (cid === id) resolveSecure(info); }), api.on('secure', (cid, info) => { if (cid === id) resolveSecure(info); }),
api.on('data', (cid, chunk) => { if (cid === id) this.onData?.(chunk); }), api.on('data', (cid, chunk) => { if (cid === id) this.onData?.(chunk); }),
api.on('close', (cid, reason) => { api.on('close', (cid, reason) => {
+10
View File
@@ -161,6 +161,14 @@ export class Session {
this.expanded = all; this.expanded = all;
} }
// How voice travels right now
get voiceTransport(): { udp: boolean; rtt: number } {
void this.tick;
const c = this.client;
// The setting (reactive) rather than the client's copy, so toggling it updates the view
return { udp: !!c && c.udpOk && !voice.settings.forceTcp, rtt: c?.udpRtt ?? 0 };
}
// true / false when known, null when the server has not told us yet // true / false when known, null when the server has not told us yet
can(channelId: number, bit: number): boolean | null { can(channelId: number, bit: number): boolean | null {
void this.tick; void this.tick;
@@ -236,6 +244,7 @@ export class Session {
} }
const client = new MumbleClient(codec); const client = new MumbleClient(codec);
client.forceTcp = voice.settings.forceTcp;
this.client = client; this.client = client;
this.wire(client); this.wire(client);
// TLS is up but the server may still never finish the login // TLS is up but the server may still never finish the login
@@ -309,6 +318,7 @@ export class Session {
bump(); bump();
}); });
client.on('ping', rtt => { if (live()) this.rtt = rtt; }); client.on('ping', rtt => { if (live()) this.rtt = rtt; });
client.on('udp', bump);
client.on('user', (user, changed, actor, isNew) => { client.on('user', (user, changed, actor, isNew) => {
if (!live()) return; if (!live()) return;
+6 -1
View File
@@ -23,8 +23,12 @@ export interface Settings {
linkPreviews: 'host' | 'direct' | 'off'; linkPreviews: 'host' | 'direct' | 'off';
theme: ThemeId; theme: ThemeId;
publicListUrl: string; // where the public server list comes from publicListUrl: string; // where the public server list comes from
arrangement: Arrangement;
} }
// Window layouts; classic and stacked follow the Mumble desktop client's layouts of the same name
export type Arrangement = 'side' | 'classic' | 'stacked';
export type ThemeId = 'dark' | 'light' | 'midnight' | 'frost' | 'win95'; export type ThemeId = 'dark' | 'light' | 'midnight' | 'frost' | 'win95';
export const THEMES: { id: ThemeId; name: string; note: string }[] = [ export const THEMES: { id: ThemeId; name: string; note: string }[] = [
@@ -45,7 +49,8 @@ const defaults: Settings = {
youtube: 'click', youtube: 'click',
linkPreviews: 'host', linkPreviews: 'host',
theme: 'dark', theme: 'dark',
publicListUrl: 'https://publist.mumble.info/v1/list?version=1.5.0' publicListUrl: 'https://publist.mumble.info/v1/list?version=1.5.0',
arrangement: 'side'
}; };
class SettingsStore { class SettingsStore {
+46 -1
View File
@@ -1,5 +1,16 @@
<script lang="ts"> <script lang="ts">
import { store, THEMES, type ThemeId } from '../lib/settings.svelte.ts'; import { store, THEMES, type ThemeId, type Arrangement } from '../lib/settings.svelte.ts';
const ARRANGEMENTS: { id: Arrangement; name: string; note: string }[] = [
{ id: 'side', name: 'Modern', note: 'Channels left, members right' },
{ id: 'classic', name: 'Classic', note: 'Chat left, channels right, like Mumble' },
{ id: 'stacked', name: 'Stacked', note: 'Channels on top, chat below, like Mumble' }
];
function arrange(id: Arrangement) {
store.settings.arrangement = id;
store.saveSettings();
}
function pick(id: ThemeId) { function pick(id: ThemeId) {
store.settings.theme = id; store.settings.theme = id;
@@ -28,7 +39,41 @@
{/each} {/each}
</div> </div>
<h3 class="gap">Layout</h3>
<div class="themes" role="radiogroup" aria-label="Layout">
{#each ARRANGEMENTS as a (a.id)}
{@const on = (store.settings.arrangement ?? 'side') === a.id}
<button role="radio" aria-checked={on} class:active={on} onclick={() => arrange(a.id)} aria-label={a.name}>
<span class="mini lay {a.id}" aria-hidden="true">
<span class="m-rail"><span></span><span></span></span>
{#if a.id === 'side'}<span class="m-side"><i></i><i class="sel"></i><i></i></span>{/if}
{#if a.id === 'stacked'}
<span class="m-col">
<span class="m-side"><i></i><i class="sel"></i></span>
<span class="m-chat"><span class="m-head"></span><i class="a"></i><i></i><span class="m-box"></span></span>
</span>
{:else}
<span class="m-chat"><span class="m-head"></span><i class="a"></i><i></i><i class="short"></i><span class="m-box"></span></span>
{/if}
{#if a.id === 'classic'}<span class="m-side"><i></i><i class="sel"></i><i></i></span>{/if}
{#if a.id === 'side'}<span class="m-members"><i></i><i></i></span>{/if}
</span>
<span class="name">{a.name}</span>
<span class="note">{a.note}</span>
</button>
{/each}
</div>
<p class="help">Narrow windows always use the phone layout.</p>
<style> <style>
.gap { margin-top: 22px; }
.help { margin: 6px 0 0; font-size: 13px; color: var(--text-dim); }
.m-col { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.m-col .m-chat { flex: 1; }
.m-col .m-side { width: auto; height: 34%; flex: none; padding: 5px; gap: 4px; border-bottom: 1px solid var(--line); }
.m-members { width: 18%; background: var(--bg-1); display: flex; flex-direction: column; gap: 5px; padding: 8px 4px; border-left: 1px solid var(--line); }
.m-members i { height: 4px; background: var(--text-faint); width: 80%; }
.lay.classic .m-side { border-left: 1px solid var(--line); }
h3 { margin: 8px 0 0; font-size: 14px; } h3 { margin: 8px 0 0; font-size: 14px; }
.themes { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 10px; margin-top: 10px; } .themes { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 10px; margin-top: 10px; }
.themes > button { display: flex; flex-direction: column; gap: 2px; padding: 8px; text-align: left; border: 1px solid var(--line); background: var(--bg-0); } .themes > button { display: flex; flex-direction: column; gap: 2px; padding: 8px; text-align: left; border: 1px solid var(--line); background: var(--bg-0); }
+8 -3
View File
@@ -58,13 +58,18 @@
{:else} {:else}
<h2>mumh5</h2> <h2>mumh5</h2>
<p>Choose a server to get started, or find one:</p> <p>Choose a server to get started, or find one:</p>
<button class="btn browse" onclick={() => (ui.publicServers = true)}>Browse public servers</button> <div class="welcome-actions">
<button class="btn" onclick={() => (ui.publicServers = true)}>Browse public servers</button>
<button class="btn" onclick={() => { ui.settingsTab = 'identities'; ui.settingsOpen = true; }}>Manage identities</button>
</div>
{/if} {/if}
</div> </div>
{/snippet} {/snippet}
</ChatLog> </ChatLog>
<Composer target={session.view} placeholder={session.status === 'connected' ? `Message ${isDm ? '@' : '#'}${title}` : 'Not connected'} /> {#if session.status === 'connected'}
<Composer target={session.view} placeholder={`Message ${isDm ? '@' : '#'}${title}`} />
{/if}
</section> </section>
<style> <style>
@@ -79,7 +84,7 @@
.empty { padding: 32px 16px 8px; } .empty { padding: 32px 16px 8px; }
.empty h2 { margin: 0 0 4px; font-size: 24px; } .empty h2 { margin: 0 0 4px; font-size: 24px; }
.empty p { margin: 0; color: var(--text-dim); } .empty p { margin: 0; color: var(--text-dim); }
.browse { margin-top: 12px; } .welcome-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
.ended { max-width: 460px; margin: 16px 16px 0; padding: 16px 18px; background: var(--bg-1); border: 1px solid var(--line); border-left: 4px solid var(--warn); } .ended { max-width: 460px; margin: 16px 16px 0; padding: 16px 18px; background: var(--bg-1); border: 1px solid var(--line); border-left: 4px solid var(--warn); }
.ended.bad { border-left-color: var(--danger); } .ended.bad { border-left-color: var(--danger); }
.ended h2 { margin: 0; font-size: 20px; } .ended h2 { margin: 0; font-size: 20px; }
+7 -29
View File
@@ -14,8 +14,6 @@
let picker: HTMLInputElement; let picker: HTMLInputElement;
let imagePicker: HTMLInputElement; let imagePicker: HTMLInputElement;
let empty = $state(true); let empty = $state(true);
let source = $state(false);
let sourceText = $state('');
// The formatting toolbar is opt-in and remembered // The formatting toolbar is opt-in and remembered
let formatting = $state(loadFormatting()); let formatting = $state(loadFormatting());
const disabled = $derived(session.status !== 'connected'); const disabled = $derived(session.status !== 'connected');
@@ -27,7 +25,6 @@
function toggleFormatting() { function toggleFormatting() {
formatting = !formatting; formatting = !formatting;
try { localStorage.setItem('mumh5.formatBar', formatting ? '1' : '0'); } catch { /* storage unavailable */ } try { localStorage.setItem('mumh5.formatBar', formatting ? '1' : '0'); } catch { /* storage unavailable */ }
if (!formatting && source) toggleSource();
input.focus(); input.focus();
} }
@@ -45,16 +42,14 @@
} }
function submit() { function submit() {
const html = source ? sourceText : input.innerHTML; const html = input.innerHTML;
// Plain text goes out the usual way; anything formatted as sanitized XHTML // Plain text goes out the usual way; anything formatted as sanitized XHTML
const formatted = source || !!input.querySelector('b, strong, i, em, u, s, strike, span[style], font, ul, ol, a, img'); const formatted = !!input.querySelector('b, strong, i, em, u, s, strike, span[style], font, ul, ol, a, img');
const ok = formatted const ok = formatted
? session.sendHtml(toMumbleHtml(html, { linkify: true }), target) ? session.sendHtml(toMumbleHtml(html, { linkify: true }), target)
: session.sendText(input.innerText, target); : session.sendText(input.innerText, target);
if (!ok) return; if (!ok) return;
input.innerHTML = ''; input.innerHTML = '';
sourceText = '';
if (source) source = false;
sync(); sync();
} }
@@ -97,17 +92,6 @@
}; };
} }
function toggleSource() {
if (!source) {
sourceText = toMumbleHtml(input.innerHTML);
source = true;
} else {
input.innerHTML = toMumbleHtml(sourceText);
source = false;
sync();
}
}
$effect(() => { $effect(() => {
void target; void target;
if (autofocus && !disabled && matchMedia('(pointer: fine)').matches) input.focus(); if (autofocus && !disabled && matchMedia('(pointer: fine)').matches) input.focus();
@@ -128,7 +112,7 @@
{/if} {/if}
<div class="frame"> <div class="frame">
{#if formatting} {#if formatting}
<FormatToolbar {exec} onlink={link} onimage={() => imagePicker.click()} {source} ontogglesource={toggleSource} /> <FormatToolbar {exec} onlink={link} onimage={() => imagePicker.click()} />
{/if} {/if}
<div class="box"> <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> <button class="icon-btn" title={store.settings.uploadHost ? 'Upload a file' : 'Send an image'} {disabled} onclick={() => picker.click()}><Icon name="paperclip" /></button>
@@ -137,33 +121,27 @@
onchange={() => { session.uploadFiles([...(picker.files ?? [])], target); picker.value = ''; }} /> onchange={() => { session.uploadFiles([...(picker.files ?? [])], target); picker.value = ''; }} />
<input bind:this={imagePicker} type="file" accept="image/*" hidden <input bind:this={imagePicker} type="file" accept="image/*" hidden
onchange={() => { session.uploadFiles([...(imagePicker.files ?? [])], target); imagePicker.value = ''; }} /> onchange={() => { session.uploadFiles([...(imagePicker.files ?? [])], target); imagePicker.value = ''; }} />
<div class="input" class:blank={empty} class:hidden={source} bind:this={input} contenteditable={!disabled} <div class="input" class:blank={empty} bind:this={input} contenteditable={!disabled}
role="textbox" aria-multiline="true" aria-label="Message" tabindex="0" data-placeholder={placeholder} role="textbox" aria-multiline="true" aria-label="Message" tabindex="0" data-placeholder={placeholder}
oninput={sync} {onkeydown} {onpaste}></div> oninput={sync} {onkeydown} {onpaste}></div>
{#if source}
<textarea class="input src" bind:value={sourceText} aria-label="HTML source" spellcheck="false"
onkeydown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submit(); } }}></textarea>
{/if}
<button class="icon-btn fmt" class:on={formatting} title="Text formatting" aria-label="Text formatting" aria-pressed={formatting} <button class="icon-btn fmt" class:on={formatting} title="Text formatting" aria-label="Text formatting" aria-pressed={formatting}
{disabled} onmousedown={e => e.preventDefault()} onclick={toggleFormatting}>Aa</button> {disabled} onmousedown={e => e.preventDefault()} onclick={toggleFormatting}>Aa</button>
<button class="icon-btn send" title="Send" disabled={disabled || (empty && !(source && sourceText.trim()))} onclick={submit}><Icon name="send" size={18} /></button> <button class="icon-btn send" title="Send" disabled={disabled || empty} onclick={submit}><Icon name="send" size={18} /></button>
</div> </div>
</div> </div>
</div> </div>
<style> <style>
/* Same height as the user panel at the bottom of the sidebar; grows for longer messages */ /* Same height as the user panel at the bottom of the sidebar; grows for longer messages */
.composer { flex: none; min-height: var(--footer-h); display: flex; flex-direction: column; justify-content: flex-end; padding: 6px 16px; background: var(--bg-0); } .composer { flex: none; min-height: var(--footer-h); display: flex; flex-direction: column; justify-content: flex-end; padding: 0; background: var(--bg-0); }
.frame { background: var(--bg-3); } .frame { background: var(--bg-3); }
.frame :global(.toolbar) { background: var(--bg-2); } .frame :global(.toolbar) { background: var(--bg-2); }
.box { display: flex; align-items: flex-end; gap: 4px; min-height: calc(var(--footer-h) - 12px); padding: 2px 4px; } .box { display: flex; align-items: flex-end; gap: 4px; min-height: var(--footer-h); padding: 6px 4px; }
.input { position: relative; flex: 1; min-width: 0; padding: 8px 4px; max-height: 200px; overflow-y: auto; line-height: 1.4; outline: none; overflow-wrap: anywhere; white-space: pre-wrap; } .input { position: relative; flex: 1; min-width: 0; padding: 8px 4px; max-height: 200px; overflow-y: auto; line-height: 1.4; outline: none; overflow-wrap: anywhere; white-space: pre-wrap; }
.input.hidden { display: none; }
.input.blank::before { content: attr(data-placeholder); position: absolute; left: 4px; top: 8px; color: var(--text-faint); pointer-events: none; } .input.blank::before { content: attr(data-placeholder); position: absolute; left: 4px; top: 8px; color: var(--text-faint); pointer-events: none; }
.input :global(img) { max-height: 120px; } .input :global(img) { max-height: 120px; }
.input :global(ul), .input :global(ol) { margin: 0; padding-left: 20px; } .input :global(ul), .input :global(ol) { margin: 0; padding-left: 20px; }
.input[contenteditable='false'] { color: var(--text-faint); } .input[contenteditable='false'] { color: var(--text-faint); }
.src { border: 0; resize: none; background: transparent; font: 12px var(--mono); min-height: 60px; }
.fmt { font-weight: 700; font-size: 13px; } .fmt { font-weight: 700; font-size: 13px; }
.fmt.on { color: var(--accent-hover); } .fmt.on { color: var(--accent-hover); }
.send:not(:disabled) { color: var(--accent-hover); } .send:not(:disabled) { color: var(--accent-hover); }
+3 -1
View File
@@ -4,7 +4,7 @@
// Formatting controls shared by the description editor and the message input. // Formatting controls shared by the description editor and the message input.
// Buttons keep the text selection (mousedown is suppressed) and act through the callbacks. // Buttons keep the text selection (mousedown is suppressed) and act through the callbacks.
let { exec, onlink, onimage, source = false, ontogglesource }: let { exec, onlink, onimage, source = false, ontogglesource }:
{ exec: (cmd: string, arg?: string) => void; onlink: () => void; onimage: () => void; source?: boolean; ontogglesource: () => void } = $props(); { exec: (cmd: string, arg?: string) => void; onlink: () => void; onimage: () => void; source?: boolean; ontogglesource?: () => void } = $props();
let color = $state('#e8b23c'); let color = $state('#e8b23c');
const keep = (e: MouseEvent) => e.preventDefault(); const keep = (e: MouseEvent) => e.preventDefault();
@@ -32,8 +32,10 @@
<button class="tool" title="Link" aria-label="Link" disabled={source} onmousedown={keep} onclick={onlink}><Icon name="link" size={16} /></button> <button class="tool" title="Link" aria-label="Link" disabled={source} onmousedown={keep} onclick={onlink}><Icon name="link" size={16} /></button>
<button class="tool" title="Insert picture" aria-label="Insert picture" disabled={source} onmousedown={keep} onclick={onimage}><Icon name="image" size={16} /></button> <button class="tool" title="Insert picture" aria-label="Insert picture" disabled={source} onmousedown={keep} onclick={onimage}><Icon name="image" size={16} /></button>
<button class="tool" title="Clear formatting" aria-label="Clear formatting" disabled={source} onmousedown={keep} onclick={() => exec('removeFormat')}><Icon name="eraser" size={16} /></button> <button class="tool" title="Clear formatting" aria-label="Clear formatting" disabled={source} onmousedown={keep} onclick={() => exec('removeFormat')}><Icon name="eraser" size={16} /></button>
{#if ontogglesource}
<span class="grow"></span> <span class="grow"></span>
<button class="tool" class:on={source} title="Edit HTML" aria-label="Edit HTML" aria-pressed={source} onclick={ontogglesource}><Icon name="code" size={16} /></button> <button class="tool" class:on={source} title="Edit HTML" aria-label="Edit HTML" aria-pressed={source} onclick={ontogglesource}><Icon name="code" size={16} /></button>
{/if}
</div> </div>
<style> <style>
+10 -4
View File
@@ -26,17 +26,23 @@
async function load() { async function load() {
// A new list starts a new ping round; answers from the previous round are dropped // A new list starts a new ping round; answers from the previous round are dropped
generation++; // and a list that arrives after the source changed is ignored
const round = ++generation;
queue = []; queue = [];
running = 0; running = 0;
loading = true; loading = true;
error = ''; error = '';
let list: PublicServer[] = [];
let failed = '';
try { try {
servers = (await native?.publicServers(store.settings.publicListUrl)) ?? []; list = (await native?.publicServers(store.settings.publicListUrl)) ?? [];
if (!native) error = 'The public list needs the desktop app.'; if (!native) failed = 'The public list needs the desktop app.';
} catch (e) { } catch (e) {
error = (e as Error).message.replace(/^Error invoking remote method '[^']+': (Error: )?/, ''); failed = (e as Error).message.replace(/^Error invoking remote method '[^']+': (Error: )?/, '');
} }
if (round !== generation || !alive) return;
servers = list;
error = failed;
loading = false; loading = false;
pingVisible(); pingVisible();
} }
+15 -6
View File
@@ -1,8 +1,10 @@
<script lang="ts"> <script lang="ts">
// Drag handle on one edge of a panel. `edge` is the side the handle sits on: a panel on // Drag handle on one edge of a panel. `edge` is the side the handle sits on: a panel on
// the right of the window is resized from its left edge and vice versa. Width is remembered. // the right of the window is resized from its left edge and vice versa; a panel at the
// bottom from its top edge and one at the top from its bottom edge (then `width` is its
// height). The size is remembered.
let { width = $bindable(), edge, min, max, initial, storageKey, label }: let { width = $bindable(), edge, min, max, initial, storageKey, label }:
{ width: number; edge: 'left' | 'right'; min: number; max: () => number; initial: number; storageKey: string; label: string } = $props(); { width: number; edge: 'left' | 'right' | 'top' | 'bottom'; min: number; max: () => number; initial: number; storageKey: string; label: string } = $props();
let resizing = $state(false); let resizing = $state(false);
@@ -18,7 +20,7 @@
resizing = true; resizing = true;
document.body.style.userSelect = 'none'; document.body.style.userSelect = 'none';
const box = (handle.parentElement as HTMLElement).getBoundingClientRect(); const box = (handle.parentElement as HTMLElement).getBoundingClientRect();
const move = (ev: PointerEvent) => setWidth(edge === 'left' ? box.right - ev.clientX : ev.clientX - box.left); const move = (ev: PointerEvent) => setWidth(edge === 'left' ? box.right - ev.clientX : edge === 'top' ? box.bottom - ev.clientY : edge === 'bottom' ? ev.clientY - box.top : ev.clientX - box.left);
const up = () => { const up = () => {
resizing = false; resizing = false;
document.body.style.userSelect = ''; document.body.style.userSelect = '';
@@ -32,8 +34,8 @@
} }
function onkeydown(e: KeyboardEvent) { function onkeydown(e: KeyboardEvent) {
const grow = edge === 'left' ? 'ArrowLeft' : 'ArrowRight'; const grow = edge === 'left' ? 'ArrowLeft' : edge === 'top' ? 'ArrowUp' : edge === 'bottom' ? 'ArrowDown' : 'ArrowRight';
const shrink = edge === 'left' ? 'ArrowRight' : 'ArrowLeft'; const shrink = edge === 'left' ? 'ArrowRight' : edge === 'top' ? 'ArrowDown' : edge === 'bottom' ? 'ArrowUp' : 'ArrowLeft';
if (e.key === grow) setWidth(width + 16); if (e.key === grow) setWidth(width + 16);
else if (e.key === shrink) setWidth(width - 16); else if (e.key === shrink) setWidth(width - 16);
else return; else return;
@@ -45,7 +47,7 @@
<!-- Focusable separator is the ARIA window-splitter pattern --> <!-- Focusable separator is the ARIA window-splitter pattern -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions --> <!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
<div class="resize {edge}" class:active={resizing} role="separator" aria-orientation="vertical" aria-label={label} <div class="resize {edge}" class:active={resizing} role="separator" aria-orientation={edge === 'top' || edge === 'bottom' ? 'horizontal' : 'vertical'} aria-label={label}
aria-valuenow={width} aria-valuemin={min} aria-valuemax={max()} tabindex="0" title="Drag to resize, double-click to reset" aria-valuenow={width} aria-valuemin={min} aria-valuemax={max()} tabindex="0" title="Drag to resize, double-click to reset"
onpointerdown={start} ondblclick={() => setWidth(initial)} {onkeydown}></div> onpointerdown={start} ondblclick={() => setWidth(initial)} {onkeydown}></div>
@@ -53,6 +55,13 @@
.resize { position: absolute; z-index: 5; top: 0; bottom: 0; width: 8px; cursor: col-resize; touch-action: none; } .resize { position: absolute; z-index: 5; top: 0; bottom: 0; width: 8px; cursor: col-resize; touch-action: none; }
.resize.left { left: -4px; } .resize.left { left: -4px; }
.resize.right { right: -4px; } .resize.right { right: -4px; }
.resize.top, .resize.bottom { left: 0; right: 0; width: auto; height: 8px; cursor: row-resize; }
.resize.top { top: -4px; bottom: auto; }
.resize.bottom { bottom: -4px; top: auto; }
.resize.top:hover, .resize.top:focus-visible, .resize.top.active,
.resize.bottom:hover, .resize.bottom:focus-visible, .resize.bottom.active {
background: linear-gradient(to bottom, transparent 3px, var(--accent) 3px, var(--accent) 5px, transparent 5px);
}
.resize:hover, .resize:focus-visible, .resize.active { .resize:hover, .resize:focus-visible, .resize.active {
background: linear-gradient(to right, transparent 3px, var(--accent) 3px, var(--accent) 5px, transparent 5px); background: linear-gradient(to right, transparent 3px, var(--accent) 3px, var(--accent) 5px, transparent 5px);
outline: none; outline: none;
+7 -3
View File
@@ -11,7 +11,10 @@
import { voice } from '../lib/audio/voice.svelte.ts'; import { voice } from '../lib/audio/voice.svelte.ts';
// resizable: false in the phone drawer, which keeps a fixed width // resizable: false in the phone drawer, which keeps a fixed width
let { onnavigate, resizable = true }: { onnavigate: () => void; resizable?: boolean } = $props(); // handleEdge: which side the resize handle sits on (left when the list is on the right, classic layout)
// fill: take the whole area (stacked layout, where the parent sizes it)
let { onnavigate, resizable = true, handleEdge = 'right', fill = false }:
{ onnavigate: () => void; resizable?: boolean; handleEdge?: 'left' | 'right'; fill?: boolean } = $props();
const MIN_W = 200, DEFAULT_W = 260; const MIN_W = 200, DEFAULT_W = 260;
const maxW = () => Math.max(MIN_W, Math.min(520, window.innerWidth - 520)); const maxW = () => Math.max(MIN_W, Math.min(520, window.innerWidth - 520));
@@ -58,8 +61,8 @@
); );
</script> </script>
<aside class="sidebar" style:width={resizable ? `${width}px` : undefined}> <aside class="sidebar" class:fill style:width={resizable && !fill ? `${width}px` : undefined}>
{#if resizable}<ResizeHandle bind:width edge="right" min={MIN_W} max={maxW} initial={DEFAULT_W} storageKey="mumh5.sidebarWidth" label="Resize channel list" />{/if} {#if resizable && !fill}<ResizeHandle bind:width edge={handleEdge} min={MIN_W} max={maxW} initial={DEFAULT_W} storageKey="mumh5.sidebarWidth" label="Resize channel list" />{/if}
<header class:trusted> <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') : ''}> <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> <strong>{#if cert}<Icon name={trusted ? 'lock' : 'shield'} size={14} />{/if}{session.server?.label ?? 'mumh5'}</strong>
@@ -134,6 +137,7 @@
<style> <style>
.sidebar { position: relative; width: 260px; flex: none; display: flex; flex-direction: column; background: var(--bg-1); min-height: 0; } .sidebar { position: relative; width: 260px; flex: none; display: flex; flex-direction: column; background: var(--bg-1); min-height: 0; }
.sidebar.fill { width: 100%; height: 100%; }
header { height: 52px; flex: none; display: flex; align-items: center; gap: 8px; padding: 0 8px 0 0; border-bottom: 1px solid var(--line); } 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 { background: #1d5c38; border-bottom-color: #2a7a4c; }
header.trusted .status, header.trusted .icon-btn { color: #cfeedd; } header.trusted .status, header.trusted .icon-btn { color: #cfeedd; }
+4 -3
View File
@@ -79,9 +79,10 @@
<dl> <dl>
<dt>Online for</dt><dd>{duration(stats.onlinesecs != null ? stats.onlinesecs + since : undefined)}</dd> <dt>Online for</dt><dd>{duration(stats.onlinesecs != null ? stats.onlinesecs + since : undefined)}</dd>
{#if stats.idlesecs != null}<dt>Idle for</dt><dd>{duration(stats.idlesecs + since)}</dd>{/if} {#if stats.idlesecs != null}<dt>Idle for</dt><dd>{duration(stats.idlesecs + since)}</dd>{/if}
<dt>Ping (TCP)</dt><dd>{ping(stats.tcp_ping_avg, stats.tcp_ping_var)}</dd> <!-- The server counts the client's ping messages, not voice; clients that never ping (many bots) show none -->
<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>Ping (TCP)</dt><dd>{stats.tcp_packets ? ping(stats.tcp_ping_avg, stats.tcp_ping_var) : 'n/a (this client sends no pings)'}</dd>
<dt>Packets</dt><dd>{stats.tcp_packets ?? 0} TCP, {stats.udp_packets ?? 0} UDP</dd> <dt>Ping (UDP)</dt><dd>{stats.udp_packets ? ping(stats.udp_ping_avg, stats.udp_ping_var) : 'n/a'}</dd>
<dt>Pings received</dt><dd>{stats.tcp_packets ?? 0} over TCP, {stats.udp_packets ?? 0} over UDP</dd>
{#if stats.bandwidth != null}<dt>Bandwidth</dt><dd>{formatBytes(stats.bandwidth)}/s</dd>{/if} {#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} {#if stats.address?.length}<dt>Address</dt><dd class="mono">{address(stats.address)}</dd>{/if}
</dl> </dl>
+9 -2
View File
@@ -1,7 +1,12 @@
<script lang="ts"> <script lang="ts">
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import { voice, type TransmitMode } from '../lib/audio/voice.svelte.ts'; import { voice, type TransmitMode } from '../lib/audio/voice.svelte.ts';
import { session } from '../lib/session.svelte.ts'; import { session, sessions } from '../lib/session.svelte.ts';
function setForceTcp(on: boolean) {
voice.update({ forceTcp: on });
for (const s of sessions.list) if (s.client) s.client.forceTcp = on;
}
const s = voice.settings; const s = voice.settings;
let capturing = $state(false); let capturing = $state(false);
@@ -133,6 +138,7 @@
</select> </select>
</div> </div>
</div> </div>
<label class="check"><input type="checkbox" checked={s.forceTcp} onchange={e => setForceTcp(e.currentTarget.checked)} /> Send voice over TCP only (for networks that block or mangle UDP)</label>
<label for="v-jit">Jitter buffer: {s.jitterMs} ms</label> <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} <input id="v-jit" type="range" min="20" max="300" step="10" value={s.jitterMs}
oninput={e => voice.update({ jitterMs: Number(e.currentTarget.value) })} /> oninput={e => voice.update({ jitterMs: Number(e.currentTarget.value) })} />
@@ -140,7 +146,8 @@
{#if session.status === 'connected'} {#if session.status === 'connected'}
<h3>Statistics</h3> <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> {@const t = session.voiceTransport}
<p class="help stats">Sent {voice.stats.sent} packets, received {voice.stats.received}, decoded {voice.stats.decoded}, gaps {voice.stats.lost}. Transport: {t.udp ? `UDP, ${Math.round(t.rtt)} ms ping` : 'TCP tunnel'}.</p>
{/if} {/if}
</div> </div>
+44
View File
@@ -85,6 +85,13 @@ try {
await page.getByRole('dialog', { name: 'Set up your identity' }).waitFor({ state: 'detached' }); await page.getByRole('dialog', { name: 'Set up your identity' }).waitFor({ state: 'detached' });
console.log('ok: identity wizard creates an identity'); console.log('ok: identity wizard creates an identity');
// Welcome page: no message box while offline, identities one click away
assert.equal(await page.locator('.chat .composer').count(), 0);
await page.getByRole('button', { name: 'Manage identities' }).click();
await page.getByRole('tab', { name: 'Identities', selected: true }).waitFor();
await page.getByRole('button', { name: 'Done', exact: true }).click();
console.log('ok: welcome page without composer, manage identities');
// Settings: upload host + key // Settings: upload host + key
if (process.env.UPLOAD_HOST && process.env.UPLOAD_KEY) { if (process.env.UPLOAD_HOST && process.env.UPLOAD_KEY) {
await page.getByTitle('Settings').click(); await page.getByTitle('Settings').click();
@@ -180,6 +187,13 @@ try {
const statsText = await page.locator('.stats').textContent(); const statsText = await page.locator('.stats').textContent();
const decoded = Number(/decoded (\d+)/.exec(statsText ?? '')?.[1]); const decoded = Number(/decoded (\d+)/.exec(statsText ?? '')?.[1]);
assert.ok(decoded >= 20, `decoded frames reported: ${statsText}`); assert.ok(decoded >= 20, `decoded frames reported: ${statsText}`);
// Voice runs over encrypted UDP once the server answers UDP pings
await page.locator('.stats', { hasText: /Transport: UDP, \d+ ms ping/ }).waitFor({ timeout: 10000 });
await page.getByLabel(/Send voice over TCP only/).check();
await page.locator('.stats', { hasText: 'Transport: TCP tunnel' }).waitFor();
await page.getByLabel(/Send voice over TCP only/).uncheck();
await page.locator('.stats', { hasText: /Transport: UDP/ }).waitFor();
console.log('ok: voice transport UDP, with a TCP-only switch');
await page.getByRole('button', { name: 'Done', exact: true }).click(); await page.getByRole('button', { name: 'Done', exact: true }).click();
console.log(`ok: ${decoded} frames decoded`); console.log(`ok: ${decoded} frames decoded`);
await setMode('Push to talk'); await setMode('Push to talk');
@@ -300,6 +314,36 @@ try {
await page.getByRole('button', { name: 'Done', exact: true }).click(); await page.getByRole('button', { name: 'Done', exact: true }).click();
console.log('ok: color schemes'); console.log('ok: color schemes');
// Layouts like the desktop client: classic (channels right of chat), stacked (channels above)
const box = (sel: string) => page.locator(sel).first().boundingBox().then(b => b!);
await page.getByTitle('Settings').click();
await page.getByRole('tab', { name: 'Appearance' }).click();
await page.getByRole('radio', { name: 'Classic' }).click();
await page.getByRole('button', { name: 'Done', exact: true }).click();
await page.locator('.app.classic').waitFor();
assert.ok((await box('.sidebar')).x > (await box('.chat')).x, 'classic: channel list right of chat');
await page.getByTitle('Settings').click();
await page.getByRole('tab', { name: 'Appearance' }).click();
await page.getByRole('radio', { name: 'Stacked' }).click();
await page.getByRole('button', { name: 'Done', exact: true }).click();
await page.locator('.app.stacked').waitFor();
const chatBox = await box('.chat'), treeBox = await box('.sidebar');
assert.ok(chatBox.y >= treeBox.y + treeBox.height - 1, 'stacked: chat below channel list');
// The split between chat and channel list drags
const handle = page.getByRole('separator', { name: 'Resize channel list' });
const hb = (await handle.boundingBox())!;
await page.mouse.move(hb.x + hb.width / 2, hb.y + hb.height / 2);
await page.mouse.down();
await page.mouse.move(hb.x + hb.width / 2, hb.y + 60, { steps: 4 });
await page.mouse.up();
assert.ok((await box('.sidebar')).height > treeBox.height + 40, 'stacked: split resizes');
await page.getByTitle('Settings').click();
await page.getByRole('tab', { name: 'Appearance' }).click();
await page.getByRole('radio', { name: 'Modern' }).click();
await page.getByRole('button', { name: 'Done', exact: true }).click();
await page.locator('.app.side').waitFor();
console.log('ok: classic and stacked layouts');
// Public server list, from a local list in the official XML format // Public server list, from a local list in the official XML format
const http = await import('node:http'); const http = await import('node:http');
const listServer = http.createServer((_req, res) => { const listServer = http.createServer((_req, res) => {
+68
View File
@@ -0,0 +1,68 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { randomBytes } from 'node:crypto';
import { ocbEncrypt, ocbDecrypt, CryptState } from '../electron/ocb2.ts';
const key = Buffer.from([...Array(16).keys()]);
test('OCB2 matches the test vectors used by Mumble (draft-krovetz-ocb-00)', () => {
const [, blankTag] = ocbEncrypt(key, Buffer.alloc(0), key);
assert.equal(blankTag.toString('hex'), 'bf3108130773ad5ec70ec69e7875a7b0');
const source = Buffer.from([...Array(40).keys()]);
const [crypt, tag] = ocbEncrypt(key, source, key);
assert.equal(tag.toString('hex'), '9db0cdf880f73e3e10d4eb3217766688');
assert.equal(crypt.toString('hex'), 'f75d6bc8b4dc8d66b836a2b08b32a6369f1cd3c5228d79fd6c267f5f6aa7b231c7dfb9d59951ae9c');
});
test('encrypt and decrypt round trip for every length up to 127 bytes', () => {
const nonce = Buffer.from('ffeeddccbbaa99887766554433221100', 'hex');
for (let len = 0; len < 128; len++) {
const plain = randomBytes(len);
const [ct, tag] = ocbEncrypt(key, plain, nonce);
const [back, tag2, ok] = ocbDecrypt(key, ct, nonce);
assert.ok(ok);
assert.ok(back.equals(plain), `len ${len}`);
assert.ok(tag.equals(tag2));
}
});
function pair() {
const k = randomBytes(16), cn = randomBytes(16), sn = randomBytes(16);
const client = new CryptState(), server = new CryptState();
client.setKey(k, cn, sn);
server.setKey(k, sn, cn); // the server encrypts with the client's decrypt nonce and vice versa
return { client, server };
}
test('crypt state: in order, lost, late, replay and tampering', () => {
const { client, server } = pair();
const packets = Array.from({ length: 300 }, (_, i) => client.encrypt(Buffer.from(`voice ${i}`)));
// In order (crosses the 256 wrap of the IV byte)
for (let i = 0; i < 280; i++) assert.equal(server.decrypt(packets[i])?.toString(), `voice ${i}`);
// 280-282 missing, then 281 arrives late (lost goes back down, like Mumble)
assert.equal(server.decrypt(packets[283])?.toString(), 'voice 283');
assert.equal(server.lost, 3);
assert.equal(server.decrypt(packets[281])?.toString(), 'voice 281');
assert.equal(server.late, 1);
assert.equal(server.lost, 2);
// Replays are rejected
assert.equal(server.decrypt(packets[281]), null);
assert.equal(server.decrypt(packets[283]), null);
// Tampered packets are rejected and do not disturb the state
const bad = Buffer.from(packets[284]); bad[6] ^= 1;
assert.equal(server.decrypt(bad), null);
assert.equal(server.decrypt(packets[284])?.toString(), 'voice 284');
assert.equal(server.good, 283);
});
test('XEX* attack counter-measure: the pattern is changed on encrypt and refused on decrypt', () => {
const nonce = randomBytes(16);
const attack = Buffer.alloc(32);
attack[15] = 16 * 8; // second to last block all zero except the last byte
const [, , okStrict] = ocbEncrypt(key, attack, nonce, false);
assert.equal(okStrict, false, 'pattern detected');
const [ct] = ocbEncrypt(key, attack, nonce);
const [plain, , ok] = ocbDecrypt(key, ct, nonce);
assert.ok(ok);
assert.notDeepEqual([...plain], [...attack], 'one bit was flipped to defuse the pattern');
});
+51
View File
@@ -8,10 +8,34 @@ import type { Transport } from '../src/core/transport.ts';
import { openTls } from '../electron/tls-transport.ts'; import { openTls } from '../electron/tls-transport.ts';
import { generateIdentity } from '../electron/identity.ts'; import { generateIdentity } from '../electron/identity.ts';
import { encodeVoice, decodeVoice } from '../src/core/voice-packet.ts'; import { encodeVoice, decodeVoice } from '../src/core/voice-packet.ts';
import { udpChannel } from '../electron/udp-voice.ts';
const target = process.env.MUMBLE_TEST_HOST; const target = process.env.MUMBLE_TEST_HOST;
const codec = createCodec(); const codec = createCodec();
function connectUdp(name: string): Promise<MumbleClient> {
const [host, port] = target!.split(':');
const id = generateIdentity(name);
const client = new MumbleClient(codec);
let udp: ReturnType<typeof udpChannel> | undefined;
let conn: ReturnType<typeof openTls> | undefined;
const transport: Transport = { onData: null, onClose: null, send: b => conn?.send(b), close: () => { udp?.close(); conn?.close(); } };
return new Promise((resolve, reject) => {
conn = openTls(host, Number(port || 64738), id.certPem, id.keyPem, {
// UDP goes to the address the TLS connection actually reached
onSecure: info => {
udp = udpChannel(info.address, info.port);
transport.udp = udp;
client.connect(transport, { username: name, os: 'test' });
},
onData: c => transport.onData?.(c),
onClose: r => { udp?.close(); transport.onClose?.(r); }
});
client.on('synced', () => resolve(client));
client.on('close', reject);
});
}
function connect(name: string, password = ''): Promise<MumbleClient> { function connect(name: string, password = ''): Promise<MumbleClient> {
const [host, port] = target!.split(':'); const [host, port] = target!.split(':');
const id = generateIdentity(name); const id = generateIdentity(name);
@@ -232,3 +256,30 @@ test('channel management and ACLs as SuperUser', { skip: !target || !superPw },
} }
}); });
test('voice over encrypted UDP (OCB2) between two clients', { skip: !target }, async () => {
const alice = await connectUdp('udpA' + Date.now() % 1000);
const bob = await connectUdp('udpB' + Date.now() % 1000);
try {
// Both sides confirm UDP with encrypted pings
await until<void>(res => { if (alice.udpOk) res(); else alice.on('udp', ok => { if (ok) res(); }); }, 8000);
await until<void>(res => { if (bob.udpOk) res(); else bob.on('udp', ok => { if (ok) res(); }); }, 8000);
assert.ok(alice.udpRtt >= 0);
// Alice's voice goes out over UDP and reaches bob over UDP, not the TCP tunnel
let tunneled = 0;
const origSend = (alice as any).sendVoiceTunnel.bind(alice);
(alice as any).sendVoiceTunnel = (p: Uint8Array) => { tunneled++; origSend(p); };
const got = until<number>(res => {
let n = 0;
bob.on('voice', raw => { const p = decodeVoice(raw); if (p?.session === alice.session && ++n === 10) res(n); });
});
const opus = new Uint8Array([0x78, ...new Array(40).fill(9)]);
for (let i = 0; i < 10; i++) alice.sendVoice(encodeVoice({ target: 0, frame: i * 2, opus, last: i === 9 }, alice.protobufVoice));
assert.equal(await got, 10);
assert.equal(tunneled, 0, 'nothing went through the TCP tunnel');
} finally {
alice.disconnect();
bob.disconnect();
}
});