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>
149 lines
7.1 KiB
Svelte
149 lines
7.1 KiB
Svelte
<script lang="ts">
|
|
import Modal from './Modal.svelte';
|
|
import { store, type SavedServer } from '../lib/settings.svelte.ts';
|
|
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();
|
|
|
|
// svelte-ignore state_referenced_locally
|
|
const initial = server;
|
|
// svelte-ignore state_referenced_locally
|
|
const pre = prefill ?? {};
|
|
let label = $state(initial?.label ?? pre.label ?? '');
|
|
let host = $state(initial?.host ?? pre.host ?? '');
|
|
let port = $state(initial?.port ?? pre.port ?? 64738);
|
|
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 = '';
|
|
if (!file) return;
|
|
iconError = '';
|
|
try { icon = await makeServerIcon(file); } catch { iconError = 'Could not read this image.'; }
|
|
}
|
|
|
|
const valid = $derived(host.trim() && username.trim() && port > 0 && port < 65536);
|
|
|
|
function build(): SavedServer {
|
|
// Accept "host:port" pasted into the host field
|
|
let h = host.trim(), p = Number(port);
|
|
const m = /^(.+):(\d+)$/.exec(h);
|
|
if (m && !h.includes(']')) { h = m[1]; p = Number(m[2]); }
|
|
return { id: initial?.id ?? crypto.randomUUID(), label: label.trim() || h, host: h, port: p, username: username.trim(), password, identityId: identityId || undefined, icon: icon || undefined };
|
|
}
|
|
|
|
function saveAndConnect(e: SubmitEvent) {
|
|
e.preventDefault();
|
|
if (!valid) return;
|
|
const s = build();
|
|
store.upsertServer(s);
|
|
// Reconnect with the new settings if it was connected
|
|
if (sessions.byServer(s.id)) sessions.close(s.id);
|
|
sessions.open(s);
|
|
onclose();
|
|
}
|
|
|
|
function remove() {
|
|
if (!initial) return;
|
|
sessions.close(initial.id);
|
|
store.removeServer(initial.id);
|
|
onclose();
|
|
}
|
|
</script>
|
|
|
|
<Modal title={initial ? 'Edit server' : 'Add a server'} {onclose}>
|
|
<form id="connect" onsubmit={saveAndConnect}>
|
|
{#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} 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>
|
|
{/if}
|
|
<label for="c-label">Label <span class="opt">(optional)</span></label>
|
|
<input id="c-label" bind:value={label} placeholder={host || 'My server'} />
|
|
<label for="c-icon">Icon <span class="opt">(optional)</span></label>
|
|
<div class="icon-row">
|
|
<span class="preview">
|
|
{#if icon}<img src={icon} alt="" />{:else}{(label || host || '?').trim().slice(0, 2).toUpperCase()}{/if}
|
|
</span>
|
|
<button id="c-icon" type="button" class="btn" onclick={() => iconPicker.click()}>{icon ? 'Change image' : 'Choose image'}</button>
|
|
{#if icon}<button type="button" class="btn" onclick={() => (icon = '')}>Remove</button>{/if}
|
|
<input bind:this={iconPicker} type="file" accept="image/*" hidden onchange={pickIcon} />
|
|
</div>
|
|
{#if iconError}<p class="err">{iconError}</p>{/if}
|
|
</form>
|
|
{#snippet footer()}
|
|
{#if initial}<button class="btn danger" onclick={remove}>Remove</button><span style="flex:1"></span>{/if}
|
|
<button class="btn" onclick={onclose}>Cancel</button>
|
|
<button class="btn primary" type="submit" form="connect" disabled={!valid}>Save and connect</button>
|
|
{/snippet}
|
|
</Modal>
|
|
|
|
<style>
|
|
.row { display: flex; gap: 8px; }
|
|
.port { width: 100px; flex: none; }
|
|
.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>
|