Files
mumh5/src/lib/web.svelte.ts
T
kibiandClaude Opus 5.5 826542ed8a Add a STUN responder to the proxy for screen sharing between browsers
The browser build uses it automatically, so no outside server is contacted.
Covered by unit tests and a two-browser step in the web E2E.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 21:38:42 +02:00

163 lines
7.2 KiB
TypeScript

// 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<T>(route: string, body?: unknown): Promise<T> {
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<StoredIdentity> {
let d = read();
if (!d.identities.length) {
add(await call<Fresh>('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<CertDetails[]>('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<Fresh>('identity/create', { name, email }), name),
importP12: async (bytes, password, name) => add(await call<Fresh>('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<WebSocketTransport> {
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<ProxyServer[]>([]);
any = $state(false);
// UDP port of the proxy's STUN responder, if it runs one
stun = $state<number | null>(null);
loaded = $state(false);
error = $state('');
async load(): Promise<void> {
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; }
}