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,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()
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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) : '';
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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(); }
|
||||
};
|
||||
}
|
||||
@@ -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