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
+144
View File
@@ -0,0 +1,144 @@
<script lang="ts">
import Icon from './Icon.svelte';
import Avatar from './Avatar.svelte';
import ChannelNode from './ChannelNode.svelte';
import { session, sessions } from '../lib/session.svelte.ts';
import { ui } from '../lib/ui.svelte.ts';
import { menu } from '../lib/menu.svelte.ts';
import { userMenu } from '../lib/actions.ts';
import { voice } from '../lib/audio/voice.svelte.ts';
let { onnavigate }: { onnavigate: () => void } = $props();
const roots = $derived(session.childrenOf(null));
const self = $derived(session.self);
const cert = $derived(session.serverCert);
const trusted = $derived(!!cert?.authorized);
function openQuick(e: MouseEvent, kind: 'input' | 'output') {
e.preventDefault();
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
ui.quickAudio = { kind, x: r.left + r.width / 2, y: r.top };
}
function showServerCert() {
if (!cert?.chain.length) return;
ui.cert = {
title: `Certificate of ${session.server?.host}`,
chain: cert.chain,
status: trusted
? { ok: true, text: 'Valid certificate, signed by a trusted authority.' }
: { ok: false, text: `Not signed by a trusted authority (${cert.authError ?? 'unknown reason'}). mumh5 pinned this certificate the first time you connected and warns you if it changes.` }
};
}
const dms = $derived(
Object.keys(session.logs).filter(k => k.startsWith('dm:')).map(k => ({ key: k as `dm:${number}`, user: session.user(Number(k.slice(3))) }))
);
</script>
<aside class="sidebar">
<header class:trusted>
<button class="title" onclick={showServerCert} disabled={!cert} title={cert ? (trusted ? 'Valid certificate. Click to view' : 'Self-signed or untrusted certificate. Click to view') : ''}>
<strong>{#if cert}<Icon name={trusted ? 'lock' : 'shield'} size={14} />{/if}{session.server?.label ?? 'mumh5'}</strong>
<span class="status">
{#if session.status === 'connected'}
<span class="dot"></span>Connected{#if session.rtt} , {session.rtt} ms{/if}
{:else if session.status === 'connecting'}Connecting...
{:else}Not connected{/if}
</span>
</button>
{#if session.status !== 'idle'}
<button class="icon-btn" title="Disconnect" onclick={() => session.server && sessions.close(session.server.id)}><Icon name="power" size={18} /></button>
{/if}
</header>
<div class="scroll">
{#if session.status === 'connected'}
<ul class="tree">
{#each roots as c (c.id)}<ChannelNode channel={c} {onnavigate} />{/each}
</ul>
{#if dms.length}
<h3>Direct messages</h3>
<ul class="tree">
{#each dms as dm (dm.key)}
<li>
<button class="dm" class:viewing={session.view === dm.key}
onclick={() => { session.selectView(dm.key); onnavigate(); }}>
<Avatar name={dm.user?.name ?? '?'} size={22} />
<span class="label" class:offline={!dm.user}>{dm.user?.name ?? 'Offline user'}</span>
{#if session.unread[dm.key]}<span class="badge">{session.unread[dm.key]}</span>{/if}
</button>
</li>
{/each}
</ul>
{/if}
{:else if session.error}
<p class="error">{session.error}</p>
{:else if session.status === 'idle'}
<p class="hint">Pick a server on the left, or add one with the plus button.</p>
{/if}
</div>
{#if self && sessions.voiceSession && sessions.voiceSession !== sessions.active}
<div class="elsewhere">
<span>Voice is on <strong>{sessions.voiceSession.server?.label}</strong>, deafened here</span>
<button class="btn" onclick={() => sessions.setVoice(sessions.active)}>Talk here</button>
</div>
{/if}
{#if self}
<footer class="me">
<button class="profile" title="Your profile" onclick={() => { session.showUser(self.session); ui.panelOpen = true; }}
oncontextmenu={e => menu.show(e, userMenu(self))}>
<Avatar name={self.name} size={32} speaking={voice.transmitting} />
<span class="who">
<strong>{self.name}</strong>
<span>{voice.error ? 'Voice unavailable' : voice.settings.mode === 'ptt' && !self.selfMute ? (voice.pttDown ? 'Talking' : 'Push to talk') : session.channel(self.channelId)?.name || 'Root'}</span>
</span>
</button>
<button class="icon-btn" class:on={self.selfMute} title={self.selfMute ? 'Unmute (right-click for microphone options)' : 'Mute (right-click for microphone options)'}
aria-label={self.selfMute ? 'Unmute' : 'Mute'}
onclick={() => session.setSelfMute(!self.selfMute)} oncontextmenu={e => openQuick(e, 'input')}>
<Icon name={self.selfMute ? 'mic-off' : 'mic'} size={18} />
</button>
<button class="icon-btn" class:on={self.selfDeaf} title={self.selfDeaf ? 'Undeafen (right-click for output options)' : 'Deafen (right-click for output options)'}
aria-label={self.selfDeaf ? 'Undeafen' : 'Deafen'}
onclick={() => session.setSelfDeaf(!self.selfDeaf)} oncontextmenu={e => openQuick(e, 'output')}>
<Icon name={self.selfDeaf ? 'headphones-off' : 'headphones'} size={18} />
</button>
</footer>
{/if}
</aside>
<style>
.sidebar { width: 260px; flex: none; display: flex; flex-direction: column; background: var(--bg-1); min-height: 0; }
header { height: 52px; flex: none; display: flex; align-items: center; gap: 8px; padding: 0 8px 0 0; border-bottom: 1px solid var(--line); }
header.trusted { background: #1d5c38; border-bottom-color: #2a7a4c; }
header.trusted .status, header.trusted .icon-btn { color: #cfeedd; }
header.trusted .dot { background: #fff; }
.title { flex: 1; min-width: 0; align-self: stretch; display: flex; flex-direction: column; justify-content: center; padding-left: 14px; text-align: left; }
.title:disabled { opacity: 1; }
.title:hover:not(:disabled) { background: rgb(255 255 255 / 0.05); }
.title strong { display: flex; align-items: center; gap: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.status { font-size: 12px; color: var(--text-dim); display: flex; align-items: center; gap: 6px; }
.dot { width: 8px; height: 8px; background: var(--speaking); }
.scroll { flex: 1; overflow-y: auto; padding: 8px 6px; }
ul { list-style: none; margin: 0; padding: 0; }
h3 { font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-faint); margin: 18px 8px 6px; }
.dm { display: flex; align-items: center; gap: 8px; width: 100%; padding: 4px 8px; color: var(--text-dim); text-align: left; }
.dm:hover { background: var(--bg-hover); color: var(--text); }
.dm.viewing { background: var(--bg-active); color: var(--text); }
.label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.offline { color: var(--text-faint); font-style: italic; }
.badge { background: var(--danger); color: #fff; font-size: 11px; font-weight: 700; padding: 0 5px; line-height: 16px; }
.hint, .error { padding: 8px; color: var(--text-dim); font-size: 14px; }
.error { color: var(--danger); }
.elsewhere { flex: none; display: flex; align-items: center; gap: 8px; padding: 8px 10px; font-size: 12px; color: var(--text-dim); background: var(--bg-0); border-top: 1px solid var(--line); }
.elsewhere span { flex: 1; min-width: 0; }
.elsewhere .btn { padding: 4px 10px; font-size: 12px; }
.me { flex: none; display: flex; align-items: center; gap: 8px; padding: 8px; background: var(--bg-0); }
.profile { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; padding: 2px; text-align: left; }
.profile:hover { background: var(--bg-hover); }
.who { flex: 1; min-width: 0; display: flex; flex-direction: column; line-height: 1.2; }
.who strong, .who span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.who span { font-size: 12px; color: var(--text-dim); }
</style>