Add channel and permission management, sounds, themes and the public server list

- Channels: create, edit, delete, link and move (drag and drop for channels and
  people), right-click on free space in the channel list
- Permission editor with inherited rules, allow/deny per permission, groups and
  members; menus grey out actions the server does not allow
- Notification sounds for connection, channel, chat, mute and push-to-talk events;
  custom files and TeamSpeak 3 style sound packs (settings.ini), stored in IndexedDB
- Color schemes: Dark, Light, Midnight, Frost and Windows 95
- Public server list with live UDP ping, fetched through Chromium's network stack
- Kick, ban and connection-lost card with Reconnect
- Right-click menus for text fields, links, images and message authors
- Formatting toolbar in the message box, shared with the description editor
- Resizable channel list, menu headers, registration from the self menu only
- Fix: client-side pacing now mirrors Murmur's whole-second leaky bucket exactly,
  so quick actions are no longer dropped silently

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 00:54:03 +02:00
co-authored by Claude Opus 5.5
parent aa679dddcc
commit 9a3aeb29ad
53 changed files with 2684 additions and 170 deletions
+31 -1
View File
@@ -1,4 +1,4 @@
import { app, BrowserWindow, dialog, ipcMain, shell, session, systemPreferences, type WebContents } from 'electron';
import { app, BrowserWindow, dialog, ipcMain, net, shell, session, systemPreferences, type WebContents } from 'electron';
import { promises as fs } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
@@ -6,6 +6,7 @@ import { IdentityStore } from './identity-store.ts';
import { describeCert } from './certs.ts';
import * as tray from './tray.ts';
import { fetchLinkPreview } from './link-preview.ts';
import { fetchPublicListWith, pingServer } from './publist.ts';
import { openTls } from './tls-transport.ts';
const devUrl = process.env.VITE_DEV_SERVER_URL;
@@ -75,6 +76,26 @@ ipcMain.on('mumble:close', (e, connId: string) => {
ipcMain.handle('certs:describe', (_e, ders: Uint8Array[]) =>
(Array.isArray(ders) ? ders.slice(0, 8) : []).flatMap(d => { try { return [describeCert(Buffer.from(d))]; } catch { return []; } }));
// Edit commands for the renderer's context menu (clipboard access goes through the browser engine)
ipcMain.on('edit:action', (e, action: string, arg?: unknown) => {
const wc = e.sender;
switch (action) {
case 'undo': return wc.undo();
case 'redo': return wc.redo();
case 'cut': return wc.cut();
case 'copy': return wc.copy();
case 'paste': return wc.paste();
case 'selectAll': return wc.selectAll();
case 'replaceMisspelling': return wc.replaceMisspelling(String(arg));
case 'addWord': return wc.session.addWordToSpellCheckerDictionary(String(arg));
case 'copyImageAt': { const p = arg as { x: number; y: number }; return wc.copyImageAt(p.x, p.y); }
case 'saveImage': return wc.downloadURL(String(arg));
}
});
ipcMain.handle('publist:fetch', (_e, url: string) => fetchPublicListWith(u => net.fetch(u), String(url)));
ipcMain.handle('publist:ping', (_e, host: string, port: number) => pingServer(String(host), Number(port)));
ipcMain.handle('preview:fetch', (_e, url: string) => fetchLinkPreview(String(url).slice(0, 2048)));
ipcMain.on('tray:update', (_e, state: tray.TrayState) => tray.update(state));
@@ -124,6 +145,15 @@ function createWindow() {
win.hide();
}
});
// No default menu in Electron: report what was right-clicked, the renderer shows its own menu
// (custom menus call preventDefault, which suppresses this event)
win.webContents.on('context-menu', (_e, p) => {
win.webContents.send('context-menu', {
x: p.x, y: p.y, isEditable: p.isEditable, selectionText: p.selectionText, linkURL: p.linkURL,
mediaType: p.mediaType, srcURL: p.srcURL, misspelledWord: p.misspelledWord,
suggestions: p.dictionarySuggestions.slice(0, 5), editFlags: p.editFlags
});
});
win.on('show', () => tray.onWindowVisibility());
win.on('hide', () => tray.onWindowVisibility());
win.on('closed', () => { if (mainWindow === win) mainWindow = null; });
+4
View File
@@ -13,6 +13,10 @@ contextBridge.exposeInMainWorld('mumh5Native', {
platformInfo: () => ipcRenderer.invoke('platform:info'),
updateTray: (state: unknown) => ipcRenderer.send('tray:update', state),
linkPreview: (url: string) => ipcRenderer.invoke('preview:fetch', url),
publicServers: (url: string) => ipcRenderer.invoke('publist:fetch', url),
pingServer: (host: string, port: number) => ipcRenderer.invoke('publist:ping', host, port),
onContextMenu: (fn: (params: unknown) => void) => { ipcRenderer.on('context-menu', (_e, p) => fn(p)); },
editAction: (action: string, arg?: unknown) => ipcRenderer.send('edit:action', action, arg),
onTrayAction: (fn: (action: string) => void) => { ipcRenderer.on('tray:action', (_e, a: string) => fn(a)); },
describeCerts: (ders: Uint8Array[]) => ipcRenderer.invoke('certs:describe', ders),
identities: {
+98
View File
@@ -0,0 +1,98 @@
import dgram from 'node:dgram';
import dns from 'node:dns/promises';
import { randomBytes } from 'node:crypto';
// Mumble's public server list and the classic UDP ping, as used by the desktop client.
export interface PublicServer {
name: string;
host: string;
port: number;
url: string;
country: string;
countryCode: string;
continent: string;
ca: boolean;
}
export interface PingResult {
ping: number; // round trip in ms
users: number;
maxUsers: number;
bandwidth: number;
version: string;
}
const decode = (s: string) => s
.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
.replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCodePoint(parseInt(n, 16)))
.replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
// Website field: often a bare host like "www.example.com"; only http(s) links are kept
function webUrl(raw: string): string {
const v = raw.trim();
if (/^https?:\/\/[^\s]+$/i.test(v)) return v;
if (/^[a-z0-9-]+(\.[a-z0-9-]+)+(:\d+)?(\/\S*)?$/i.test(v)) return `http://${v}`;
return '';
}
export function parsePublicList(xml: string): PublicServer[] {
const out: PublicServer[] = [];
// Quoted values may contain '>' (e.g. name="<C>"), so step over quoted strings
for (const [, attrs] of xml.matchAll(/<server\s+((?:[^>"]|"[^"]*")*?)\/?>/g)) {
const a: Record<string, string> = {};
for (const m of attrs.matchAll(/([a-z_]+)="([^"]*)"/g)) a[m[1]] = decode(m[2]);
const port = Number(a.port) || 64738;
if (!a.ip || !a.name) continue;
out.push({
name: a.name, host: a.ip, port, url: webUrl(a.url ?? ''),
country: a.country || 'Unknown', countryCode: (a.country_code ?? '').toLowerCase(),
continent: (a.continent_code ?? '').toLowerCase(), ca: a.ca === '1'
});
}
return out;
}
// Mumble's list sits behind a CDN that refuses non-browser TLS clients (Node, curl: HTTP 501),
// so the app passes Electron's net.fetch, which uses Chromium's network stack
export async function fetchPublicListWith(fetcher: (url: string) => Promise<Response>, url: string): Promise<PublicServer[]> {
let res: Response;
try { res = await fetcher(url); } catch (e) { throw new Error(`Could not reach the list server (${(e as Error).message})`); }
if (!res.ok) throw new Error(`The list server answered with HTTP ${res.status}`);
return parsePublicList(await res.text());
}
// Legacy extended ping: 4 zero bytes + 8-byte cookie; answer is 24 bytes:
// version (legacy format), cookie, users, max users, bandwidth (big endian)
export async function pingServer(host: string, port: number, timeoutMs = 2000): Promise<PingResult | null> {
let address: string, family: number;
try { ({ address, family } = await dns.lookup(host)); } catch { return null; }
return new Promise(resolve => {
const sock = dgram.createSocket(family === 6 ? 'udp6' : 'udp4');
const cookie = randomBytes(8);
const packet = Buffer.concat([Buffer.alloc(4), cookie]);
let done = false;
const finish = (r: PingResult | null) => {
if (done) return;
done = true;
clearTimeout(timer);
try { sock.close(); } catch { /* closed */ }
resolve(r);
};
const start = process.hrtime.bigint();
const timer = setTimeout(() => finish(null), timeoutMs);
sock.on('message', msg => {
if (msg.length !== 24 || !msg.subarray(4, 12).equals(cookie)) return;
const v = msg.readUInt32BE(0);
finish({
ping: Number(process.hrtime.bigint() - start) / 1e6,
users: msg.readUInt32BE(12),
maxUsers: msg.readUInt32BE(16),
bandwidth: msg.readUInt32BE(20),
version: `${v >> 16}.${(v >> 8) & 255}.${v & 255}`
});
});
sock.on('error', () => finish(null));
sock.send(packet, port, address, err => { if (err) finish(null); });
});
}