Initial commit: mumh5, a modern Mumble client

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 23:28:43 +02:00
co-authored by Claude Opus 5.5
commit 6d29ee1485
85 changed files with 22997 additions and 0 deletions
+70
View File
@@ -0,0 +1,70 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { generateIdentity, identityFromP12, identityToP12 } from '../electron/identity.ts';
import { IdentityStore } from '../electron/identity-store.ts';
const tmp = () => mkdtempSync(path.join(tmpdir(), 'mumh5-id-'));
test('PKCS#12 export and import round trip', () => {
const id = generateIdentity('roundtrip', 'rt@example.com');
const p12 = identityToP12(id, 'hunter22', 'roundtrip');
const back = identityFromP12(p12, 'hunter22');
assert.equal(back.fingerprint, id.fingerprint);
assert.throws(() => identityFromP12(p12, 'wrong'), /Wrong password/);
});
test('store migrates the legacy identity and still asks for setup', async () => {
const dir = tmp();
const legacy = generateIdentity('legacy user');
writeFileSync(path.join(dir, 'identity.json'), JSON.stringify(legacy));
const store = new IdentityStore(dir);
const { identities, setupDone } = await store.list();
assert.equal(setupDone, false);
assert.equal(identities.length, 1);
assert.equal(identities[0].fingerprint, legacy.fingerprint);
assert.equal(identities[0].name, 'legacy user');
assert.ok(identities[0].isDefault);
assert.equal(statSync(path.join(dir, 'identities.json')).mode & 0o077, 0, 'identities file is owner-only');
await store.finishSetup();
assert.equal((await new IdentityStore(dir).list()).setupDone, true);
});
test('store create, import, duplicates, default and remove', async () => {
const store = new IdentityStore(tmp());
const a = await store.create('alice', '');
const other = generateIdentity('bob');
const b = await store.importP12(identityToP12(other, 'pw', 'bob'), 'pw', '');
assert.equal(b.name, 'bob');
await assert.rejects(store.importP12(identityToP12(other, 'x', 'bob'), 'x', ''), /already stored/);
assert.equal((await store.get()).id, a.id, 'first identity is the default');
assert.equal((await store.get(b.id)).fingerprint, other.fingerprint);
await store.setDefault(b.id);
await store.remove(b.id);
const { identities } = await store.list();
assert.deepEqual(identities.map(i => [i.name, i.isDefault]), [['alice', true]]);
});
test('imports the desktop Mumble client certificate', async () => {
const cfg = tmp();
const mumble = generateIdentity('Mumble User');
mkdirSync(path.join(cfg, 'Mumble', 'Mumble'), { recursive: true });
const b64 = Buffer.from(identityToP12(mumble, '', 'Mumble User')).toString('base64');
writeFileSync(path.join(cfg, 'Mumble', 'Mumble', 'mumble_settings.json'), JSON.stringify({ certificate: b64 }));
const prev = { xdg: process.env.XDG_CONFIG_HOME, appdata: process.env.APPDATA, home: process.env.HOME };
process.env.XDG_CONFIG_HOME = cfg;
process.env.APPDATA = path.join(cfg, 'none');
process.env.HOME = path.join(cfg, 'none');
try {
const store = new IdentityStore(tmp());
assert.deepEqual(await store.findMumbleCertificate().then(f => f?.name), 'Mumble User');
const imported = await store.importFromMumble('');
assert.equal(imported.fingerprint, mumble.fingerprint);
} finally {
for (const [k, v] of Object.entries({ XDG_CONFIG_HOME: prev.xdg, APPDATA: prev.appdata, HOME: prev.home })) {
if (v === undefined) delete process.env[k]; else process.env[k] = v;
}
}
});