Add the browser version, a toolbar, description previews and hints

Browser version
- Self-hosted proxy (server/) that serves the web build and bridges WebSocket
  connections to Mumble servers over TLS, with a server allowlist or allow-any mode
- WebSocket transport and a browser platform layer; identities live in the browser
- npm run build:web, dev:web, proxy and test:e2e:web; proxy unit tests

Interface
- Toolbar next to mute and deafen: description editor, settings, expand or collapse all
- Channels with a description show a marker; resting on the row previews it
- Collapse all keeps channels with people open
- Hints (title tooltips) can be switched on in a new Accessibility settings tab
- New identities can set the username suggested when connecting
- Own user information tells the client address from the one the server sees

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 20:47:10 +02:00
co-authored by Claude Opus 5.5
parent 74aaf635be
commit 82972438fe
50 changed files with 1485 additions and 80 deletions
+3
View File
@@ -51,4 +51,7 @@ export interface ServerCertInfo {
authorized: boolean; // true if it chains to a trusted CA
authError: string | null; // why it is not trusted, e.g. DEPTH_ZERO_SELF_SIGNED_CERT
chain: CertDetails[]; // leaf first
// Browser build only: this client's address as the web proxy saw it. The Mumble server
// sees the proxy's address instead and cannot be told the original one.
clientAddress?: string;
}
+50
View File
@@ -0,0 +1,50 @@
import type { Transport, ServerCertInfo } from './transport.ts';
// Transport through the mumh5 web proxy: one WebSocket per Mumble connection. The proxy opens
// the TLS socket and presents the client certificate, since a browser can do neither.
// `secure` resolves once the proxy has finished the TLS handshake, before any Mumble data is
// sent, so the caller can check the server certificate before sending a password.
// There is no UDP here; voice goes through the TCP tunnel.
export class WebSocketTransport implements Transport {
onData: ((chunk: Uint8Array) => void) | null = null;
onClose: ((reason: string) => void) | null = null;
readonly secure: Promise<ServerCertInfo>;
private ws: WebSocket;
constructor(url: string, target: { host: string; port: number }, identity: { certPem: string; keyPem: string }) {
let resolveSecure!: (i: ServerCertInfo) => void;
let rejectSecure!: (e: Error) => void;
this.secure = new Promise((res, rej) => { resolveSecure = res; rejectSecure = rej; });
// Callers that never await `secure` still get onClose
this.secure.catch(() => {});
let reason = '';
let opened = false;
const ws = new WebSocket(url);
this.ws = ws;
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
opened = true;
ws.send(JSON.stringify({ host: target.host, port: target.port, certPem: identity.certPem, keyPem: identity.keyPem }));
};
ws.onmessage = e => {
if (typeof e.data !== 'string') return this.onData?.(new Uint8Array(e.data as ArrayBuffer));
let msg: any;
try { msg = JSON.parse(e.data); } catch { return; }
if (msg.type === 'secure') resolveSecure(msg.info);
else if (msg.type === 'close') reason = String(msg.reason ?? '');
};
ws.onclose = () => {
reason ||= opened ? 'Connection closed' : 'Could not reach the proxy';
rejectSecure(new Error(reason));
this.onClose?.(reason);
};
}
send(bytes: Uint8Array): void {
if (this.ws.readyState === WebSocket.OPEN) this.ws.send(bytes as Uint8Array<ArrayBuffer>);
}
close(): void {
this.ws.close();
}
}