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:
@@ -0,0 +1,97 @@
|
||||
import { app, BrowserWindow, Menu, Tray, nativeImage } from 'electron';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
// State the renderer reports; the tray mirrors it
|
||||
export interface TrayState {
|
||||
icon: string; // PNG data URL drawn by the renderer
|
||||
tooltip: string;
|
||||
connected: boolean;
|
||||
server: string;
|
||||
muted: boolean;
|
||||
deafened: boolean;
|
||||
mode: 'vad' | 'ptt' | 'continuous';
|
||||
}
|
||||
|
||||
let tray: Tray | null = null;
|
||||
let state: TrayState | null = null;
|
||||
let closeToTray = true;
|
||||
let quitting = false;
|
||||
const prefsFile = () => path.join(app.getPath('userData'), 'tray.json');
|
||||
|
||||
export async function setupTray(getWindow: () => BrowserWindow | null, send: (action: string) => void): Promise<void> {
|
||||
try { closeToTray = JSON.parse(await fs.readFile(prefsFile(), 'utf8')).closeToTray !== false; } catch { /* defaults */ }
|
||||
|
||||
const show = () => {
|
||||
const win = getWindow();
|
||||
if (!win) return;
|
||||
if (win.isMinimized()) win.restore();
|
||||
win.show();
|
||||
win.focus();
|
||||
};
|
||||
|
||||
const rebuild = () => {
|
||||
if (!tray || !state) return;
|
||||
const win = getWindow();
|
||||
const s = state;
|
||||
const menu = Menu.buildFromTemplate([
|
||||
{ label: win?.isVisible() ? 'Hide mumh5' : 'Show mumh5', click: () => (win?.isVisible() ? win.hide() : show()) },
|
||||
{ type: 'separator' },
|
||||
{ label: s.connected ? `Connected: ${s.server}` : 'Not connected', enabled: false },
|
||||
{ label: 'Mute', type: 'checkbox', checked: s.muted, enabled: s.connected, click: () => send('toggle-mute') },
|
||||
{ label: 'Deafen', type: 'checkbox', checked: s.deafened, enabled: s.connected, click: () => send('toggle-deafen') },
|
||||
{
|
||||
label: 'Transmit', submenu: [
|
||||
{ label: 'Voice activity', type: 'radio', checked: s.mode === 'vad', click: () => send('mode:vad') },
|
||||
{ label: 'Push to talk', type: 'radio', checked: s.mode === 'ptt', click: () => send('mode:ptt') },
|
||||
{ label: 'Always on', type: 'radio', checked: s.mode === 'continuous', click: () => send('mode:continuous') }
|
||||
]
|
||||
},
|
||||
{ label: 'Voice settings...', click: () => { show(); send('settings'); } },
|
||||
{ label: 'Disconnect', enabled: s.connected, click: () => send('disconnect') },
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Close to tray', type: 'checkbox', checked: closeToTray, click: item => {
|
||||
closeToTray = item.checked;
|
||||
fs.writeFile(prefsFile(), JSON.stringify({ closeToTray })).catch(() => {});
|
||||
}
|
||||
},
|
||||
{ label: 'Quit mumh5', click: () => { quitting = true; app.quit(); } }
|
||||
]);
|
||||
tray.setContextMenu(menu);
|
||||
// Inspection hook for the end-to-end tests (tray menus cannot be clicked by automation)
|
||||
(globalThis as any).__mumh5Tray = {
|
||||
state: { ...s, icon: s.icon.slice(0, 22) },
|
||||
tooltip: s.tooltip,
|
||||
items: menu.items.map(i => ({ label: i.label, checked: i.checked, enabled: i.enabled })),
|
||||
click: (label: string) => { const item = menu.items.find(i => i.label === label); item?.click(undefined, getWindow() ?? undefined, getWindow()?.webContents); }
|
||||
};
|
||||
};
|
||||
|
||||
app.on('before-quit', () => { quitting = true; });
|
||||
|
||||
// Called for every state change from the renderer
|
||||
update = (next: TrayState) => {
|
||||
state = next;
|
||||
const image = nativeImage.createFromDataURL(next.icon);
|
||||
if (!tray) {
|
||||
tray = new Tray(image);
|
||||
tray.on('click', show);
|
||||
} else {
|
||||
tray.setImage(image);
|
||||
}
|
||||
tray.setToolTip(next.tooltip);
|
||||
rebuild();
|
||||
};
|
||||
|
||||
// Keep the Show/Hide label right
|
||||
onWindowVisibility = rebuild;
|
||||
}
|
||||
|
||||
export let update: (s: TrayState) => void = () => {};
|
||||
export let onWindowVisibility: () => void = () => {};
|
||||
|
||||
// Closing the window hides it while the tray is there, unless the user quits
|
||||
export function shouldHideOnClose(): boolean {
|
||||
return !!tray && closeToTray && !quitting && process.platform !== 'darwin';
|
||||
}
|
||||
Reference in New Issue
Block a user