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:
+31
-1
@@ -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; });
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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(/"/g, '"').replace(/'/g, "'").replace(/</g, '<').replace(/>/g, '>').replace(/&/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); });
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user