Add the browser version, a toolbar, description previews and hints
Browser version - Self-hosted proxy (server/) that serves the web build and bridges WebSocket connections to Mumble servers over TLS, with a server allowlist or allow-any mode - WebSocket transport and a browser platform layer; identities live in the browser - npm run build:web, dev:web, proxy and test:e2e:web; proxy unit tests Interface - Toolbar next to mute and deafen: description editor, settings, expand or collapse all - Channels with a description show a marker; resting on the row previews it - Collapse all keeps channels with people open - Hints (title tooltips) can be switched on in a new Accessibility settings tab - New identities can set the username suggested when connecting - Own user information tells the client address from the one the server sees Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
// 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);
|
||||
loaded = $state(false);
|
||||
error = $state('');
|
||||
|
||||
async load(): Promise<void> {
|
||||
try {
|
||||
const c = await call<{ servers: ProxyServer[]; any: boolean }>('config');
|
||||
this.servers = c.servers;
|
||||
this.any = c.any;
|
||||
this.error = '';
|
||||
} catch (e) {
|
||||
this.error = (e as Error).message;
|
||||
}
|
||||
this.loaded = true;
|
||||
}
|
||||
}
|
||||
export const proxyInfo = new ProxyInfo();
|
||||
Reference in New Issue
Block a user