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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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,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,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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user