// The browser build: what the desktop app does in its main process goes through the mumh5 web // proxy instead (server/proxy.ts). Identities are kept in this browser and sent to the proxy // when connecting, because the proxy presents the certificate to the Mumble server. import type { CertDetails } from '../core/transport.ts'; import { WebSocketTransport } from '../core/ws-transport.ts'; import type { IdentitySummary, PlatformApi } from './native.ts'; interface StoredIdentity { id: string; name: string; certPem: string; keyPem: string; fingerprint: string; cert: CertDetails; createdAt: string; } interface StoreData { identities: StoredIdentity[]; defaultId: string | null; setupDone: boolean } const KEY = 'mumh5.identities'; // The proxy normally serves the page; VITE_PROXY_URL points a separately hosted page at it const base = (() => { const fixed = import.meta.env?.VITE_PROXY_URL as string | undefined; if (fixed) return fixed.replace(/\/*$/, '/'); return typeof location !== 'undefined' ? new URL('.', location.href).href : ''; })(); async function call(route: string, body?: unknown): Promise { let res: Response; try { res = await fetch(`${base}api/${route}`, body === undefined ? undefined : { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); } catch { throw new Error('Could not reach the proxy.'); } const data = await res.json().catch(() => null); if (!res.ok) throw new Error(data?.error ?? `The proxy answered ${res.status}.`); return data as T; } const toBase64 = (bytes: Uint8Array) => { let s = ''; for (const b of bytes) s += String.fromCharCode(b); return btoa(s); }; const fromBase64 = (b64: string) => Uint8Array.from(atob(b64), c => c.charCodeAt(0)); function read(): StoreData { try { const d = JSON.parse(localStorage.getItem(KEY) ?? 'null'); if (d && Array.isArray(d.identities)) return d; } catch { /* storage unavailable or damaged */ } return { identities: [], defaultId: null, setupDone: false }; } function write(d: StoreData): void { // Unlike settings, losing this silently would lose the user's registrations try { localStorage.setItem(KEY, JSON.stringify(d)); } catch { throw new Error('This browser does not allow storing the identity (private window or storage disabled).'); } } const summary = (d: StoreData, i: StoredIdentity): IdentitySummary => ({ id: i.id, name: i.name, fingerprint: i.fingerprint, isDefault: i.id === d.defaultId, cert: i.cert }); type Fresh = { certPem: string; keyPem: string; fingerprint: string; cert: CertDetails; commonName: string }; function add(fresh: Fresh, name: string): IdentitySummary { const d = read(); const existing = d.identities.find(i => i.fingerprint === fresh.fingerprint); if (existing) throw new Error(`This certificate is already stored as "${existing.name}".`); const stored: StoredIdentity = { id: crypto.randomUUID(), name: name.trim() || fresh.commonName || 'Identity', certPem: fresh.certPem, keyPem: fresh.keyPem, fingerprint: fresh.fingerprint, cert: fresh.cert, createdAt: new Date().toISOString() }; d.identities.push(stored); d.defaultId ??= stored.id; write(d); return summary(d, stored); } // The identity to connect with: the requested one, else the default, else a new one async function identityFor(id?: string): Promise { let d = read(); if (!d.identities.length) { add(await call('identity/create', { name: 'mumh5 user', email: '' }), 'My identity'); d = read(); } return d.identities.find(i => i.id === id) ?? d.identities.find(i => i.id === d.defaultId) ?? d.identities[0]; } function browserVersion(): string { const m = /(Firefox|Edg|Chrome|Version)\/(\d+)/.exec(navigator.userAgent); if (!m) return ''; return `${m[1] === 'Edg' ? 'Edge' : m[1] === 'Version' ? 'Safari' : m[1]} ${m[2]}`; } export const webApi: PlatformApi = { platformInfo: async () => ({ os: 'Web', osVersion: browserVersion() }), describeCerts: ders => call('certs/describe', { ders: ders.map(toBase64) }), identities: { list: async () => { const d = read(); return { identities: d.identities.map(i => summary(d, i)), setupDone: d.setupDone }; }, create: async (name, email) => add(await call('identity/create', { name, email }), name), importP12: async (bytes, password, name) => add(await call('identity/import', { p12: toBase64(bytes), password }), name), // The desktop client's settings are out of a browser's reach findMumble: async () => null, importMumble: async () => { throw new Error('Not available in the browser. Export the certificate from Mumble and import the file.'); }, // Returns the file name; the browser decides where downloads go exportP12: async (id, password) => { const i = read().identities.find(x => x.id === id); if (!i) throw new Error('Identity not found.'); const { p12 } = await call<{ p12: string }>('identity/export', { certPem: i.certPem, keyPem: i.keyPem, password, name: i.name }); const file = `${i.name.replace(/[^\w.-]+/g, '_') || 'identity'}.p12`; const url = URL.createObjectURL(new Blob([fromBase64(p12)], { type: 'application/x-pkcs12' })); const a = document.createElement('a'); a.href = url; a.download = file; a.click(); setTimeout(() => URL.revokeObjectURL(url), 10000); return `your downloads as ${file}`; }, setDefault: async id => { const d = read(); if (d.identities.some(i => i.id === id)) d.defaultId = id; write(d); }, rename: async (id, name) => { const d = read(); const i = d.identities.find(x => x.id === id); if (i && name.trim()) i.name = name.trim(); write(d); }, remove: async id => { const d = read(); d.identities = d.identities.filter(i => i.id !== id); if (d.defaultId === id) d.defaultId = d.identities[0]?.id ?? null; write(d); }, finishSetup: async () => { const d = read(); d.setupDone = true; write(d); } } }; export async function openWebTransport(host: string, port: number, identityId?: string): Promise { const identity = await identityFor(identityId); return new WebSocketTransport(`${base.replace(/^http/, 'ws')}api/connect`, { host, port }, identity); } // Which servers the proxy connects to; the connect dialog offers these export interface ProxyServer { host: string; port: number; label: string } class ProxyInfo { servers = $state([]); any = $state(false); // UDP port of the proxy's STUN responder, if it runs one stun = $state(null); loaded = $state(false); error = $state(''); async load(): Promise { try { const c = await call<{ servers: ProxyServer[]; any: boolean; stun?: number | null }>('config'); this.servers = c.servers; this.any = c.any; this.stun = c.stun ?? null; this.error = ''; } catch (e) { this.error = (e as Error).message; } this.loaded = true; } } export const proxyInfo = new ProxyInfo(); // The proxy's own STUN address: a host the user already uses, so nothing new is contacted export function proxyStun(): string | null { if (!proxyInfo.stun || !base) return null; try { return `stun:${new URL(base).hostname}:${proxyInfo.stun}`; } catch { return null; } }