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
+39
View File
@@ -0,0 +1,39 @@
import { X509Certificate } from 'node:crypto';
export interface CertDetails {
subject: string;
issuer: string;
validFrom: string;
validTo: string;
serialNumber: string;
fingerprint: string; // SHA-1, the hash Mumble uses for users
fingerprint256: string;
subjectAltName: string;
keyType: string;
keyBits: number | null;
ca: boolean;
selfSigned: boolean;
pem: string;
}
// Plain, IPC-safe summary of a certificate for the viewer
export function describeCert(input: string | Buffer | X509Certificate): CertDetails {
const c = input instanceof X509Certificate ? input : new X509Certificate(input);
const key = c.publicKey;
const d = key.asymmetricKeyDetails;
return {
subject: c.subject,
issuer: c.issuer,
validFrom: new Date(c.validFrom).toISOString(),
validTo: new Date(c.validTo).toISOString(),
serialNumber: c.serialNumber,
fingerprint: c.fingerprint,
fingerprint256: c.fingerprint256,
subjectAltName: c.subjectAltName ?? '',
keyType: key.asymmetricKeyType ?? 'unknown',
keyBits: d?.modulusLength ?? null,
ca: c.ca,
selfSigned: c.checkIssued(c),
pem: c.toString()
};
}
+161
View File
@@ -0,0 +1,161 @@
import { promises as fs } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import { generateIdentity, identityFromP12, identityToP12, certCommonName, type Identity } from './identity.ts';
import { describeCert, type CertDetails } from './certs.ts';
export interface StoredIdentity extends Identity {
id: string;
name: string;
createdAt: string;
}
interface StoreFile {
identities: StoredIdentity[];
defaultId: string | null;
setupDone: boolean;
}
export interface IdentitySummary {
id: string;
name: string;
fingerprint: string;
isDefault: boolean;
cert: CertDetails;
}
// Where the desktop Mumble client (1.5+) keeps its settings, including its certificate
export function mumbleSettingsPaths(): string[] {
const home = os.homedir();
return [
path.join(process.env.APPDATA ?? path.join(home, 'AppData', 'Roaming'), 'Mumble', 'Mumble', 'mumble_settings.json'),
path.join(home, 'Library', 'Application Support', 'Mumble', 'Mumble', 'mumble_settings.json'),
path.join(process.env.XDG_CONFIG_HOME ?? path.join(home, '.config'), 'Mumble', 'Mumble', 'mumble_settings.json')
];
}
// Client identities (certificates), stored with owner-only permissions in the app's data folder
export class IdentityStore {
private data: StoreFile | null = null;
private file: string;
private legacyFile: string;
constructor(dir: string) {
this.file = path.join(dir, 'identities.json');
this.legacyFile = path.join(dir, 'identity.json');
}
private async load(): Promise<StoreFile> {
if (this.data) return this.data;
try {
this.data = JSON.parse(await fs.readFile(this.file, 'utf8')) as StoreFile;
} catch {
this.data = { identities: [], defaultId: null, setupDone: false };
// Earlier versions generated one identity silently; keep it, but still run the setup wizard
try {
const legacy = JSON.parse(await fs.readFile(this.legacyFile, 'utf8')) as Identity;
const id = { ...legacy, id: randomUUID(), name: certCommonName(legacy.certPem) || 'My identity', createdAt: new Date().toISOString() };
this.data.identities.push(id);
this.data.defaultId = id.id;
await this.save();
} catch { /* no legacy identity */ }
}
return this.data;
}
private async save(): Promise<void> {
await fs.writeFile(this.file, JSON.stringify(this.data), { mode: 0o600 });
}
private summary(d: StoreFile, i: StoredIdentity): IdentitySummary {
return { id: i.id, name: i.name, fingerprint: i.fingerprint, isDefault: i.id === d.defaultId, cert: describeCert(i.certPem) };
}
async list(): Promise<{ identities: IdentitySummary[]; setupDone: boolean }> {
const d = await this.load();
return { identities: d.identities.map(i => this.summary(d, i)), setupDone: d.setupDone };
}
// The identity to connect with: the requested one, else the default, else a new one
async get(id?: string | null): Promise<StoredIdentity> {
const d = await this.load();
const found = d.identities.find(i => i.id === id) ?? d.identities.find(i => i.id === d.defaultId) ?? d.identities[0];
if (found) return found;
const created = await this.add(generateIdentity(os.userInfo().username), os.userInfo().username);
return d.identities.find(i => i.id === created.id)!;
}
private async add(identity: Identity, name: string): Promise<IdentitySummary> {
const d = await this.load();
const existing = d.identities.find(i => i.fingerprint === identity.fingerprint);
if (existing) throw new Error(`This certificate is already stored as "${existing.name}".`);
const stored: StoredIdentity = { ...identity, id: randomUUID(), name: name.trim() || 'Identity', createdAt: new Date().toISOString() };
d.identities.push(stored);
d.defaultId ??= stored.id;
await this.save();
return this.summary(d, stored);
}
create(name: string, email: string): Promise<IdentitySummary> {
return this.add(generateIdentity(name, email), name);
}
importP12(bytes: Uint8Array, password: string, name: string): Promise<IdentitySummary> {
const identity = identityFromP12(bytes, password);
return this.add(identity, name || certCommonName(identity.certPem));
}
// Finds the desktop client's certificate; returns its name if one exists
async findMumbleCertificate(): Promise<{ path: string; name: string } | null> {
for (const p of mumbleSettingsPaths()) {
try {
const settings = JSON.parse(await fs.readFile(p, 'utf8'));
if (typeof settings.certificate !== 'string' || !settings.certificate) continue;
const identity = identityFromP12(new Uint8Array(Buffer.from(settings.certificate, 'base64')), '');
return { path: p, name: certCommonName(identity.certPem) || 'Mumble certificate' };
} catch { /* not there or unreadable */ }
}
return null;
}
async importFromMumble(name: string): Promise<IdentitySummary> {
const found = await this.findMumbleCertificate();
if (!found) throw new Error('No certificate from the desktop Mumble client was found.');
const settings = JSON.parse(await fs.readFile(found.path, 'utf8'));
return this.importP12(new Uint8Array(Buffer.from(settings.certificate, 'base64')), '', name || found.name);
}
async exportP12(id: string, password: string): Promise<{ bytes: Uint8Array; name: string }> {
const d = await this.load();
const i = d.identities.find(x => x.id === id);
if (!i) throw new Error('Identity not found.');
return { bytes: identityToP12(i, password, i.name), name: i.name };
}
async setDefault(id: string): Promise<void> {
const d = await this.load();
if (d.identities.some(i => i.id === id)) d.defaultId = id;
await this.save();
}
async rename(id: string, name: string): Promise<void> {
const d = await this.load();
const i = d.identities.find(x => x.id === id);
if (i && name.trim()) i.name = name.trim();
await this.save();
}
async remove(id: string): Promise<void> {
const d = await this.load();
d.identities = d.identities.filter(i => i.id !== id);
if (d.defaultId === id) d.defaultId = d.identities[0]?.id ?? null;
await this.save();
}
async finishSetup(): Promise<void> {
const d = await this.load();
d.setupDone = true;
await this.save();
}
}
+73
View File
@@ -0,0 +1,73 @@
import forge from 'node-forge';
import { createHash } from 'node:crypto';
export interface Identity {
certPem: string;
keyPem: string;
fingerprint: string;
}
// Self-signed client certificate, the same kind the desktop Mumble client generates.
// Mumble servers identify registered users by the hash of this certificate.
export function generateIdentity(name: string, email = ''): Identity {
const keys = forge.pki.rsa.generateKeyPair({ bits: 2048 });
const cert = forge.pki.createCertificate();
cert.publicKey = keys.publicKey;
cert.serialNumber = '01' + forge.util.bytesToHex(forge.random.getBytesSync(8));
cert.validity.notBefore = new Date();
cert.validity.notAfter = new Date();
cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 20);
const attrs: { name: string; value: string }[] = [{ name: 'commonName', value: name || 'mumh5 user' }];
if (email) attrs.push({ name: 'emailAddress', value: email });
cert.setSubject(attrs);
cert.setIssuer(attrs);
cert.setExtensions([
{ name: 'basicConstraints', cA: false },
{ name: 'keyUsage', digitalSignature: true, keyEncipherment: true },
{ name: 'extKeyUsage', clientAuth: true }
]);
cert.sign(keys.privateKey, forge.md.sha256.create());
const certPem = forge.pki.certificateToPem(cert);
return { certPem, keyPem: forge.pki.privateKeyToPem(keys.privateKey), fingerprint: certFingerprint(certPem) };
}
// SHA-1 of the DER certificate, the hash Mumble shows and stores for users.
export function certFingerprint(certPem: string): string {
const der = forge.asn1.toDer(forge.pki.certificateToAsn1(forge.pki.certificateFromPem(certPem))).getBytes();
return createHash('sha1').update(Buffer.from(der, 'binary')).digest('hex');
}
// Read a PKCS#12 file (.p12/.pfx, as exported by the desktop Mumble client).
export function identityFromP12(bytes: Uint8Array, password: string): Identity {
let p12: forge.pkcs12.Pkcs12Pfx;
try {
const der = forge.util.binary.raw.encode(bytes);
p12 = forge.pkcs12.pkcs12FromAsn1(forge.asn1.fromDer(der), password);
} catch {
throw new Error('Could not open the file. Wrong password, or not a PKCS#12 certificate.');
}
const bags = (type: string) => p12.getBags({ bagType: type })[type] ?? [];
const cert = bags(forge.pki.oids.certBag).find(b => b.cert)?.cert;
const key = [...bags(forge.pki.oids.pkcs8ShroudedKeyBag), ...bags(forge.pki.oids.keyBag)].find(b => b.key)?.key;
if (!cert || !key) throw new Error('The file does not contain both a certificate and its private key.');
const certPem = forge.pki.certificateToPem(cert);
return { certPem, keyPem: forge.pki.privateKeyToPem(key), fingerprint: certFingerprint(certPem) };
}
// PKCS#12 with 3DES, which the desktop Mumble client and other tools can import.
export function identityToP12(id: Identity, password: string, friendlyName: string): Uint8Array {
const asn1 = forge.pkcs12.toPkcs12Asn1(
forge.pki.privateKeyFromPem(id.keyPem),
[forge.pki.certificateFromPem(id.certPem)],
password,
{ algorithm: '3des', friendlyName }
);
return new Uint8Array(Buffer.from(forge.asn1.toDer(asn1).getBytes(), 'binary'));
}
// Common name of a certificate, used as the default identity name
export function certCommonName(certPem: string): string {
const cn = forge.pki.certificateFromPem(certPem).subject.getField('CN');
return cn ? String(cn.value) : '';
}
+152
View File
@@ -0,0 +1,152 @@
import { app, BrowserWindow, dialog, ipcMain, shell, session, systemPreferences, type WebContents } from 'electron';
import { promises as fs } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { IdentityStore } from './identity-store.ts';
import { describeCert } from './certs.ts';
import * as tray from './tray.ts';
import { openTls } from './tls-transport.ts';
const devUrl = process.env.VITE_DEV_SERVER_URL;
let identityStore: IdentityStore | null = null;
const identities = () => (identityStore ??= new IdentityStore(app.getPath('userData')));
// ─── Mumble TLS connections, one per renderer request ─────────────────────────
type Conn = ReturnType<typeof openTls> & { owner: WebContents };
const conns = new Map<string, Conn>();
// ─── Identities (client certificates) ─────────────────────────────────────────
ipcMain.handle('identities:list', () => identities().list());
ipcMain.handle('identities:create', (_e, name: string, email: string) => identities().create(String(name), String(email ?? '')));
ipcMain.handle('identities:import', (_e, bytes: Uint8Array, password: string, name: string) =>
identities().importP12(new Uint8Array(bytes), String(password ?? ''), String(name ?? '')));
ipcMain.handle('identities:findMumble', () => identities().findMumbleCertificate().then(f => f && { name: f.name }));
ipcMain.handle('identities:importMumble', (_e, name: string) => identities().importFromMumble(String(name ?? '')));
ipcMain.handle('identities:setDefault', (_e, id: string) => identities().setDefault(String(id)));
ipcMain.handle('identities:rename', (_e, id: string, name: string) => identities().rename(String(id), String(name)));
ipcMain.handle('identities:remove', (_e, id: string) => identities().remove(String(id)));
ipcMain.handle('identities:finishSetup', () => identities().finishSetup());
// Saves a password-protected .p12 backup where the user chooses; returns the path or null
ipcMain.handle('identities:export', async (e, id: string, password: string) => {
const { bytes, name } = await identities().exportP12(String(id), String(password ?? ''));
const win = BrowserWindow.fromWebContents(e.sender);
const opts = {
title: 'Save identity backup',
defaultPath: path.join(app.getPath('documents'), `${name.replace(/[^\w.-]+/g, '_') || 'identity'}.p12`),
filters: [{ name: 'PKCS#12 certificate', extensions: ['p12', 'pfx'] }]
};
const res = win ? await dialog.showSaveDialog(win, opts) : await dialog.showSaveDialog(opts);
if (res.canceled || !res.filePath) return null;
await fs.writeFile(res.filePath, bytes, { mode: 0o600 });
return res.filePath;
});
// The renderer picks the connection id and subscribes before calling open, so no event can be missed
ipcMain.handle('mumble:open', async (e, connId: string, host: string, port: number, identityId?: string) => {
if (typeof connId !== 'string' || conns.has(connId)) throw new Error('Invalid connection id');
const id = await identities().get(identityId);
const owner = e.sender;
const emit = (channel: string, ...args: unknown[]) => { if (!owner.isDestroyed()) owner.send(channel, connId, ...args); };
const conn = openTls(String(host), Number(port) || 64738, id.certPem, id.keyPem, {
onSecure: info => emit('mumble:secure', info),
onData: chunk => emit('mumble:data', chunk),
onClose: reason => { conns.delete(connId); emit('mumble:close', reason); }
});
conns.set(connId, Object.assign(conn, { owner }));
owner.once('destroyed', () => conn.close());
});
ipcMain.on('mumble:send', (e, connId: string, bytes: Uint8Array) => {
const conn = conns.get(connId);
if (conn && conn.owner === e.sender) conn.send(bytes);
});
ipcMain.on('mumble:close', (e, connId: string) => {
const conn = conns.get(connId);
if (conn && conn.owner === e.sender) conn.close();
});
// Parses DER certificates (e.g. a user's chain from UserStats) for the viewer
ipcMain.handle('certs:describe', (_e, ders: Uint8Array[]) =>
(Array.isArray(ders) ? ders.slice(0, 8) : []).flatMap(d => { try { return [describeCert(Buffer.from(d))]; } catch { return []; } }));
ipcMain.on('tray:update', (_e, state: tray.TrayState) => tray.update(state));
ipcMain.handle('platform:info', () => ({ os: process.platform, osVersion: os.release() }));
// ─── Window ────────────────────────────────────────────────────────────────────
let mainWindow: BrowserWindow | null = null;
function createWindow() {
const win = new BrowserWindow({
width: 1280,
height: 800,
minWidth: 360,
minHeight: 480,
backgroundColor: '#111214',
title: 'mumh5',
autoHideMenuBar: true,
webPreferences: {
preload: path.join(__dirname, 'preload.cjs'),
contextIsolation: true,
sandbox: true,
nodeIntegration: false,
// Voice must play without a click first
autoplayPolicy: 'no-user-gesture-required',
// Keep audio and timers running at full speed when the window is in the background
backgroundThrottling: false
}
});
// Links from chat open in the system browser, never inside the app
win.webContents.setWindowOpenHandler(({ url }) => {
if (/^https?:\/\//i.test(url)) shell.openExternal(url);
return { action: 'deny' };
});
win.webContents.on('will-navigate', (e, url) => {
if (devUrl && url.startsWith(devUrl)) return;
e.preventDefault();
if (/^https?:\/\//i.test(url)) shell.openExternal(url);
});
mainWindow = win;
win.on('close', e => {
if (tray.shouldHideOnClose()) {
e.preventDefault();
win.hide();
}
});
win.on('show', () => tray.onWindowVisibility());
win.on('hide', () => tray.onWindowVisibility());
win.on('closed', () => { if (mainWindow === win) mainWindow = null; });
if (devUrl) win.loadURL(devUrl);
else win.loadFile(path.join(__dirname, '../dist/index.html'));
}
app.whenReady().then(async () => {
// macOS asks once per app for microphone access
if (process.platform === 'darwin') await systemPreferences.askForMediaAccess('microphone').catch(() => false);
// YouTube refuses embeds without a referrer (error 153); a file:// app sends none
session.defaultSession.webRequest.onBeforeSendHeaders({ urls: ['https://www.youtube-nocookie.com/*'] }, (details, cb) => {
if (!details.requestHeaders.Referer) details.requestHeaders.Referer = 'https://mumh5.app/';
cb({ requestHeaders: details.requestHeaders });
});
// Microphone, camera and screen capture for voice and video; nothing else
session.defaultSession.setPermissionRequestHandler((_wc, permission, cb) => {
cb(['media', 'display-capture', 'clipboard-sanitized-write', 'notifications'].includes(permission));
});
createWindow();
await tray.setupTray(() => mainWindow, action => mainWindow?.webContents.send('tray:action', action));
app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); });
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
+36
View File
@@ -0,0 +1,36 @@
import { contextBridge, ipcRenderer } from 'electron';
type Listener = (connId: string, ...args: any[]) => void;
const listeners = { secure: new Set<Listener>(), data: new Set<Listener>(), close: new Set<Listener>() };
for (const key of Object.keys(listeners) as (keyof typeof listeners)[]) {
ipcRenderer.on(`mumble:${key}`, (_e, connId: string, ...args: any[]) => {
for (const fn of listeners[key]) fn(connId, ...args);
});
}
// The only native surface the renderer gets
contextBridge.exposeInMainWorld('mumh5Native', {
platformInfo: () => ipcRenderer.invoke('platform:info'),
updateTray: (state: unknown) => ipcRenderer.send('tray:update', state),
onTrayAction: (fn: (action: string) => void) => { ipcRenderer.on('tray:action', (_e, a: string) => fn(a)); },
describeCerts: (ders: Uint8Array[]) => ipcRenderer.invoke('certs:describe', ders),
identities: {
list: () => ipcRenderer.invoke('identities:list'),
create: (name: string, email: string) => ipcRenderer.invoke('identities:create', name, email),
importP12: (bytes: Uint8Array, password: string, name: string) => ipcRenderer.invoke('identities:import', bytes, password, name),
findMumble: () => ipcRenderer.invoke('identities:findMumble'),
importMumble: (name: string) => ipcRenderer.invoke('identities:importMumble', name),
exportP12: (id: string, password: string) => ipcRenderer.invoke('identities:export', id, password),
setDefault: (id: string) => ipcRenderer.invoke('identities:setDefault', id),
rename: (id: string, name: string) => ipcRenderer.invoke('identities:rename', id, name),
remove: (id: string) => ipcRenderer.invoke('identities:remove', id),
finishSetup: () => ipcRenderer.invoke('identities:finishSetup')
},
open: (connId: string, host: string, port: number, identityId?: string) => ipcRenderer.invoke('mumble:open', connId, host, port, identityId),
send: (connId: string, bytes: Uint8Array) => ipcRenderer.send('mumble:send', connId, bytes),
close: (connId: string) => ipcRenderer.send('mumble:close', connId),
on: (event: keyof typeof listeners, fn: Listener) => {
listeners[event].add(fn);
return () => listeners[event].delete(fn);
}
});
+50
View File
@@ -0,0 +1,50 @@
import tls from 'node:tls';
import { describeCert, type CertDetails } from './certs.ts';
export interface TlsHandlers {
onSecure(info: { fingerprint: string; authorized: boolean; authError: string | null; chain: CertDetails[] }): void;
onData(chunk: Uint8Array): void;
onClose(reason: string): void;
}
// Opens a TLS connection to a Mumble server. Most servers use self-signed certificates,
// so verification is left to the caller (trust on first use via the sha256 fingerprint).
export function openTls(host: string, port: number, cert: string, key: string, h: TlsHandlers) {
let closeReason = 'Connection closed';
const socket = tls.connect({
host, port, cert, key,
rejectUnauthorized: false,
servername: /^[\d.:]+$/.test(host) ? undefined : host
});
socket.setNoDelay(true);
socket.setKeepAlive(true, 30000);
socket.setTimeout(20000, () => {
closeReason = 'Connection timed out';
socket.destroy();
});
socket.on('secureConnect', () => {
socket.setTimeout(0);
// Leaf first, then the issuers the server sent (the root links to itself)
const chain: CertDetails[] = [];
let cur = socket.getPeerCertificate(true) as tls.DetailedPeerCertificate | undefined;
const seen = new Set<string>();
while (cur?.raw && !seen.has(cur.fingerprint256) && chain.length < 8) {
seen.add(cur.fingerprint256);
chain.push(describeCert(cur.raw));
cur = cur.issuerCertificate;
}
h.onSecure({
chain,
fingerprint: chain[0]?.fingerprint256 ?? '',
authorized: socket.authorized,
authError: socket.authorizationError ? String(socket.authorizationError) : null
});
});
socket.on('data', (chunk: Buffer) => h.onData(new Uint8Array(chunk)));
socket.on('error', (e: Error) => { closeReason = e.message; });
socket.on('close', () => h.onClose(closeReason));
return {
send(bytes: Uint8Array) { if (!socket.destroyed) socket.write(bytes); },
close() { closeReason = 'Disconnected'; socket.destroy(); }
};
}
+97
View File
@@ -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';
}