diff --git a/CLAUDE.md b/CLAUDE.md index 7fe3aed..503fa0a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,7 @@ A reused test server keeps registrations and channels from earlier runs; tests m ## 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/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). diff --git a/README.md b/README.md index 534a86b..74651d5 100644 --- a/README.md +++ b/README.md @@ -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 | | 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" - 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 +- 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 - Right-click the mute or deafen button for quick device and volume options - Hear-yourself microphone test @@ -164,7 +165,6 @@ Without an upload host, mumh5 still sends images, scaled to fit the server's lim ## 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 diff --git a/electron/main.ts b/electron/main.ts index 363698b..3130eaf 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -8,6 +8,7 @@ import * as tray from './tray.ts'; import { fetchLinkPreview } from './link-preview.ts'; import { fetchPublicListWith, pingServer } from './publist.ts'; import { openTls } from './tls-transport.ts'; +import { udpChannel } from './udp-voice.ts'; 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 ───────────────────────── -type Conn = ReturnType & { owner: WebContents }; +type Conn = ReturnType & { owner: WebContents; udp?: ReturnType }; const conns = new Map(); // ─── Identities (client certificates) ───────────────────────────────────────── @@ -54,9 +55,19 @@ ipcMain.handle('mumble:open', async (e, connId: string, host: string, port: numb 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), + 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), - 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 })); 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); }); +// 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) => { const conn = conns.get(connId); if (conn && conn.owner === e.sender) conn.close(); diff --git a/electron/ocb2.ts b/electron/ocb2.ts new file mode 100644 index 0000000..cdaf365 --- /dev/null +++ b/electron/ocb2.ts @@ -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; + } +} diff --git a/electron/preload.ts b/electron/preload.ts index 831df59..704d66c 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -1,7 +1,10 @@ import { contextBridge, ipcRenderer } from 'electron'; type Listener = (connId: string, ...args: any[]) => void; -const listeners = { secure: new Set(), data: new Set(), close: new Set() }; +const listeners = { + secure: new Set(), data: new Set(), close: new Set(), + udpVoice: new Set(), udpState: new Set(), udpResync: new Set() +}; 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); @@ -34,6 +37,10 @@ contextBridge.exposeInMainWorld('mumh5Native', { 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), + 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) => { listeners[event].add(fn); return () => listeners[event].delete(fn); diff --git a/electron/tls-transport.ts b/electron/tls-transport.ts index e9eaf9e..d1c93ac 100644 --- a/electron/tls-transport.ts +++ b/electron/tls-transport.ts @@ -2,7 +2,7 @@ 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; + onSecure(info: { fingerprint: string; authorized: boolean; authError: string | null; chain: CertDetails[]; address: string; port: number }): void; onData(chunk: Uint8Array): void; onClose(reason: string): void; } @@ -35,6 +35,9 @@ export function openTls(host: string, port: number, cert: string, key: string, h } h.onSecure({ 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 ?? '', authorized: socket.authorized, authError: socket.authorizationError ? String(socket.authorizationError) : null diff --git a/electron/udp-voice.ts b/electron/udp-voice.ts new file mode 100644 index 0000000..3e609fb --- /dev/null +++ b/electron/udp-voice.ts @@ -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 | 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; +} + diff --git a/src/core/client.ts b/src/core/client.ts index 72243c4..81d3990 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -156,6 +156,7 @@ type ClientEvents = { reject: (type: number, reason: string) => void; voice: (packet: Uint8Array) => void; userStats: (stats: any) => void; + udp: (ok: boolean, rtt: number) => void; acl: (acl: ChannelAcl) => void; userNames: (names: Map) => void; permissions: (channelId: number | null, bits: number) => void; @@ -178,6 +179,11 @@ export class MumbleClient extends Emitter { serverVersion = ''; // Numeric server version (major << 16 | minor << 8 | patch), 0 until known 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; config: ServerConfig = { allowHtml: true, messageLength: 5000, imageMessageLength: 131072, maxUsers: 0, recordingAllowed: true }; @@ -203,6 +209,18 @@ export class MumbleClient extends Emitter { connect(transport: Transport, opts: ConnectOptions): void { 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 => { try { this.reader.push(chunk, (id, body) => this.handleFrame(id, body)); @@ -409,6 +427,12 @@ export class MumbleClient extends Emitter { }); } + // 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 sendVoiceTunnel(packet: Uint8Array): void { if (!this.transport || this.closed) return; @@ -529,6 +553,19 @@ export class MumbleClient extends Emitter { case 'UserStats': this.emit('userStats', msg); 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': { // proto2 defaults: missing booleans are true const t = (v: unknown) => v !== false; diff --git a/src/core/transport.ts b/src/core/transport.ts index d8bca9e..b0f8b22 100644 --- a/src/core/transport.ts +++ b/src/core/transport.ts @@ -4,6 +4,18 @@ export interface Transport { close(): void; onData: ((chunk: Uint8Array) => 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; + send(plain: Uint8Array): void; + onVoice: ((plain: Uint8Array) => void) | null; + onState: ((ok: boolean, rtt: number) => void) | null; + onResync: (() => void) | null; } export interface ConnectTarget { diff --git a/src/lib/audio/voice.svelte.ts b/src/lib/audio/voice.svelte.ts index 2b3a91d..b8548ec 100644 --- a/src/lib/audio/voice.svelte.ts +++ b/src/lib/audio/voice.svelte.ts @@ -21,6 +21,7 @@ export interface VoiceSettings { bitrate: number; // Opus bits per second frameMs: 10 | 20 | 40 | 60; 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) userVolumes: Record; localMutes: Record; @@ -41,6 +42,7 @@ const defaults: VoiceSettings = { bitrate: 40000, frameMs: 20, jitterMs: 60, + forceTcp: false, userVolumes: {}, localMutes: {} }; @@ -337,7 +339,7 @@ class VoiceEngine { 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)); + client.sendVoice(encodeVoice({ target: 0, frame: this.frameCounter, opus, last }, client.protobufVoice)); this.frameCounter += this.settings.frameMs / 10; this.stats.sent++; } diff --git a/src/lib/native.ts b/src/lib/native.ts index f2da03e..d0c1fd7 100644 --- a/src/lib/native.ts +++ b/src/lib/native.ts @@ -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 { platformInfo(): Promise<{ os: string; osVersion: string }>; @@ -25,7 +25,11 @@ interface NativeApi { open(connId: string, host: string, port: number, identityId?: string): Promise; send(connId: string, bytes: Uint8Array): 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; } // 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; onClose: ((reason: string) => void) | null = null; readonly secure: Promise; + udp: UdpChannel; private id = crypto.randomUUID(); private offs: (() => void)[] = []; private api: NativeApi; @@ -86,7 +91,19 @@ export class ElectronTransport implements Transport { let rejectSecure!: (e: Error) => void; this.secure = new Promise((res, rej) => { resolveSecure = res; rejectSecure = rej; }); 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( + 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('data', (cid, chunk) => { if (cid === id) this.onData?.(chunk); }), api.on('close', (cid, reason) => { diff --git a/src/lib/session.svelte.ts b/src/lib/session.svelte.ts index 1011b96..217248e 100644 --- a/src/lib/session.svelte.ts +++ b/src/lib/session.svelte.ts @@ -161,6 +161,14 @@ export class Session { 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 can(channelId: number, bit: number): boolean | null { void this.tick; @@ -236,6 +244,7 @@ export class Session { } const client = new MumbleClient(codec); + client.forceTcp = voice.settings.forceTcp; this.client = client; this.wire(client); // TLS is up but the server may still never finish the login @@ -309,6 +318,7 @@ export class Session { bump(); }); client.on('ping', rtt => { if (live()) this.rtt = rtt; }); + client.on('udp', bump); client.on('user', (user, changed, actor, isNew) => { if (!live()) return; diff --git a/src/ui/Composer.svelte b/src/ui/Composer.svelte index f160980..4905335 100644 --- a/src/ui/Composer.svelte +++ b/src/ui/Composer.svelte @@ -14,8 +14,6 @@ let picker: HTMLInputElement; let imagePicker: HTMLInputElement; let empty = $state(true); - let source = $state(false); - let sourceText = $state(''); // The formatting toolbar is opt-in and remembered let formatting = $state(loadFormatting()); const disabled = $derived(session.status !== 'connected'); @@ -27,7 +25,6 @@ function toggleFormatting() { formatting = !formatting; try { localStorage.setItem('mumh5.formatBar', formatting ? '1' : '0'); } catch { /* storage unavailable */ } - if (!formatting && source) toggleSource(); input.focus(); } @@ -45,16 +42,14 @@ } function submit() { - const html = source ? sourceText : input.innerHTML; + const html = input.innerHTML; // 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 ? session.sendHtml(toMumbleHtml(html, { linkify: true }), target) : session.sendText(input.innerText, target); if (!ok) return; input.innerHTML = ''; - sourceText = ''; - if (source) source = false; sync(); } @@ -97,17 +92,6 @@ }; } - function toggleSource() { - if (!source) { - sourceText = toMumbleHtml(input.innerHTML); - source = true; - } else { - input.innerHTML = toMumbleHtml(sourceText); - source = false; - sync(); - } - } - $effect(() => { void target; if (autofocus && !disabled && matchMedia('(pointer: fine)').matches) input.focus(); @@ -128,7 +112,7 @@ {/if}
{#if formatting} - imagePicker.click()} {source} ontogglesource={toggleSource} /> + imagePicker.click()} /> {/if}
@@ -137,33 +121,27 @@ onchange={() => { session.uploadFiles([...(picker.files ?? [])], target); picker.value = ''; }} /> { session.uploadFiles([...(imagePicker.files ?? [])], target); imagePicker.value = ''; }} /> -
- {#if source} - - {/if} - +