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
+80
View File
@@ -0,0 +1,80 @@
import { native } from './native.ts';
import { ICON_PATHS } from './icons.ts';
import { session, sessions } from './session.svelte.ts';
import { voice, type TransmitMode } from './audio/voice.svelte.ts';
import { ui } from './ui.svelte.ts';
type TrayLook = 'offline' | 'idle' | 'talking' | 'muted' | 'deafened';
const LOOK: Record<TrayLook, { bg: string; glyph: string }> = {
offline: { bg: '#4a4c54', glyph: 'mic' },
idle: { bg: '#7c6cff', glyph: 'mic' },
talking: { bg: '#2fb863', glyph: 'mic' },
muted: { bg: '#e0443e', glyph: 'mic-off' },
deafened: { bg: '#e0443e', glyph: 'headphones-off' }
};
const cache = new Map<TrayLook, string>();
// Square tile with a white glyph, like the rail tiles; 64 px so the OS can scale it down sharply
function drawIcon(look: TrayLook): string {
const hit = cache.get(look);
if (hit) return hit;
const size = 64;
const canvas = document.createElement('canvas');
canvas.width = canvas.height = size;
const ctx = canvas.getContext('2d')!;
ctx.fillStyle = LOOK[look].bg;
ctx.fillRect(0, 0, size, size);
ctx.translate(8, 8);
ctx.scale(2, 2); // 24 unit glyph into 48 px
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2.6;
ctx.lineCap = 'square';
ctx.lineJoin = 'miter';
ctx.stroke(new Path2D(ICON_PATHS[LOOK[look].glyph]));
const url = canvas.toDataURL('image/png');
cache.set(look, url);
return url;
}
// Mirrors voice state into the system tray and handles its menu
export function startTray(): void {
if (!native) return;
const api = native;
$effect.root(() => {
$effect(() => {
const self = session.self;
const voiceSession = sessions.voiceSession;
const connected = session.status === 'connected';
const vSelf = voiceSession?.self;
const muted = !!(vSelf ? vSelf.selfMute || vSelf.mute : self?.selfMute);
const deafened = !!(vSelf ? vSelf.selfDeaf || vSelf.deaf : self?.selfDeaf);
const look: TrayLook = !connected && !voiceSession ? 'offline'
: deafened ? 'deafened' : muted ? 'muted' : voice.transmitting ? 'talking' : 'idle';
const server = voiceSession?.server?.label ?? session.server?.label ?? '';
const state = { offline: 'not connected', idle: 'connected', talking: 'talking', muted: 'muted', deafened: 'deafened' }[look];
api.updateTray({
icon: drawIcon(look),
tooltip: server ? `mumh5: ${state} on ${server}` : 'mumh5: not connected',
connected: !!(voiceSession?.status === 'connected' || connected),
server,
muted,
deafened,
mode: voice.settings.mode
});
});
});
api.onTrayAction(action => {
// Tray actions apply where voice runs
const target = sessions.voiceSession ?? sessions.active;
const self = target.self;
if (action === 'toggle-mute' && self) target.setSelfMute(!self.selfMute);
else if (action === 'toggle-deafen' && self) target.setSelfDeaf(!self.selfDeaf);
else if (action.startsWith('mode:')) voice.update({ mode: action.slice(5) as TransmitMode });
else if (action === 'settings') { ui.settingsTab = 'voice'; ui.settingsOpen = true; }
else if (action === 'disconnect' && target.server) sessions.close(target.server.id);
});
}