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:
2026-10-01 20:47:10 +02:00
co-authored by Claude Opus 5.5
parent 74aaf635be
commit 82972438fe
50 changed files with 1485 additions and 80 deletions
+3
View File
@@ -51,4 +51,7 @@ export interface ServerCertInfo {
authorized: boolean; // true if it chains to a trusted CA
authError: string | null; // why it is not trusted, e.g. DEPTH_ZERO_SELF_SIGNED_CERT
chain: CertDetails[]; // leaf first
// Browser build only: this client's address as the web proxy saw it. The Mumble server
// sees the proxy's address instead and cannot be told the original one.
clientAddress?: string;
}
+50
View File
@@ -0,0 +1,50 @@
import type { Transport, ServerCertInfo } from './transport.ts';
// Transport through the mumh5 web proxy: one WebSocket per Mumble connection. The proxy opens
// the TLS socket and presents the client certificate, since a browser can do neither.
// `secure` resolves once the proxy has finished the TLS handshake, before any Mumble data is
// sent, so the caller can check the server certificate before sending a password.
// There is no UDP here; voice goes through the TCP tunnel.
export class WebSocketTransport implements Transport {
onData: ((chunk: Uint8Array) => void) | null = null;
onClose: ((reason: string) => void) | null = null;
readonly secure: Promise<ServerCertInfo>;
private ws: WebSocket;
constructor(url: string, target: { host: string; port: number }, identity: { certPem: string; keyPem: string }) {
let resolveSecure!: (i: ServerCertInfo) => void;
let rejectSecure!: (e: Error) => void;
this.secure = new Promise((res, rej) => { resolveSecure = res; rejectSecure = rej; });
// Callers that never await `secure` still get onClose
this.secure.catch(() => {});
let reason = '';
let opened = false;
const ws = new WebSocket(url);
this.ws = ws;
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
opened = true;
ws.send(JSON.stringify({ host: target.host, port: target.port, certPem: identity.certPem, keyPem: identity.keyPem }));
};
ws.onmessage = e => {
if (typeof e.data !== 'string') return this.onData?.(new Uint8Array(e.data as ArrayBuffer));
let msg: any;
try { msg = JSON.parse(e.data); } catch { return; }
if (msg.type === 'secure') resolveSecure(msg.info);
else if (msg.type === 'close') reason = String(msg.reason ?? '');
};
ws.onclose = () => {
reason ||= opened ? 'Connection closed' : 'Could not reach the proxy';
rejectSecure(new Error(reason));
this.onClose?.(reason);
};
}
send(bytes: Uint8Array): void {
if (this.ws.readyState === WebSocket.OPEN) this.ws.send(bytes as Uint8Array<ArrayBuffer>);
}
close(): void {
this.ws.close();
}
}
+33
View File
@@ -0,0 +1,33 @@
import { store } from './settings.svelte.ts';
// Hints are the browser tooltips from title attributes. With the setting off, the title of
// whatever is under the pointer is set aside while it is hovered and put back afterwards, so
// the attributes stay in place for screen readers and everything else that reads them.
export function startHints(): void {
let held: { el: Element; title: string; labelled: boolean }[] = [];
const restore = () => {
for (const h of held) {
// Leave it alone if the app set a new title in the meantime
if (!h.el.hasAttribute('title')) h.el.setAttribute('title', h.title);
if (h.labelled) h.el.removeAttribute('aria-label');
}
held = [];
};
document.addEventListener('mouseover', e => {
restore();
if (store.settings.hints) return;
// A title on any ancestor would show too
for (let el = e.target as Element | null; el; el = el.parentElement) {
const title = el.getAttribute('title');
if (!title) continue;
// Keep the accessible name of icon buttons that are only named by their title
const labelled = !el.hasAttribute('aria-label') && !el.textContent?.trim();
if (labelled) el.setAttribute('aria-label', title);
el.removeAttribute('title');
held.push({ el, title, labelled });
}
}, true);
document.addEventListener('mouseleave', restore);
}
+1
View File
@@ -6,6 +6,7 @@ export const ICON_PATHS: Record<string, string> = {
'headphones-off': 'M3 14v-2a9 9 0 0 1 18 0v2M21 14v7h-4v-7zM3 14v7h4v-7zM3 3l18 18',
settings: 'M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6',
plus: 'M12 5v14M5 12h14',
filter: 'M3 4h18l-7 8v7l-4 2v-9z',
info: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zM12 11v6M12 7h.01',
'arrow-right': 'M5 12h14M13 6l6 6-6 6',
paperclip: 'M21.4 11.1l-9.2 9.2a6 6 0 0 1-8.5-8.5l9.2-9.2a4 4 0 0 1 5.7 5.7l-9.2 9.2a2 2 0 0 1-2.8-2.8l8.5-8.5',
+32 -14
View File
@@ -1,8 +1,27 @@
import type { Transport, ServerCertInfo, CertDetails, UdpChannel } from '../core/transport.ts';
import { webApi, openWebTransport } from './web.svelte.ts';
interface NativeApi {
// What both the desktop app and the browser build (through the web proxy) provide
export interface PlatformApi {
platformInfo(): Promise<{ os: string; osVersion: string }>;
describeCerts(ders: Uint8Array[]): Promise<CertDetails[]>;
identities: {
list(): Promise<{ identities: IdentitySummary[]; setupDone: boolean }>;
create(name: string, email: string): Promise<IdentitySummary>;
importP12(bytes: Uint8Array, password: string, name: string): Promise<IdentitySummary>;
findMumble(): Promise<{ name: string } | null>;
importMumble(name: string): Promise<IdentitySummary>;
// Where the backup was saved, or null when cancelled
exportP12(id: string, password: string): Promise<string | null>;
setDefault(id: string): Promise<void>;
rename(id: string, name: string): Promise<void>;
remove(id: string): Promise<void>;
finishSetup(): Promise<void>;
};
}
// The desktop app's preload API
interface NativeApi extends PlatformApi {
updateTray(state: { icon: string; tooltip: string; connected: boolean; server: string; muted: boolean; deafened: boolean; mode: string }): void;
onTrayAction(fn: (action: string) => void): void;
linkPreview(url: string): Promise<LinkPreviewData | null>;
@@ -10,18 +29,6 @@ interface NativeApi {
pingServer(host: string, port: number): Promise<PingResult | null>;
onContextMenu(fn: (params: ContextParams) => void): void;
editAction(action: string, arg?: unknown): void;
identities: {
list(): Promise<{ identities: IdentitySummary[]; setupDone: boolean }>;
create(name: string, email: string): Promise<IdentitySummary>;
importP12(bytes: Uint8Array, password: string, name: string): Promise<IdentitySummary>;
findMumble(): Promise<{ name: string } | null>;
importMumble(name: string): Promise<IdentitySummary>;
exportP12(id: string, password: string): Promise<string | null>;
setDefault(id: string): Promise<void>;
rename(id: string, name: string): Promise<void>;
remove(id: string): Promise<void>;
finishSetup(): Promise<void>;
};
open(connId: string, host: string, port: number, identityId?: string): Promise<void>;
send(connId: string, bytes: Uint8Array): void;
close(connId: string): void;
@@ -71,7 +78,18 @@ declare global {
interface Window { mumh5Native?: NativeApi }
}
export const native: NativeApi | null = typeof window !== 'undefined' ? window.mumh5Native ?? null : null;
// Desktop-only features (tray, UDP voice, public list, direct link previews) check `desktop`
export const desktop: NativeApi | null = typeof window !== 'undefined' ? window.mumh5Native ?? null : null;
// The browser build (vite --mode web) talks to the web proxy instead
export const isWeb = !desktop && import.meta.env?.MODE === 'web';
export const native: PlatformApi | null = desktop ?? (isWeb ? webApi : null);
// A connection to a Mumble server: TLS in the desktop app, the proxy's WebSocket in the browser
export async function openTransport(host: string, port: number, identityId?: string): Promise<(Transport & { secure: Promise<ServerCertInfo> }) | null> {
if (desktop) return new ElectronTransport(desktop, host, port, identityId);
if (isWeb) return openWebTransport(host, port, identityId);
return null;
}
// Transport backed by a TLS socket in the Electron main process.
// `secure` resolves once the TLS handshake is done, before any Mumble data is sent,
+6 -3
View File
@@ -1,4 +1,4 @@
import { native, type LinkPreviewData } from './native.ts';
import { desktop, type LinkPreviewData } from './native.ts';
import { store } from './settings.svelte.ts';
// One request per URL and mode per app run
@@ -6,9 +6,12 @@ const cache = new Map<string, Promise<LinkPreviewData | null>>();
// The mode in effect: previews through the upload host need its key, without one they are off.
// Never falls back to fetching directly, that would show linked sites the user's IP.
// Fetching directly needs the desktop app (a web page cannot read other sites).
export function previewMode(): 'host' | 'direct' | 'off' {
const { linkPreviews, uploadHost, uploadKey } = store.settings;
return linkPreviews === 'host' && !(uploadHost && uploadKey) ? 'off' : linkPreviews;
if (linkPreviews === 'host' && !(uploadHost && uploadKey)) return 'off';
if (linkPreviews === 'direct' && !desktop) return 'off';
return linkPreviews;
}
export function linkPreview(url: string): Promise<LinkPreviewData | null> {
@@ -19,7 +22,7 @@ export function linkPreview(url: string): Promise<LinkPreviewData | null> {
let p = cache.get(id);
if (!p) {
p = mode === 'direct'
? (native?.linkPreview(url) ?? Promise.resolve(null)).catch(() => null)
? (desktop?.linkPreview(url) ?? Promise.resolve(null)).catch(() => null)
: fetch(`${uploadHost.replace(/\/+$/, '')}/api/chat/preview?url=${encodeURIComponent(url)}`, { headers: { 'X-API-Key': uploadKey } })
.then(r => r.json())
.then(d => (d?.success ? d as LinkPreviewData : null))
+8 -4
View File
@@ -1,6 +1,6 @@
import { createCodec } from '../core/proto.ts';
import { MumbleClient, describeDenial, type Channel, type User, type TextMessage } from '../core/client.ts';
import { native, ElectronTransport } from './native.ts';
import { native, openTransport } from './native.ts';
import type { ServerCertInfo } from '../core/transport.ts';
import { store, type SavedServer } from './settings.svelte.ts';
import { renderIncoming, textToHtml, escapeHtml, formatBytes, type Embed } from './html.ts';
@@ -155,9 +155,10 @@ export class Session {
this.expanded[id] = !this.isExpanded(id);
}
// Collapsing goes back to the default: channels with people in them stay open
setAllExpanded(open: boolean): void {
const all: Record<number, boolean> = {};
for (const id of this.client?.channels.keys() ?? []) all[id] = open;
if (open) for (const id of this.client?.channels.keys() ?? []) all[id] = true;
this.expanded = all;
}
@@ -224,13 +225,16 @@ export class Session {
this.status = 'connecting';
if (!native) {
this.fail('Connecting needs the desktop app for now. The web version will connect through a proxy.');
this.fail('Connecting needs the desktop app or the web build served by a mumh5 proxy.');
return;
}
const transport = new ElectronTransport(native, server.host, server.port, server.identityId);
let transport;
let info;
try {
transport = (await openTransport(server.host, server.port, server.identityId))!;
// Superseded while the identity was being prepared
if (attempt !== this.attempt) return transport.close();
info = await transport.secure;
} catch (e) {
if (attempt === this.attempt) this.fail((e as Error).message);
+11 -1
View File
@@ -24,6 +24,7 @@ export interface Settings {
theme: ThemeId;
publicListUrl: string; // where the public server list comes from
arrangement: Arrangement;
hints: boolean; // tooltips (title attributes) when resting the pointer on buttons
}
// Window layouts; classic and stacked follow the Mumble desktop client's layouts of the same name
@@ -50,7 +51,8 @@ const defaults: Settings = {
linkPreviews: 'host',
theme: 'dark',
publicListUrl: 'https://publist.mumble.info/v1/list?version=1.5.0',
arrangement: 'side'
arrangement: 'side',
hints: false
};
class SettingsStore {
@@ -58,9 +60,17 @@ class SettingsStore {
servers = $state<SavedServer[]>(loadList('mumh5.servers'));
// Pinned sha256 fingerprints of server certificates, keyed by host:port (trust on first use)
pins = $state<Record<string, string>>(load('mumh5.pins', {}));
// Username to suggest when connecting with an identity, keyed by identity id
identityNames = $state<Record<string, string>>(load('mumh5.identityNames', {}));
// Local nicknames for other users, only visible to us; keyed by certificate hash (or name)
nicknames = $state<Record<string, string>>(load('mumh5.nicknames', {}));
setIdentityName(id: string, name: string) {
if (name.trim()) this.identityNames[id] = name.trim();
else delete this.identityNames[id];
save('mumh5.identityNames', $state.snapshot(this.identityNames));
}
saveSettings() { save('mumh5.settings', $state.snapshot(this.settings)); }
upsertServer(s: SavedServer) {
+3 -3
View File
@@ -1,11 +1,11 @@
import { native } from './native.ts';
import { desktop } from './native.ts';
import { menu, type MenuItem } from './menu.svelte.ts';
// Right-click menu for text fields, messages, links and images. Elements with their own menu
// (users, channels, servers) call preventDefault, so Electron does not report those.
export function startTextMenu(): void {
if (!native) return;
const api = native;
if (!desktop) return;
const api = desktop;
api.onContextMenu(p => {
// Edit commands act on the focused element; keep it focused through the menu
+3 -3
View File
@@ -1,4 +1,4 @@
import { native } from './native.ts';
import { desktop } from './native.ts';
import { ICON_PATHS } from './icons.ts';
import { session, sessions } from './session.svelte.ts';
import { voice, type TransmitMode } from './audio/voice.svelte.ts';
@@ -40,8 +40,8 @@ function drawIcon(look: TrayLook): string {
// Mirrors voice state into the system tray and handles its menu
export function startTray(): void {
if (!native) return;
const api = native;
if (!desktop) return;
const api = desktop;
$effect.root(() => {
$effect(() => {
+3 -1
View File
@@ -25,9 +25,11 @@ class Ui {
wizard = $state<'setup' | 'add' | null>(null);
// Quick audio popover from right-clicking mute (input) or deafen (output)
quickAudio = $state<{ kind: 'input' | 'output'; x: number; y: number } | null>(null);
settingsTab = $state<'voice' | 'sounds' | 'appearance' | 'files' | 'identities'>('voice');
settingsTab = $state<'voice' | 'sounds' | 'appearance' | 'accessibility' | 'files' | 'identities'>('voice');
publicServers = $state(false);
serverInfo = $state(false);
// Set by the toolbar to open the own profile with the description editor
editDescription = $state(false);
// Add-server dialog, optionally prefilled (from the public list)
addServer = $state<{ host?: string; port?: number; label?: string } | null>(null);
// Channel dialog: create a subchannel of `parent`, or edit channel `id`
+153
View File
@@ -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();
+6
View File
@@ -5,7 +5,13 @@ import App from './App.svelte';
import './themes.css';
import { startTray } from './lib/tray.svelte.ts';
import { startTextMenu } from './lib/text-menu.ts';
import { startHints } from './lib/hints.ts';
import { isWeb } from './lib/native.ts';
import { proxyInfo } from './lib/web.svelte.ts';
if (isWeb) proxyInfo.load();
mount(App, { target: document.getElementById('app')! });
startTray();
startTextMenu();
startHints();
+57 -2
View File
@@ -10,6 +10,7 @@
import { store } from '../lib/settings.svelte.ts';
import { drag } from '../lib/drag.svelte.ts';
import { sounds } from '../lib/audio/sounds.svelte.ts';
import { renderIncoming } from '../lib/html.ts';
import type { Channel } from '../core/client.ts';
let { channel, depth = 0, onnavigate }: { channel: Channel; depth?: number; onnavigate: () => void } = $props();
@@ -22,6 +23,39 @@
const unread = $derived(session.unread[`ch:${channel.id}`] ?? 0);
const total = $derived(session.subtreeUserCount(channel.id));
// Resting on a channel that has a description for 1 second opens a quick preview next to its row
const hasDescription = $derived(!!(channel.description || channel.descriptionHash));
const descriptionHtml = $derived(channel.description ? renderIncoming(channel.description, store.trustedHosts).html : '');
let preview = $state<{ x: number; y: number } | null>(null);
let hoverTimer: ReturnType<typeof setTimeout> | undefined;
// Where the pointer is while it rests on the row; the preview opens there
let pointer = { x: 0, y: 0 };
function trackPointer(e: MouseEvent) {
pointer = { x: e.clientX, y: e.clientY };
}
function hoverRow(e: MouseEvent) {
if (!hasDescription) return;
trackPointer(e);
clearTimeout(hoverTimer);
hoverTimer = setTimeout(() => {
// Long descriptions are only announced by hash; the text is fetched now
session.loadDescription(channel.id);
// Just below and right of the pointer, kept inside the window
preview = { x: Math.max(8, Math.min(pointer.x + 12, window.innerWidth - 348)), y: Math.max(8, Math.min(pointer.y + 12, window.innerHeight - 340)) };
}, 1000);
}
// Short grace period, so the pointer can move from the row into the preview
function leaveRow() {
clearTimeout(hoverTimer);
hoverTimer = setTimeout(() => (preview = null), 250);
}
function showDescription() {
clearTimeout(hoverTimer);
preview = null;
session.showChannel(channel.id, 'info');
ui.panelOpen = true;
}
// Single click previews, double click acts. The single action waits briefly so a
// double click does not also flash the preview.
let timer: ReturnType<typeof setTimeout> | undefined;
@@ -69,7 +103,8 @@
oncontextmenu={e => menu.show(e, channelMenu(channel))} role="presentation"
ondragover={e => { if (canDrop) { e.preventDefault(); over = true; } }}
ondragleave={() => (over = false)}
ondrop={onDrop}>
ondrop={onDrop}
onmouseenter={hoverRow} onmousemove={trackPointer} onmouseleave={leaveRow}>
{#if children.length || users.length}
<button class="caret" class:collapsed={!expanded} onclick={() => session.toggleExpanded(channel.id)}
aria-label={expanded ? 'Collapse' : 'Expand'} aria-expanded={expanded}>
@@ -81,14 +116,25 @@
<!-- The name is what people grab; a drag cannot start from a button inside a draggable element -->
<button class="name" title="Click for details, double-click to join" {...channelClicks}
draggable={channel.id !== 0}
ondragstart={e => { drag.item = { kind: 'channel', id: channel.id }; e.dataTransfer?.setData('text/plain', channel.name); }}
ondragstart={e => { clearTimeout(hoverTimer); preview = null; drag.item = { kind: 'channel', id: channel.id }; e.dataTransfer?.setData('text/plain', channel.name); }}
ondragend={() => { drag.item = null; over = false; }}>
{#if !channel.canEnter}<Icon name="lock" size={14} />{/if}
<span class="label">{channel.name || 'Root'}</span>
{#if !expanded && total > 0}<span class="count" title="{total} {total === 1 ? 'person' : 'people'} inside">{total}</span>{/if}
{#if unread}<span class="badge">{unread}</span>{/if}
</button>
{#if hasDescription}
<button class="desc" title="Show description" aria-label="Show description of {channel.name || 'Root'}"
onclick={showDescription}><span></span></button>
{/if}
</div>
{#if preview}
<div class="desc-pop" role="tooltip" style:left="{preview.x}px" style:top="{preview.y}px"
onmouseenter={() => clearTimeout(hoverTimer)} onmouseleave={leaveRow}>
<strong>{channel.name || 'Root'}</strong>
{#if descriptionHtml}<div class="html">{@html descriptionHtml}</div>{:else}<p>Loading description...</p>{/if}
</div>
{/if}
{#if users.length && expanded}
<ul class="users">
@@ -144,6 +190,15 @@
.caret.collapsed svg { transform: rotate(-90deg); }
.name { flex: 1; min-width: 0; display: flex; align-items: center; gap: 6px; padding: 4px 0; text-align: left; color: inherit; user-select: none; }
.label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.desc { flex: none; width: 20px; height: 20px; display: inline-flex; align-items: center; justify-content: center; }
.desc span { width: 9px; height: 9px; background: var(--warn); }
.desc:hover span { outline: 2px solid var(--warn); outline-offset: 1px; }
.desc-pop { position: fixed; z-index: 40; width: 340px; max-width: calc(100vw - 16px); max-height: 320px; overflow: auto; padding: 10px 12px;
background: var(--bg-2); border: 1px solid var(--line); color: var(--text); font-size: 14px; box-shadow: 0 8px 32px rgb(0 0 0 / 0.4); }
.desc-pop strong { display: block; margin-bottom: 6px; }
.desc-pop p { margin: 0; color: var(--text-dim); }
.desc-pop .html { overflow-wrap: anywhere; user-select: text; }
.desc-pop .html :global(img) { max-width: 100%; }
.count { font-size: 11px; color: var(--text-faint); border: 1px solid var(--line); padding: 0 5px; line-height: 16px; }
.badge { background: var(--danger); color: #fff; font-size: 11px; font-weight: 700; padding: 0 5px; line-height: 16px; }
.user { gap: 8px; text-align: left; font-size: 14px; user-select: none; }
+47 -8
View File
@@ -4,6 +4,8 @@
import { sessions } from '../lib/session.svelte.ts';
import { identities } from '../lib/identities.svelte.ts';
import { makeServerIcon } from '../lib/upload.ts';
import { isWeb } from '../lib/native.ts';
import { proxyInfo } from '../lib/web.svelte.ts';
// prefill: values for a new server, e.g. picked from the public list
let { server, prefill, onclose }: { server: SavedServer | null; prefill?: { host?: string; port?: number; label?: string }; onclose: () => void } = $props();
@@ -15,14 +17,41 @@
let label = $state(initial?.label ?? pre.label ?? '');
let host = $state(initial?.host ?? pre.host ?? '');
let port = $state(initial?.port ?? pre.port ?? 64738);
// New servers start with the name you used last
let username = $state(initial?.username ?? store.servers.at(-1)?.username ?? '');
let password = $state(initial?.password ?? '');
let identityId = $state(initial?.identityId ?? '');
// The name chosen for an identity when it was created, if any
const identityName = (id: string) => store.identityNames[identities.byId(id || undefined)?.id ?? ''] ?? '';
// New servers start with the identity's name, else the name you used last
const firstName = initial?.username ?? (identityName('') || store.servers.at(-1)?.username || '');
let username = $state(firstName);
// Picking another identity swaps the suggestion, unless the name was typed by hand
let suggested = firstName;
function identityChanged() {
const next = identityName(identityId);
if (next && (!username.trim() || username === suggested)) username = suggested = next;
}
let icon = $state(initial?.icon ?? '');
let iconError = $state('');
let iconPicker: HTMLInputElement;
// In the browser the proxy decides which servers can be reached; offer those instead of free text
const listed = $derived(isWeb && !proxyInfo.any ? proxyInfo.servers : null);
const pick = $derived(listed ? `${host}:${port}` : '');
function choose(value: string) {
const s = listed?.find(x => `${x.host}:${x.port}` === value);
if (!s) return;
// Keep a label the user typed; replace one that came from the previous choice
if (!label.trim() || listed?.some(x => x.label === label)) label = s.label;
host = s.host;
port = s.port;
}
// Refreshed per dialog, so a changed allowlist shows up without a reload
if (isWeb) proxyInfo.load();
// A new server starts on the first one the proxy offers
$effect(() => {
if (!initial && listed?.length && !listed.some(x => x.host === host && x.port === port)) choose(`${listed[0].host}:${listed[0].port}`);
});
async function pickIcon() {
const file = iconPicker.files?.[0];
iconPicker.value = '';
@@ -62,18 +91,27 @@
<Modal title={initial ? 'Edit server' : 'Add a server'} {onclose}>
<form id="connect" onsubmit={saveAndConnect}>
<label for="c-host">Address</label>
<div class="row">
<input id="c-host" bind:value={host} placeholder="mumble.example.com" autocomplete="off" required />
<input class="port" type="number" bind:value={port} min="1" max="65535" aria-label="Port" />
</div>
{#if listed}
<label for="c-host">Server</label>
<select id="c-host" value={pick} onchange={e => choose(e.currentTarget.value)}>
{#each listed as s (`${s.host}:${s.port}`)}<option value={`${s.host}:${s.port}`}>{s.label === s.host ? '' : `${s.label} - `}{s.host}{s.port !== 64738 ? `:${s.port}` : ''}</option>{/each}
</select>
<p class="note">This proxy connects to the servers listed here.</p>
{:else}
<label for="c-host">Address</label>
<div class="row">
<input id="c-host" bind:value={host} placeholder="mumble.example.com" autocomplete="off" required />
<input class="port" type="number" bind:value={port} min="1" max="65535" aria-label="Port" />
</div>
{/if}
{#if isWeb && proxyInfo.error}<p class="err">{proxyInfo.error}</p>{/if}
<label for="c-user">Username</label>
<input id="c-user" bind:value={username} autocomplete="username" required />
<label for="c-pass">Password <span class="opt">(optional)</span></label>
<input id="c-pass" type="password" bind:value={password} autocomplete="current-password" />
{#if identities.list.length > 1}
<label for="c-id">Identity</label>
<select id="c-id" bind:value={identityId}>
<select id="c-id" bind:value={identityId} onchange={identityChanged}>
<option value="">Default ({identities.default?.name})</option>
{#each identities.list as i (i.id)}<option value={i.id}>{i.name}</option>{/each}
</select>
@@ -104,6 +142,7 @@
.icon-row { display: flex; align-items: center; gap: 8px; }
.preview { width: 44px; height: 44px; flex: none; display: flex; align-items: center; justify-content: center; background: var(--bg-3); font-weight: 700; overflow: hidden; }
.preview img { width: 100%; height: 100%; object-fit: cover; display: block; }
.note { margin: 6px 0 0; font-size: 13px; color: var(--text-dim); }
.err { color: var(--danger); font-size: 13px; margin: 6px 0 0; }
.opt { text-transform: none; font-weight: 400; letter-spacing: 0; color: var(--text-faint); }
</style>
+2 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { native, type IdentitySummary } from '../lib/native.ts';
import { native, isWeb, type IdentitySummary } from '../lib/native.ts';
import { identities } from '../lib/identities.svelte.ts';
import { store } from '../lib/settings.svelte.ts';
import { ui } from '../lib/ui.svelte.ts';
@@ -20,7 +20,7 @@
function exportId(i: IdentitySummary) {
ui.prompt = {
title: `Back up ${i.name}`, label: 'Password for the backup file', confirm: 'Choose location...', input: 'password',
title: `Back up ${i.name}`, label: 'Password for the backup file', confirm: isWeb ? 'Download' : 'Choose location...', input: 'password',
text: 'The .p12 file contains your private key. Protect it with a password; the desktop Mumble client can import it.',
onsubmit: pw => act(async () => {
const path = await native!.identities.exportP12(i.id, pw);
+15 -4
View File
@@ -1,7 +1,8 @@
<script lang="ts">
import Modal from './Modal.svelte';
import Icon from './Icon.svelte';
import { native, type IdentitySummary } from '../lib/native.ts';
import { native, isWeb, type IdentitySummary } from '../lib/native.ts';
import { store } from '../lib/settings.svelte.ts';
import { identities } from '../lib/identities.svelte.ts';
// setup: first start (marks setup as done at the end); add: adding another identity from Settings
@@ -44,9 +45,15 @@
}
}
// Suggest the identity's name as the username in the connect dialog
let useAsUsername = $state(true);
const create = (e: SubmitEvent) => {
e.preventDefault();
run(() => native!.identities.create(name.trim(), email.trim()), 'backup');
run(async () => {
const created = await native!.identities.create(name.trim(), email.trim());
if (useAsUsername) store.setIdentityName(created.id, name);
return created;
}, 'backup');
};
const importFile = async (e: SubmitEvent) => {
@@ -93,6 +100,7 @@
<Modal {title} onclose={mode === 'setup' ? () => {} : onclose} width={560}>
{#if step === 'welcome'}
<p>Mumble recognizes you by a <strong>certificate</strong>, not by a password. Registered names, admin rights and friends lists on servers are tied to it, so it is worth setting up once and keeping a backup.</p>
{#if isWeb}<p class="dim">In the browser the certificate is kept in this browser's storage and passed to the proxy at {location.host} when you connect, which presents it to the Mumble server.</p>{/if}
<div class="choices">
<button class="choice" onclick={() => (step = 'create')}>
<Icon name="plus" /><span><strong>Create a new identity</strong><small>Start fresh with a new certificate</small></span>
@@ -116,7 +124,8 @@
<p>The name is stored in the certificate and helps you tell identities apart. Servers show the username you enter when connecting, not this.</p>
<label for="w-name">Name</label>
<!-- svelte-ignore a11y_autofocus -->
<input id="w-name" bind:value={name} required autofocus placeholder="e.g. kibi" />
<input id="w-name" bind:value={name} required autofocus placeholder="e.g. your nickname" />
<label class="check"><input type="checkbox" bind:checked={useAsUsername} /> Use this name as my username when connecting with this identity</label>
<label for="w-email">Email <span class="opt">(optional, stored in the certificate)</span></label>
<input id="w-email" type="email" bind:value={email} />
</form>
@@ -130,7 +139,7 @@
{:else if step === 'backup' && result}
<p>Identity <strong>{result.name}</strong> is ready.</p>
<p class="mono">{result.fingerprint}</p>
<p>If this computer dies, the certificate goes with it, and so do your registrations. Save a password-protected backup now; the desktop Mumble client can import it too.</p>
<p>{isWeb ? 'If this browser\'s data is cleared' : 'If this computer dies'}, the certificate goes with it, and so do your registrations. Save a password-protected backup now; the desktop Mumble client can import it too.</p>
<label for="w-bpass">Backup password <span class="opt">(at least 8 characters)</span></label>
<input id="w-bpass" type="password" bind:value={backupPassword} autocomplete="new-password" />
<label for="w-bconf">Repeat password</label>
@@ -163,6 +172,8 @@
</Modal>
<style>
.check { display: flex; align-items: center; gap: 8px; text-transform: none; font-weight: 400; letter-spacing: 0; font-size: 14px; color: var(--text); margin-top: 14px; }
.check input { width: auto; }
p { color: var(--text-dim); margin: 10px 0 0; }
p strong { color: var(--text); }
.choices { display: flex; flex-direction: column; gap: 8px; margin-top: 16px; }
+4 -3
View File
@@ -2,10 +2,11 @@
import { onDestroy } from 'svelte';
import Modal from './Modal.svelte';
import Icon from './Icon.svelte';
import { native, type PublicServer, type PingResult } from '../lib/native.ts';
import { desktop as native, type PublicServer, type PingResult } from '../lib/native.ts';
import { store } from '../lib/settings.svelte.ts';
import { ui } from '../lib/ui.svelte.ts';
import { sessions } from '../lib/session.svelte.ts';
import { identities } from '../lib/identities.svelte.ts';
let { onclose }: { onclose: () => void } = $props();
@@ -95,11 +96,11 @@
});
}
// Connects right away with the name used last; the server is saved so it shows in the rail.
// Connects right away with the name used last (or the default identity's name); the server is saved so it shows in the rail.
// Without a name to reuse, the add dialog asks for one.
function connect(s: PublicServer) {
const saved = store.servers.find(x => x.host === s.host && x.port === s.port);
const username = store.servers.at(-1)?.username;
const username = store.servers.at(-1)?.username || store.identityNames[identities.default?.id ?? ''];
if (!saved && !username) return add(s);
const server = saved ?? { id: crypto.randomUUID(), label: s.name || s.host, host: s.host, port: s.port, username: username!, password: '' };
if (!saved) store.upsertServer(server);
+8
View File
@@ -8,6 +8,7 @@
import { session } from '../lib/session.svelte.ts';
import { store } from '../lib/settings.svelte.ts';
import { menu } from '../lib/menu.svelte.ts';
import { ui } from '../lib/ui.svelte.ts';
import { userMenu, changeNamePrompt } from '../lib/actions.ts';
import { voice } from '../lib/audio/voice.svelte.ts';
import { renderIncoming } from '../lib/html.ts';
@@ -45,6 +46,13 @@
editing = true;
}
// After the reset above, so a request from the toolbar survives the panel switching to the profile
$effect(() => {
if (!ui.editDescription || !isSelf) return;
ui.editDescription = false;
startEdit();
});
function saveComment() {
session.setComment(draft);
editing = false;
+3 -1
View File
@@ -3,6 +3,7 @@
import { store, type SavedServer } from '../lib/settings.svelte.ts';
import { sessions } from '../lib/session.svelte.ts';
import { menu } from '../lib/menu.svelte.ts';
import { desktop } from '../lib/native.ts';
import { makeServerIcon } from '../lib/upload.ts';
let { onadd, onedit, onsettings, onbrowse }:
@@ -88,7 +89,8 @@
{#if dropIndex === store.servers.length && dragging}<div class="drop-line"></div>{/if}
<input bind:this={iconPicker} type="file" accept="image/*" hidden onchange={pickIcon} />
<button class="tile add" title="Add a server" onclick={onadd}><Icon name="plus" /></button>
<button class="tile add" title="Browse public servers" onclick={onbrowse}><Icon name="globe" /></button>
<!-- The public list and its pings need the desktop app -->
{#if desktop}<button class="tile add" title="Browse public servers" onclick={onbrowse}><Icon name="globe" /></button>{/if}
<div class="spacer"></div>
<button class="tile ghost" title="Settings" onclick={onsettings}><Icon name="settings" /></button>
</nav>
+10 -6
View File
@@ -5,14 +5,14 @@
import AppearanceSettings from './AppearanceSettings.svelte';
import SoundSettings from './SoundSettings.svelte';
import { store } from '../lib/settings.svelte.ts';
import { native } from '../lib/native.ts';
import { native, desktop, isWeb } from '../lib/native.ts';
import { fetchUploadInfo } from '../lib/upload.ts';
import { previewMode } from '../lib/preview.ts';
let { onclose, tab: initialTab = 'voice' }: { onclose: () => void; tab?: 'voice' | 'sounds' | 'appearance' | 'files' | 'identities' } = $props();
let { onclose, tab: initialTab = 'voice' }: { onclose: () => void; tab?: 'voice' | 'sounds' | 'appearance' | 'accessibility' | 'files' | 'identities' } = $props();
// svelte-ignore state_referenced_locally
let tab = $state<'voice' | 'sounds' | 'appearance' | 'files' | 'identities'>(initialTab);
let tab = $state<'voice' | 'sounds' | 'appearance' | 'accessibility' | 'files' | 'identities'>(initialTab);
const s = store.settings;
let embedHosts = $state(s.embedHosts.join('\n'));
let testResult = $state('');
@@ -39,11 +39,12 @@
}
}
type Tab = 'voice' | 'sounds' | 'appearance' | 'files' | 'identities';
type Tab = 'voice' | 'sounds' | 'appearance' | 'accessibility' | 'files' | 'identities';
const tabs: { id: Tab; label: string }[] = [
{ id: 'voice', label: 'Voice' },
{ id: 'sounds', label: 'Sounds' },
{ id: 'appearance', label: 'Appearance' },
{ id: 'accessibility', label: 'Accessibility' },
{ id: 'files', label: 'Chat and files' },
...(native ? [{ id: 'identities' as Tab, label: 'Identities' }] : [])
];
@@ -62,6 +63,9 @@
<SoundSettings />
{:else if tab === 'appearance'}
<AppearanceSettings />
{:else if tab === 'accessibility'}
<label class="check"><input type="checkbox" bind:checked={s.hints} onchange={() => store.saveSettings()} /> Show hints when the pointer rests on buttons and icons</label>
<p class="help">Hints are the small labels that appear on hover. Screen readers get the names either way.</p>
{:else if tab === 'files'}
<p class="help">Files go to a f0ckm instance and are posted as links. Other mumh5 users see them inline; regular Mumble users get a link and a small preview. Without an upload host you can still send images, inline and scaled to the server's limit.</p>
<label for="s-host">Upload host</label>
@@ -91,7 +95,7 @@
<!-- Shows the mode in effect; a stored "upload host" choice comes back once the key is set -->
<select id="s-lp" value={previewMode()} onchange={e => { s.linkPreviews = e.currentTarget.value as typeof s.linkPreviews; saveFiles(); }}>
<option value="host" disabled={!hasUpload}>Fetched by the upload host (keeps your IP private){hasUpload ? '' : ' - needs an upload host and key'}</option>
<option value="direct">Fetched by this computer (linked sites see your IP)</option>
<option value="direct" disabled={!desktop}>Fetched by this computer (linked sites see your IP){desktop ? '' : ' - needs the desktop app'}</option>
<option value="off">Off</option>
</select>
<label for="s-yt">YouTube links</label>
@@ -101,7 +105,7 @@
<option value="thumbnail">Player card with thumbnail (loads the thumbnail from YouTube)</option>
</select>
{:else}
<p class="help">Servers recognize you by your certificate. The default is used unless a server is set to another one.</p>
<p class="help">Servers recognize you by your certificate. The default is used unless a server is set to another one.{isWeb ? ' Certificates are kept in this browser and passed to the proxy when you connect.' : ''}</p>
<IdentityList />
{/if}
+35 -3
View File
@@ -43,6 +43,20 @@
]);
}
function editDescription() {
if (!self) return;
session.showUser(self.session);
ui.editDescription = true;
ui.panelOpen = true;
}
// One button for the whole tree: open everything, then close everything
let allOpen = $state(false);
function toggleAll() {
allOpen = !allOpen;
session.setAllExpanded(allOpen);
}
function openQuick(e: MouseEvent, kind: 'input' | 'output') {
e.preventDefault();
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
@@ -130,6 +144,7 @@
<span>{deafened ? 'Deafened' : muted ? 'Muted' : 'Microphone on'}</span>
</span>
{/if}
<div class="tools">
<button class="icon-btn info" title="Server information" aria-label="Server information"
disabled={session.status !== 'connected'} onclick={() => (ui.serverInfo = true)}>
<Icon name="info" size={18} />
@@ -144,6 +159,20 @@
onclick={() => session.setSelfDeaf(!deafened)} oncontextmenu={e => openQuick(e, 'output')}>
<Icon name={deafened ? 'headphones-off' : 'headphones'} size={18} />
</button>
<span class="sep"></span>
<button class="icon-btn" title="Change your description" aria-label="Change your description" disabled={!self} onclick={editDescription}>
<Icon name="message" size={18} />
</button>
<span class="sep"></span>
<button class="icon-btn" title="Configure" aria-label="Configure" onclick={() => (ui.settingsOpen = true)}>
<Icon name="settings" size={18} />
</button>
<span class="sep"></span>
<button class="icon-btn" title={allOpen ? 'Collapse all channels' : 'Expand all channels'} aria-label={allOpen ? 'Collapse all channels' : 'Expand all channels'}
disabled={session.status !== 'connected'} onclick={toggleAll}>
<Icon name="filter" size={18} />
</button>
</div>
</footer>
</aside>
@@ -174,15 +203,18 @@
.elsewhere { flex: none; display: flex; align-items: center; gap: 8px; padding: 8px 10px; font-size: 12px; color: var(--text-dim); background: var(--bg-0); border-top: 1px solid var(--line); }
.elsewhere span { flex: 1; min-width: 0; }
.elsewhere .btn { padding: 4px 10px; font-size: 12px; }
.me { flex: none; height: var(--footer-h); display: flex; align-items: center; gap: 8px; padding: 0 8px; background: var(--bg-0); }
/* One row when the sidebar is wide enough, otherwise the buttons go under the profile */
.me { flex: none; min-height: var(--footer-h); display: flex; flex-wrap: wrap; align-items: center; gap: 2px 8px; padding: 4px 8px; background: var(--bg-0); }
.tools { flex: none; max-width: 100%; display: flex; flex-wrap: wrap; align-items: center; gap: 2px; }
.sep { width: 1px; height: 20px; margin: 0 4px; background: var(--line); }
/* Stacked layout: the bar leads the stack, like the desktop client's toolbar */
.sidebar.fill .me { order: -1; border-bottom: 1px solid var(--line); }
.sidebar.fill .me .profile, .sidebar.fill .me .offline-who { display: none; }
.profile { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; padding: 2px; text-align: left; }
.profile { flex: 1 1 110px; min-width: 0; display: flex; align-items: center; gap: 8px; padding: 2px; text-align: left; }
.profile:hover { background: var(--bg-hover); }
.who { flex: 1; min-width: 0; display: flex; flex-direction: column; line-height: 1.2; }
.who strong, .who span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.who span { font-size: 12px; color: var(--text-dim); }
.info, .info:hover { color: var(--warn); }
.offline-who { padding: 2px 2px 2px 6px; color: var(--text-dim); }
.offline-who { flex: 1 1 110px; padding: 2px 2px 2px 6px; color: var(--text-dim); }
</style>
+12 -1
View File
@@ -23,6 +23,11 @@
const client = session.client;
const user = $derived(session.user(who));
const isSelf = who === session.self?.session;
// Browser build: our address as the web proxy saw it
// A proxy on this same machine connects from our own address, so what the server sees is ours
const viaProxy = session.serverCert?.clientAddress ?? '';
const ownAddress = /^(127\.|::1$|localhost$)/.test(viaProxy) ? '' : viaProxy;
const off = client?.on('userStats', msg => {
if (msg.session !== who) return;
@@ -84,7 +89,13 @@
<dt>Ping (UDP)</dt><dd>{stats.udp_packets ? ping(stats.udp_ping_avg, stats.udp_ping_var) : 'n/a'}</dd>
<dt>Pings received</dt><dd>{stats.tcp_packets ?? 0} over TCP, {stats.udp_packets ?? 0} over UDP</dd>
{#if stats.bandwidth != null}<dt>Bandwidth</dt><dd>{formatBytes(stats.bandwidth)}/s</dd>{/if}
{#if stats.address?.length}<dt>Address</dt><dd class="mono">{address(stats.address)}</dd>{/if}
{#if isSelf && ownAddress}
<!-- Through the web proxy the server only knows the proxy's address -->
<dt>Address</dt><dd class="mono">{ownAddress}</dd>
{#if stats.address?.length && address(stats.address) !== ownAddress}<dt>Seen by the server</dt><dd><span class="mono">{address(stats.address)}</span> (the web proxy)</dd>{/if}
{:else if stats.address?.length}
<dt>Address</dt><dd class="mono">{address(stats.address)}</dd>
{/if}
</dl>
{#if stats.version}