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>
@@ -1,6 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
dist-web/
|
||||
dist-proxy/
|
||||
dist-electron/
|
||||
release/
|
||||
*.log
|
||||
|
||||
@@ -6,6 +6,7 @@ All notable changes to mumh5. Versions follow the `version` in `package.json`.
|
||||
|
||||
### Added
|
||||
|
||||
- Browser version: `npm run build:web` builds the web app and a small self-hosted proxy that bridges browsers to Mumble servers on an allowlist. Identities are kept in the browser, voice goes through the TCP tunnel. See "Browser version" in the README.
|
||||
- Link previews in chat: title, description and image for web links (up to two per message). By default they are fetched by your f0ckm upload host, so the linked sites never see your IP address. Can be switched to "fetched by this computer" or off in Settings, Chat and files.
|
||||
- Voice tiles on small windows: when the window is too narrow for the member list, the people in your voice channel appear as tiles above the chat, light up while they talk, and keep mute and deafen at hand. The tiles can be collapsed.
|
||||
- Per-person volume slider (0 to 300%) directly in the user context menu. Double-click resets it to 100%.
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
## Project overview
|
||||
|
||||
mumh5 is a Mumble client: Electron desktop app (Windows, macOS, Linux), Svelte 5 + TypeScript + Vite UI, Discord-like layout, responsive down to phone width. It must stay compatible with stock Murmur servers and with people on the desktop Mumble client. A browser build (through a self-hosted WebSocket proxy with a server allowlist) is planned.
|
||||
mumh5 is a Mumble client: Electron desktop app (Windows, macOS, Linux), Svelte 5 + TypeScript + Vite UI, Discord-like layout, responsive down to phone width. It must stay compatible with stock Murmur servers and with people on the desktop Mumble client. There is also a browser build that connects through a self-hosted WebSocket proxy with a server allowlist (`server/`).
|
||||
|
||||
File sharing uploads to a f0ckm instance (`../f0ckm`, `src/chat_upload_handler.mjs` there) with an upload-only key; links are posted in chat.
|
||||
|
||||
@@ -22,6 +22,10 @@ npm test # unit tests; server tests too when MUMBLE_TEST_HOST is s
|
||||
npm run build # renderer to dist/, main+preload to dist-electron/ (esbuild)
|
||||
npm run test:e2e # drives the built app (needs npm run build first)
|
||||
npm run screenshots # regenerates docs/screenshots (see test/e2e/screenshots.ts)
|
||||
npm run build:web # browser build to dist-web/, proxy bundle to dist-proxy/proxy.mjs
|
||||
npm run dev:web # browser build with hot reload: starts the proxy (any server allowed) and Vite
|
||||
npm run proxy # run the proxy from source (MUMH5_SERVERS=host:port required)
|
||||
npm run test:e2e:web # drives the browser build through the proxy (needs npm run build:web first)
|
||||
npm run proto # regenerate src/core/mumble-pb.js and mumble-udp-pb.js from proto/
|
||||
```
|
||||
|
||||
@@ -38,7 +42,9 @@ A reused test server keeps registrations and channels from earlier runs; tests m
|
||||
## Architecture
|
||||
|
||||
- `electron/` (Node, main process): window, TLS sockets to Mumble servers (`tls-transport.ts`), encrypted UDP voice (`udp-voice.ts`, `ocb2.ts`, tested against Mumble's OCB2 vectors), identities and PKCS#12 (`identity.ts`, `identity-store.ts`), certificate parsing (`certs.ts`), tray (`tray.ts`). The renderer only gets the narrow `window.mumh5Native` API from `preload.ts` (context isolation, sandbox).
|
||||
- `server/` (Node): the web proxy. `proxy.ts` serves `dist-web`, bridges WebSocket connections to Mumble over TLS (reusing `electron/tls-transport.ts`) and has stateless identity endpoints. It stores nothing; the browser keeps identities in localStorage and sends one with each connect.
|
||||
- `src/core/` (browser-safe TypeScript, also runs in Node for tests): framing and codec (`proto.ts`), the Mumble client state machine (`client.ts`), voice packet formats (`voice-packet.ts`). No DOM, no Electron, no Node imports here.
|
||||
- `src/lib/native.ts`: `desktop` is the Electron preload API or null; `native` is what both platforms provide (identities, certificates), backed by `web.svelte.ts` in the browser build (`isWeb`, vite `--mode web`). Desktop-only features check `desktop`.
|
||||
- `src/lib/`: app state. `session.svelte.ts` has one `Session` per server plus the `sessions` manager; `session` is a Proxy to the active one. `audio/voice.svelte.ts` is the voice engine (WebCodecs Opus, capture and playback AudioWorklets). `html.ts` sanitizes incoming HTML and serializes outgoing rich text.
|
||||
- `src/ui/`: Svelte components. `App.svelte` owns layout and global dialogs (`ui.svelte.ts` store).
|
||||
|
||||
|
||||
@@ -147,6 +147,81 @@ Installers are written to `release/`. The Windows installer also builds on Linux
|
||||
|
||||
---
|
||||
|
||||
## Browser version
|
||||
|
||||
mumh5 also runs in a browser. Browsers cannot open the TLS connection Mumble uses or present a client certificate, so a small proxy you host yourself does that part: it serves the web app and bridges each browser connection to a Mumble server.
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm run build:web # web app to dist-web/, proxy to dist-proxy/proxy.mjs
|
||||
MUMH5_SERVERS="mumble.example.com=My server" node dist-proxy/proxy.mjs
|
||||
```
|
||||
|
||||
Then open `http://127.0.0.1:8080`. For development, `npm run dev:web` starts the proxy and a hot-reloading page together, with any server allowed. To deploy, copy `dist-web/` and `dist-proxy/` next to each other on the server (Node 22 or newer, no `node_modules` needed) and put a reverse proxy with HTTPS in front that forwards WebSocket upgrades. Browsers only allow the microphone on HTTPS pages (or on localhost).
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `MUMH5_SERVERS` | none | Mumble servers people may connect to: `host[:port][=Label]`, comma-separated. Required unless `MUMH5_ALLOW_ANY=1` |
|
||||
| `MUMH5_ALLOW_ANY` | off | Allow any server on the public internet. Private and loopback addresses stay blocked, except servers listed in `MUMH5_SERVERS` (for a Mumble server on the same machine: `MUMH5_SERVERS=localhost`), or all of them with `MUMH5_ALLOW_PRIVATE=1` |
|
||||
| `MUMH5_PORT`, `MUMH5_BIND` | `8080`, `127.0.0.1` | Where the proxy listens |
|
||||
| `MUMH5_ORIGINS` | same host | Origins allowed to use the API, comma-separated, when the page is hosted elsewhere |
|
||||
| `MUMH5_TRUST_PROXY` | off | Take client addresses from `X-Forwarded-For` (set this behind a reverse proxy) |
|
||||
| `MUMH5_SEND_PROXY` | off | Announce each visitor's address to the server with the PROXY protocol (see below). Breaks connections to a plain Mumble server |
|
||||
| `MUMH5_STATIC` | `../dist-web` | Folder with the web build |
|
||||
| `MUMH5_MAX_CONNECTIONS`, `MUMH5_MAX_PER_ADDRESS` | `200`, `8` | Connection limits, in total and per client address |
|
||||
|
||||
Example for nginx. `Host` must be passed on unchanged, because the proxy only accepts requests whose origin matches it. `X-Forwarded-For` carries the visitor's address; start the proxy with `MUMH5_TRUST_PROXY=1` so its per-address limits use it.
|
||||
|
||||
```nginx
|
||||
map $http_upgrade $connection_upgrade { default upgrade; '' close; }
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name voice.example.com;
|
||||
# ssl_certificate and ssl_certificate_key go here
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_read_timeout 1h;
|
||||
proxy_send_timeout 1h;
|
||||
proxy_buffering off;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
By default the Mumble server sees every browser user coming from the proxy's address, and that is the address other people and admins get in a user's information. No nginx setting changes that. Bans by IP on the Mumble server would then hit all users of the proxy; ban by certificate instead. In your own information mumh5 shows the address the proxy saw for you, next to the one the server sees.
|
||||
|
||||
#### Showing the Mumble server the visitor's real address
|
||||
|
||||
Mumble does not understand the PROXY protocol ([mumble#4769](https://github.com/mumble-voip/mumble/issues/4769)), so the only way is to make the connection to it really come from the visitor's address. [go-mmproxy](https://github.com/path-network/go-mmproxy) does this on Linux: it accepts a connection that starts with a PROXY line and opens the connection to the server with that address as the source. It has to run on the same machine as the Mumble server, as root or with `CAP_NET_ADMIN`, with routing rules that send the server's replies back to it:
|
||||
|
||||
```bash
|
||||
# on the Mumble server's machine
|
||||
ip rule add from 127.0.0.1/8 iif lo table 123
|
||||
ip route add local 0.0.0.0/0 dev lo table 123
|
||||
ip -6 rule add from ::1/128 iif lo table 123
|
||||
ip -6 route add local ::/0 dev lo table 123
|
||||
go-mmproxy -l 127.0.0.1:64750 -4 127.0.0.1:64738 -6 "[::1]:64738"
|
||||
|
||||
# the mumh5 proxy then connects to go-mmproxy instead of Mumble
|
||||
MUMH5_SEND_PROXY=1 MUMH5_TRUST_PROXY=1 MUMH5_SERVERS="127.0.0.1:64750=My server" node dist-proxy/proxy.mjs
|
||||
```
|
||||
|
||||
The routing rules and options are go-mmproxy's; check its README for your system. mumh5's side (sending the PROXY line) is covered by tests, the go-mmproxy setup itself has not been tested with mumh5. People on the desktop client keep connecting to Mumble directly.
|
||||
|
||||
What is different from the desktop app:
|
||||
|
||||
- **The proxy is trusted.** Your certificate and its private key are kept in the browser's storage and sent to the proxy on every connect, and everything you send, including the server password, passes through it. Whoever runs the proxy could act as you. Use a proxy you run yourself or trust.
|
||||
- **Voice goes over TCP.** Browsers have no UDP sockets, so voice uses Mumble's TCP tunnel through the proxy. It works, with more delay on lossy connections.
|
||||
- **Clearing the browser's data removes your identity.** Download a backup; the same `.p12` file works in the desktop app and in Mumble.
|
||||
- **Not available:** the public server browser, link previews fetched by your own computer, the tray icon, importing the desktop Mumble certificate automatically.
|
||||
- Voice needs a browser with WebCodecs audio. It is tested in Chromium only.
|
||||
|
||||
## File sharing with f0ckm
|
||||
|
||||
Mumble can only carry small inline images. mumh5 can share any file by uploading it to a f0ckm instance (a self-hosted imageboard by the same author) and posting the link. Other mumh5 users get inline players; desktop Mumble users get a normal link plus a small preview.
|
||||
@@ -167,7 +242,7 @@ Without an upload host, mumh5 still sends images, scaled to fit the server's lim
|
||||
|
||||
## Privacy and security
|
||||
|
||||
- **Your identity is a certificate** that stays on your computer, stored with owner-only permissions. mumh5 reminds you to back it up.
|
||||
- **Your identity is a certificate** that stays on your computer, stored with owner-only permissions. mumh5 reminds you to back it up. (The browser version is different, see above.)
|
||||
- **Server certificates are pinned** the first time you connect. If one changes, mumh5 stops before sending your password and asks you.
|
||||
- **Chats don't phone home.** Media is only loaded from your upload host and hosts you add yourself. YouTube players are off by default and click-to-play in privacy mode when enabled.
|
||||
- **Strict content security policy** in the app, and all message HTML is sanitized.
|
||||
@@ -179,8 +254,7 @@ Without an upload host, mumh5 still sends images, scaled to fit the server's lim
|
||||
- System-wide push to talk
|
||||
- Whisper and shout
|
||||
- Rich chat between mumh5 users: replies, reactions, edits, typing indicators
|
||||
- Video and screen sharing between mumh5 users
|
||||
- A browser version, through a small self-hosted WebSocket proxy
|
||||
- Screen sharing between mumh5 users in a channel, with sound (one application or the whole system, also on Linux through PipeWire). Anyone can start a stream; no extra key, your Mumble certificate is your identity. Streams go directly between clients with WebRTC, set up through the Mumble server, with an optional self-hosted relay for many viewers
|
||||
- Release builds for all platforms
|
||||
|
||||
---
|
||||
@@ -201,6 +275,7 @@ docker run -d --name mumh5-test-murmur -p 64739:64738 -p 64739:64738/udp \
|
||||
MUMBLE_TEST_HOST=localhost:64739 npm test
|
||||
npm run build
|
||||
MUMBLE_TEST_HOST=localhost:64739 MUMBLE_SUPERUSER_PASSWORD=testsuper npm run test:e2e
|
||||
npm run build:web && MUMBLE_TEST_HOST=localhost:64739 npm run test:e2e:web # browser build through the proxy
|
||||
```
|
||||
|
||||
On Linux the end-to-end test forces X11, so it can run on a virtual display: `Xvfb :99 & DISPLAY=:99 npm run test:e2e`. See [CLAUDE.md](CLAUDE.md) for the architecture and project conventions.
|
||||
|
||||
|
Before Width: | Height: | Size: 259 KiB After Width: | Height: | Size: 253 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 270 KiB After Width: | Height: | Size: 263 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 259 KiB After Width: | Height: | Size: 254 KiB |
|
Before Width: | Height: | Size: 259 KiB After Width: | Height: | Size: 253 KiB |
|
Before Width: | Height: | Size: 256 KiB After Width: | Height: | Size: 251 KiB |
|
Before Width: | Height: | Size: 286 KiB After Width: | Height: | Size: 287 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 119 KiB |
@@ -1,4 +1,5 @@
|
||||
import tls from 'node:tls';
|
||||
import net from 'node:net';
|
||||
import { describeCert, type CertDetails } from './certs.ts';
|
||||
|
||||
export interface TlsHandlers {
|
||||
@@ -9,13 +10,37 @@ export interface TlsHandlers {
|
||||
|
||||
// Opens a TLS connection to a Mumble server. Most servers use self-signed certificates,
|
||||
// so verification is left to the caller (trust on first use via the sha256 fingerprint).
|
||||
export function openTls(host: string, port: number, cert: string, key: string, h: TlsHandlers) {
|
||||
export interface TlsOptions {
|
||||
// Custom DNS resolution (the web proxy uses it to refuse private addresses)
|
||||
lookup?: net.LookupFunction;
|
||||
// Announce this client address with a PROXY protocol v1 line before TLS starts, for a
|
||||
// receiver such as go-mmproxy that passes it on to a server without PROXY support
|
||||
proxyClient?: string;
|
||||
}
|
||||
|
||||
// PROXY protocol v1 line. Source and destination must be the same family; an IPv4 client is
|
||||
// written as a mapped address towards an IPv6 destination, anything else is announced as unknown.
|
||||
export function proxyLine(client: string, clientPort: number, dest: string, destPort: number): string {
|
||||
const d = dest.replace(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i, '$1');
|
||||
const c = client.replace(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i, '$1');
|
||||
if (net.isIPv4(c) && net.isIPv4(d)) return `PROXY TCP4 ${c} ${d} ${clientPort} ${destPort}\r\n`;
|
||||
if (net.isIPv6(d) && net.isIP(c)) return `PROXY TCP6 ${net.isIPv4(c) ? `::ffff:${c}` : c} ${d} ${clientPort} ${destPort}\r\n`;
|
||||
return 'PROXY UNKNOWN\r\n';
|
||||
}
|
||||
|
||||
export function openTls(host: string, port: number, cert: string, key: string, h: TlsHandlers, opts: TlsOptions = {}) {
|
||||
let closeReason = 'Connection closed';
|
||||
const socket = tls.connect({
|
||||
host, port, cert, key,
|
||||
rejectUnauthorized: false,
|
||||
servername: /^[\d.:]+$/.test(host) ? undefined : host
|
||||
});
|
||||
const servername = /^[\d.:]+$/.test(host) ? undefined : host;
|
||||
let socket: tls.TLSSocket;
|
||||
if (opts.proxyClient) {
|
||||
// The PROXY line goes first, in the clear; TLS then runs over the same connection
|
||||
const raw = net.connect({ host, port, lookup: opts.lookup });
|
||||
raw.once('connect', () => raw.write(proxyLine(opts.proxyClient!, raw.localPort ?? 0, raw.remoteAddress ?? '', raw.remotePort ?? port)));
|
||||
raw.on('error', (e: Error) => { closeReason = e.message; socket.destroy(); });
|
||||
socket = tls.connect({ socket: raw, cert, key, rejectUnauthorized: false, servername });
|
||||
} else {
|
||||
socket = tls.connect({ host, port, cert, key, lookup: opts.lookup, rejectUnauthorized: false, servername });
|
||||
}
|
||||
socket.setNoDelay(true);
|
||||
socket.setKeepAlive(true, 30000);
|
||||
socket.setTimeout(20000, () => {
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"@tsconfig/svelte": "^5.0.8",
|
||||
"@types/node": "^26.6.3",
|
||||
"@types/node-forge": "^1.3.14",
|
||||
"@types/ws": "^8.18.2",
|
||||
"electron": "^44.5.1",
|
||||
"electron-builder": "^26.15.3",
|
||||
"esbuild": "^0.28.2",
|
||||
@@ -26,7 +27,8 @@
|
||||
"svelte": "^5.57.1",
|
||||
"svelte-check": "^4.7.6",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.3.1"
|
||||
"vite": "^8.3.1",
|
||||
"ws": "^8.22.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
@@ -1513,6 +1515,16 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz",
|
||||
"integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@xmldom/xmldom": {
|
||||
"version": "0.8.15",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz",
|
||||
@@ -5736,6 +5748,28 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.22.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
|
||||
"integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xmlbuilder": {
|
||||
"version": "15.1.1",
|
||||
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
"scripts": {
|
||||
"dev": "node scripts/dev.mjs",
|
||||
"build": "vite build && node scripts/build-electron.mjs",
|
||||
"build:web": "vite build --mode web --outDir dist-web",
|
||||
"build:web": "vite build --mode web --outDir dist-web && node scripts/build-proxy.mjs",
|
||||
"dev:web": "node scripts/dev-web.mjs",
|
||||
"proxy": "node server/main.ts",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json",
|
||||
"test": "node --test test/*.test.ts",
|
||||
"dist": "npm run build && electron-builder",
|
||||
@@ -17,6 +19,7 @@
|
||||
"dist:win": "npm run build && electron-builder --win",
|
||||
"dist:mac": "npm run build && electron-builder --mac",
|
||||
"test:e2e": "node test/e2e/app.e2e.ts",
|
||||
"test:e2e:web": "node test/e2e/web.e2e.ts",
|
||||
"proto": "pbjs -t static-module -w es6 --keep-case --no-delimited --no-service --no-comments --force-number proto/Mumble.proto -o src/core/mumble-pb.js && pbjs -t static-module -w es6 --keep-case --no-delimited --no-service --no-comments --force-number proto/MumbleUDP.proto -o src/core/mumble-udp-pb.js",
|
||||
"screenshots": "node test/e2e/screenshots.ts"
|
||||
},
|
||||
@@ -90,6 +93,7 @@
|
||||
"@tsconfig/svelte": "^5.0.8",
|
||||
"@types/node": "^26.6.3",
|
||||
"@types/node-forge": "^1.3.14",
|
||||
"@types/ws": "^8.18.2",
|
||||
"electron": "^44.5.1",
|
||||
"electron-builder": "^26.15.3",
|
||||
"esbuild": "^0.28.2",
|
||||
@@ -98,7 +102,8 @@
|
||||
"svelte": "^5.57.1",
|
||||
"svelte-check": "^4.7.6",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.3.1"
|
||||
"vite": "^8.3.1",
|
||||
"ws": "^8.22.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"dompurify": "^3.4.16",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import * as esbuild from 'esbuild';
|
||||
|
||||
// Bundles the web proxy to one file, dist-proxy/proxy.mjs, that runs with plain Node.
|
||||
await esbuild.build({
|
||||
entryPoints: { proxy: 'server/main.ts' },
|
||||
outdir: 'dist-proxy',
|
||||
outExtension: { '.js': '.mjs' },
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'esm',
|
||||
target: 'node22',
|
||||
// Bundled CommonJS dependencies still call require()
|
||||
banner: { js: "import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);" },
|
||||
logLevel: 'info'
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createServer } from 'vite';
|
||||
|
||||
// The browser build for development: the web proxy on 127.0.0.1:8080 and the Vite dev server,
|
||||
// which forwards /api to it (vite.config.ts). Without MUMH5_SERVERS the proxy allows any
|
||||
// server, private addresses included; it only listens on this machine.
|
||||
const env = { ...process.env, MUMH5_PORT: '8080', MUMH5_BIND: '127.0.0.1' };
|
||||
if (!env.MUMH5_SERVERS && !env.MUMH5_ALLOW_ANY) Object.assign(env, { MUMH5_ALLOW_ANY: '1', MUMH5_ALLOW_PRIVATE: '1' });
|
||||
|
||||
const proxy = spawn(process.execPath, ['server/main.ts'], { stdio: 'inherit', env });
|
||||
proxy.on('exit', code => { console.error(`The proxy stopped (exit code ${code}). Is port 8080 in use?`); process.exit(1); });
|
||||
|
||||
const server = await createServer({ mode: 'web' });
|
||||
await server.listen();
|
||||
server.printUrls();
|
||||
|
||||
const stop = () => { proxy.removeAllListeners('exit'); proxy.kill(); server.close().finally(() => process.exit(0)); };
|
||||
process.on('SIGINT', stop);
|
||||
process.on('SIGTERM', stop);
|
||||
@@ -0,0 +1,19 @@
|
||||
// Starts the web proxy from environment variables (see the README, "Browser version").
|
||||
import path from 'node:path';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { configFromEnv, startProxy } from './proxy.ts';
|
||||
|
||||
const config = configFromEnv(process.env);
|
||||
// The web build sits next to this script's folder, both in the repository and when bundled
|
||||
config.staticDir ??= [path.resolve(import.meta.dirname, '../dist-web')].find(d => existsSync(path.join(d, 'index.html'))) ?? null;
|
||||
|
||||
try {
|
||||
const { port } = await startProxy(config);
|
||||
console.log(`mumh5 proxy listening on http://${config.bind}:${port}`);
|
||||
console.log(config.allowAny ? `Allowed servers: any${config.allowPrivate ? ', private addresses included' : ' public address'}` : `Allowed servers: ${config.servers.map(s => `${s.host}:${s.port}`).join(', ')}`);
|
||||
if (config.sendProxy) console.log('Announcing client addresses with the PROXY protocol; the allowed servers must expect it');
|
||||
console.log(config.staticDir ? `Serving the web app from ${config.staticDir}` : 'No web build found (npm run build:web); serving the API only');
|
||||
} catch (e) {
|
||||
console.error((e as Error).message);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
// Web proxy for the browser build: serves the web app and bridges WebSocket connections to
|
||||
// Mumble servers over TLS. Browsers cannot open TLS sockets or present a client certificate,
|
||||
// so the proxy does both. It only connects to the servers on its allowlist.
|
||||
import http from 'node:http';
|
||||
import dns from 'node:dns';
|
||||
import net from 'node:net';
|
||||
import path from 'node:path';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { WebSocketServer, type WebSocket } from 'ws';
|
||||
import { openTls } from '../electron/tls-transport.ts';
|
||||
import { generateIdentity, identityFromP12, identityToP12, certCommonName } from '../electron/identity.ts';
|
||||
import { describeCert } from '../electron/certs.ts';
|
||||
|
||||
export interface AllowedServer { host: string; port: number; label: string }
|
||||
|
||||
export interface ProxyConfig {
|
||||
port: number;
|
||||
bind: string;
|
||||
servers: AllowedServer[];
|
||||
// Connect anywhere on the public internet instead of only to `servers`
|
||||
allowAny: boolean;
|
||||
// With allowAny: also allow loopback and private addresses
|
||||
allowPrivate: boolean;
|
||||
// Allowed Origin headers; empty means the origin must match the Host header, '*' allows all
|
||||
origins: string[];
|
||||
// Take the client address from X-Forwarded-For (behind a reverse proxy)
|
||||
trustProxy: boolean;
|
||||
// Start every server connection with a PROXY protocol line naming the client's address.
|
||||
// Only for servers behind something that understands it (go-mmproxy); plain Mumble does not.
|
||||
sendProxy: boolean;
|
||||
staticDir: string | null;
|
||||
maxConnections: number;
|
||||
maxPerAddress: number;
|
||||
}
|
||||
|
||||
export const defaults: ProxyConfig = {
|
||||
port: 8080, bind: '127.0.0.1', servers: [], allowAny: false, allowPrivate: false, origins: [],
|
||||
trustProxy: false, sendProxy: false, staticDir: null, maxConnections: 200, maxPerAddress: 8
|
||||
};
|
||||
|
||||
// "host", "host:port", "[v6]:port", each optionally followed by "=Label"
|
||||
export function parseServers(list: string): AllowedServer[] {
|
||||
return list.split(',').map(s => s.trim()).filter(Boolean).map(entry => {
|
||||
const eq = entry.indexOf('=');
|
||||
const addr = eq === -1 ? entry : entry.slice(0, eq);
|
||||
const label = eq === -1 ? '' : entry.slice(eq + 1).trim();
|
||||
const m = /^\[([^\]]+)\](?::(\d+))?$/.exec(addr) ?? /^([^:]+)(?::(\d+))?$/.exec(addr);
|
||||
if (!m) throw new Error(`Invalid server "${entry}"`);
|
||||
const host = m[1].trim().toLowerCase();
|
||||
return { host, port: Number(m[2] ?? 64738), label: label || host };
|
||||
});
|
||||
}
|
||||
|
||||
export function configFromEnv(env: NodeJS.ProcessEnv): ProxyConfig {
|
||||
const on = (v?: string) => v === '1' || v === 'true';
|
||||
return {
|
||||
...defaults,
|
||||
port: Number(env.MUMH5_PORT ?? defaults.port),
|
||||
bind: env.MUMH5_BIND ?? defaults.bind,
|
||||
servers: parseServers(env.MUMH5_SERVERS ?? ''),
|
||||
allowAny: on(env.MUMH5_ALLOW_ANY),
|
||||
allowPrivate: on(env.MUMH5_ALLOW_PRIVATE),
|
||||
origins: (env.MUMH5_ORIGINS ?? '').split(',').map(s => s.trim()).filter(Boolean),
|
||||
trustProxy: on(env.MUMH5_TRUST_PROXY),
|
||||
sendProxy: on(env.MUMH5_SEND_PROXY),
|
||||
staticDir: env.MUMH5_STATIC ?? null,
|
||||
maxConnections: Number(env.MUMH5_MAX_CONNECTIONS ?? defaults.maxConnections),
|
||||
maxPerAddress: Number(env.MUMH5_MAX_PER_ADDRESS ?? defaults.maxPerAddress)
|
||||
};
|
||||
}
|
||||
|
||||
// Loopback, private, link-local and other addresses that are not on the public internet
|
||||
export function isPrivateAddress(address: string): boolean {
|
||||
if (net.isIPv4(address)) {
|
||||
const [a, b] = address.split('.').map(Number);
|
||||
return a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254) ||
|
||||
(a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || a >= 224;
|
||||
}
|
||||
const v6 = address.toLowerCase();
|
||||
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(v6);
|
||||
if (mapped) return isPrivateAddress(mapped[1]);
|
||||
return v6 === '::' || v6 === '::1' || /^f[cd]/.test(v6) || /^fe[89ab]/.test(v6) || /^ff/.test(v6);
|
||||
}
|
||||
|
||||
// DNS lookup that fails for private addresses, so a public name cannot point the proxy inward
|
||||
const publicLookup: net.LookupFunction = (hostname, options, callback) => {
|
||||
// With `all` the result is a list of addresses, otherwise one address string
|
||||
(dns.lookup as Function)(hostname, options, (err: Error | null, result: string | dns.LookupAddress[], family?: number) => {
|
||||
if (err) return (callback as Function)(err);
|
||||
const list = Array.isArray(result) ? result : [{ address: result, family }];
|
||||
if (list.some(r => isPrivateAddress(r.address))) return (callback as Function)(new Error('This proxy does not connect to private addresses'));
|
||||
(callback as Function)(null, result, family);
|
||||
});
|
||||
};
|
||||
|
||||
const TYPES: Record<string, string> = {
|
||||
'.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8',
|
||||
'.json': 'application/json', '.png': 'image/png', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.jpg': 'image/jpeg',
|
||||
'.webp': 'image/webp', '.woff2': 'font/woff2', '.woff': 'font/woff', '.ogg': 'audio/ogg', '.mp3': 'audio/mpeg',
|
||||
'.wav': 'audio/wav', '.map': 'application/json', '.txt': 'text/plain; charset=utf-8', '.wasm': 'application/wasm'
|
||||
};
|
||||
|
||||
const MAX_BODY = 256 * 1024;
|
||||
// Mumble frames are at most 8 MiB; a WebSocket message carries a chunk of that stream
|
||||
const MAX_WS_MESSAGE = 8 * 1024 * 1024 + 64;
|
||||
const MAX_BUFFERED = 32 * 1024 * 1024;
|
||||
|
||||
class HttpError extends Error {
|
||||
status: number;
|
||||
constructor(status: number, message: string) { super(message); this.status = status; }
|
||||
}
|
||||
|
||||
export async function startProxy(config: ProxyConfig): Promise<{ port: number; close(): Promise<void> }> {
|
||||
if (!config.allowAny && !config.servers.length) {
|
||||
throw new Error('No servers allowed. Set MUMH5_SERVERS=host[:port][=Label],... or MUMH5_ALLOW_ANY=1.');
|
||||
}
|
||||
const staticDir = config.staticDir ? path.resolve(config.staticDir) : null;
|
||||
const perAddress = new Map<string, number>();
|
||||
// Identity requests per address in the current minute; key generation is the costly part
|
||||
const identityUse = new Map<string, number>();
|
||||
const sweep = setInterval(() => identityUse.clear(), 60000);
|
||||
sweep.unref();
|
||||
|
||||
const addressOf = (req: http.IncomingMessage): string => {
|
||||
// The last entry is the one our own reverse proxy added; earlier ones come from the client and can be made up
|
||||
const fwd = config.trustProxy ? String(req.headers['x-forwarded-for'] ?? '').split(',').at(-1)!.trim() : '';
|
||||
return fwd || req.socket.remoteAddress || 'unknown';
|
||||
};
|
||||
|
||||
const originOk = (req: http.IncomingMessage): boolean => {
|
||||
if (config.origins.includes('*')) return true;
|
||||
const origin = req.headers.origin;
|
||||
if (!origin) return false;
|
||||
if (config.origins.length) return config.origins.includes(origin);
|
||||
try { return new URL(origin).host === req.headers.host; } catch { return false; }
|
||||
};
|
||||
|
||||
const listed = (host: string, port: number): boolean => config.servers.some(s => s.host === host.toLowerCase() && s.port === port);
|
||||
const allowed = (host: string, port: number): boolean => config.allowAny || listed(host, port);
|
||||
|
||||
async function readJson(req: http.IncomingMessage): Promise<any> {
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
for await (const chunk of req) {
|
||||
size += (chunk as Buffer).length;
|
||||
if (size > MAX_BODY) throw new HttpError(413, 'Request too large');
|
||||
chunks.push(chunk as Buffer);
|
||||
}
|
||||
try { return JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { throw new HttpError(400, 'Invalid JSON'); }
|
||||
}
|
||||
|
||||
const withCert = (id: { certPem: string; keyPem: string; fingerprint: string }) =>
|
||||
({ ...id, cert: describeCert(id.certPem), commonName: certCommonName(id.certPem) });
|
||||
|
||||
async function api(req: http.IncomingMessage, route: string): Promise<unknown> {
|
||||
if (route === 'config' && req.method === 'GET') {
|
||||
return { servers: config.servers, any: config.allowAny };
|
||||
}
|
||||
if (req.method !== 'POST') throw new HttpError(404, 'Not found');
|
||||
if (!originOk(req)) throw new HttpError(403, 'Origin not allowed');
|
||||
const body = await readJson(req);
|
||||
if (route.startsWith('identity/')) {
|
||||
const addr = addressOf(req);
|
||||
const used = (identityUse.get(addr) ?? 0) + 1;
|
||||
identityUse.set(addr, used);
|
||||
if (used > 20) throw new HttpError(429, 'Too many requests, try again in a minute');
|
||||
}
|
||||
switch (route) {
|
||||
// Nothing is stored here: the browser keeps its identities and sends one along when it connects
|
||||
case 'identity/create':
|
||||
return withCert(generateIdentity(String(body.name ?? '').slice(0, 128), String(body.email ?? '').slice(0, 128)));
|
||||
case 'identity/import':
|
||||
return withCert(identityFromP12(new Uint8Array(Buffer.from(String(body.p12 ?? ''), 'base64')), String(body.password ?? '')));
|
||||
case 'identity/export': {
|
||||
const id = { certPem: String(body.certPem), keyPem: String(body.keyPem), fingerprint: '' };
|
||||
return { p12: Buffer.from(identityToP12(id, String(body.password ?? ''), String(body.name ?? 'Identity').slice(0, 128))).toString('base64') };
|
||||
}
|
||||
case 'certs/describe':
|
||||
return (Array.isArray(body.ders) ? body.ders.slice(0, 8) : []).flatMap((d: unknown) => {
|
||||
try { return [describeCert(Buffer.from(String(d), 'base64'))]; } catch { return []; }
|
||||
});
|
||||
}
|
||||
throw new HttpError(404, 'Not found');
|
||||
}
|
||||
|
||||
async function serveStatic(req: http.IncomingMessage, res: http.ServerResponse, pathname: string): Promise<void> {
|
||||
if (!staticDir || (req.method !== 'GET' && req.method !== 'HEAD')) throw new HttpError(404, 'Not found');
|
||||
let rel: string;
|
||||
try { rel = decodeURIComponent(pathname); } catch { throw new HttpError(400, 'Bad path'); }
|
||||
let file = path.join(staticDir, path.normalize('/' + rel));
|
||||
if (file !== staticDir && !file.startsWith(staticDir + path.sep)) throw new HttpError(404, 'Not found');
|
||||
let data: Buffer;
|
||||
try {
|
||||
if ((await fs.stat(file)).isDirectory()) file = path.join(file, 'index.html');
|
||||
data = await fs.readFile(file);
|
||||
} catch {
|
||||
throw new HttpError(404, 'Not found');
|
||||
}
|
||||
const html = file.endsWith('.html');
|
||||
res.writeHead(200, {
|
||||
'Content-Type': TYPES[path.extname(file).toLowerCase()] ?? 'application/octet-stream',
|
||||
'Content-Length': data.length,
|
||||
// Built assets carry a content hash in their name; the page itself must stay fresh
|
||||
'Cache-Control': html ? 'no-cache' : 'public, max-age=31536000, immutable',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Frame-Options': 'DENY'
|
||||
});
|
||||
res.end(req.method === 'HEAD' ? undefined : data);
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const pathname = new URL(req.url ?? '/', 'http://x').pathname;
|
||||
const work = pathname.startsWith('/api/')
|
||||
? api(req, pathname.slice(5)).then(result => {
|
||||
const data = Buffer.from(JSON.stringify(result));
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': data.length, 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' });
|
||||
res.end(data);
|
||||
})
|
||||
: serveStatic(req, res, pathname);
|
||||
work.catch((e: Error) => {
|
||||
const status = e instanceof HttpError ? e.status : 400;
|
||||
if (res.headersSent) return res.destroy();
|
||||
res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
||||
res.end(JSON.stringify({ error: e.message }));
|
||||
});
|
||||
});
|
||||
|
||||
const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_WS_MESSAGE, perMessageDeflate: false });
|
||||
|
||||
server.on('upgrade', (req, socket, head) => {
|
||||
const refuse = (status: string) => { socket.write(`HTTP/1.1 ${status}\r\nConnection: close\r\n\r\n`); socket.destroy(); };
|
||||
if (new URL(req.url ?? '/', 'http://x').pathname !== '/api/connect') return refuse('404 Not Found');
|
||||
if (!originOk(req)) return refuse('403 Forbidden');
|
||||
const addr = addressOf(req);
|
||||
if (wss.clients.size >= config.maxConnections || (perAddress.get(addr) ?? 0) >= config.maxPerAddress) return refuse('503 Service Unavailable');
|
||||
wss.handleUpgrade(req, socket, head, ws => bridge(ws, addr));
|
||||
});
|
||||
|
||||
// One WebSocket is one Mumble connection. The first message names the server and carries the
|
||||
// client certificate; after that binary messages are the raw Mumble TCP stream in both directions.
|
||||
function bridge(ws: WebSocket, addr: string): void {
|
||||
perAddress.set(addr, (perAddress.get(addr) ?? 0) + 1);
|
||||
const clientAddress = addr.replace(/^::ffff:/, '');
|
||||
let conn: ReturnType<typeof openTls> | null = null;
|
||||
let done = false;
|
||||
const finish = (reason: string) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(hello);
|
||||
conn?.close();
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'close', reason }));
|
||||
ws.close(1000);
|
||||
}
|
||||
};
|
||||
const hello = setTimeout(() => finish('No connection request received'), 10000);
|
||||
|
||||
ws.on('message', (data, isBinary) => {
|
||||
if (conn) {
|
||||
if (isBinary) conn.send(data as Buffer);
|
||||
return;
|
||||
}
|
||||
if (isBinary) return finish('Expected a connection request');
|
||||
let req: any;
|
||||
try { req = JSON.parse(data.toString()); } catch { return finish('Invalid connection request'); }
|
||||
const host = String(req.host ?? '').trim();
|
||||
const port = Number(req.port) || 64738;
|
||||
if (!host || host.length > 255 || port < 1 || port > 65535 || !allowed(host, port)) {
|
||||
return finish(`This proxy does not allow connecting to ${host}:${port}`);
|
||||
}
|
||||
// Servers the operator listed are trusted as they are, also on localhost or a private network
|
||||
const guard = config.allowAny && !config.allowPrivate && !listed(host, port);
|
||||
if (guard && net.isIP(host) && isPrivateAddress(host)) return finish('This proxy does not connect to private addresses');
|
||||
clearTimeout(hello);
|
||||
conn = openTls(host, port, String(req.certPem ?? ''), String(req.keyPem ?? ''), {
|
||||
onSecure: info => {
|
||||
if (ws.readyState !== ws.OPEN) return;
|
||||
// The server's address stays here; the browser has no use for it without UDP
|
||||
ws.send(JSON.stringify({ type: 'secure', info: {
|
||||
fingerprint: info.fingerprint, authorized: info.authorized, authError: info.authError, chain: info.chain,
|
||||
// Mumble only ever sees this proxy's address; tell the browser its own so the app can show it
|
||||
clientAddress
|
||||
} }));
|
||||
},
|
||||
onData: chunk => {
|
||||
if (ws.readyState !== ws.OPEN) return;
|
||||
// A browser that stopped reading must not grow our memory without bound
|
||||
if (ws.bufferedAmount > MAX_BUFFERED) return finish('Connection too slow');
|
||||
ws.send(chunk, { binary: true });
|
||||
},
|
||||
onClose: reason => finish(reason)
|
||||
}, { lookup: guard ? publicLookup : undefined, proxyClient: config.sendProxy ? clientAddress : undefined });
|
||||
});
|
||||
ws.on('close', () => {
|
||||
done = true;
|
||||
clearTimeout(hello);
|
||||
conn?.close();
|
||||
const left = (perAddress.get(addr) ?? 1) - 1;
|
||||
if (left > 0) perAddress.set(addr, left); else perAddress.delete(addr);
|
||||
});
|
||||
ws.on('error', () => ws.terminate());
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(config.port, config.bind, resolve);
|
||||
});
|
||||
return {
|
||||
port: (server.address() as net.AddressInfo).port,
|
||||
close: () => new Promise<void>(resolve => {
|
||||
clearInterval(sweep);
|
||||
for (const ws of wss.clients) ws.terminate();
|
||||
wss.close();
|
||||
server.close(() => resolve());
|
||||
server.closeAllConnections();
|
||||
})
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -78,7 +78,8 @@ try {
|
||||
// First start: identity setup wizard
|
||||
await page.getByRole('dialog', { name: 'Set up your identity' }).waitFor();
|
||||
await page.getByRole('button', { name: /Create a new identity/ }).click();
|
||||
await page.getByLabel('Name').fill('alice');
|
||||
await page.getByLabel('Name', { exact: true }).fill('alice');
|
||||
assert.ok(await page.getByLabel('Use this name as my username').isChecked());
|
||||
await page.getByRole('button', { name: 'Create', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Skip for now' }).click();
|
||||
await page.getByRole('button', { name: 'Done' }).click();
|
||||
@@ -119,7 +120,7 @@ try {
|
||||
await page.getByTitle('Add a server').click();
|
||||
await page.getByLabel('Address').fill(host);
|
||||
await page.getByLabel('Port').fill(port);
|
||||
await page.getByLabel('Username').fill('alice');
|
||||
assert.equal(await page.getByLabel('Username').inputValue(), 'alice', 'username suggested from the identity');
|
||||
await page.getByLabel('Label').fill('Test Server');
|
||||
await page.getByRole('button', { name: 'Save and connect' }).click();
|
||||
await page.getByText(/Connected/).first().waitFor();
|
||||
@@ -331,8 +332,28 @@ try {
|
||||
assert.equal(await page.locator('.rail').evaluate(el => getComputedStyle(el).backgroundColor), 'rgb(0, 128, 128)');
|
||||
await page.getByRole('radio', { name: 'Dark' }).click();
|
||||
assert.equal(await page.evaluate(() => document.documentElement.dataset.theme), undefined);
|
||||
// Hints are off by default: the title under the pointer is set aside while hovered, and back afterwards
|
||||
const hintTarget = page.locator('.rail').getByRole('button', { name: 'Add a server' });
|
||||
const titleOf = () => page.locator('.rail .tile.add').first().getAttribute('title');
|
||||
await page.getByRole('tab', { name: 'Accessibility' }).click();
|
||||
assert.equal(await page.getByLabel('Show hints when the pointer rests').isChecked(), false);
|
||||
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
||||
console.log('ok: color schemes');
|
||||
await hintTarget.hover();
|
||||
assert.equal(await titleOf(), null, 'no tooltip while hints are off');
|
||||
await page.mouse.move(700, 400);
|
||||
assert.equal(await titleOf(), 'Add a server');
|
||||
await page.getByTitle('Settings').click();
|
||||
await page.getByRole('tab', { name: 'Accessibility' }).click();
|
||||
await page.getByLabel('Show hints when the pointer rests').check();
|
||||
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
||||
await hintTarget.hover();
|
||||
assert.equal(await titleOf(), 'Add a server', 'tooltip kept while hints are on');
|
||||
await page.mouse.move(700, 400);
|
||||
await page.getByTitle('Settings').click();
|
||||
await page.getByRole('tab', { name: 'Accessibility' }).click();
|
||||
await page.getByLabel('Show hints when the pointer rests').uncheck();
|
||||
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
||||
console.log('ok: color schemes, hints off by default and can be turned on');
|
||||
|
||||
// Layouts like the desktop client: classic (channels right of chat), stacked (channels above)
|
||||
const box = (sel: string) => page.locator(sel).first().boundingBox().then(b => b!);
|
||||
@@ -571,6 +592,15 @@ try {
|
||||
await srvInfo.waitFor({ state: 'detached' });
|
||||
console.log('ok: server information dialog');
|
||||
|
||||
// Toolbar: description editor and settings
|
||||
await page.locator('.me').getByRole('button', { name: 'Change your description' }).click();
|
||||
await page.locator('.panel .rich').waitFor();
|
||||
await page.locator('.panel').getByRole('button', { name: 'Cancel' }).click();
|
||||
await page.locator('.me').getByRole('button', { name: 'Configure' }).click();
|
||||
await page.getByRole('dialog', { name: 'Settings' }).waitFor();
|
||||
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
||||
console.log('ok: toolbar opens the description editor and settings');
|
||||
|
||||
// Information: own connection shows client details and the full certificate
|
||||
await page.locator('.sidebar .user.self').click({ button: 'right' });
|
||||
await page.getByRole('menuitem', { name: 'Information' }).click();
|
||||
@@ -629,6 +659,29 @@ try {
|
||||
assert.equal(await page.locator('.me .who span').textContent(), 'Root');
|
||||
console.log('ok: single click shows description without joining');
|
||||
|
||||
// Description marker: only on channels that have one. Resting on the row previews, clicking the marker opens the panel
|
||||
const marker = page.locator('.channel', { hasText: 'Lobby' }).getByRole('button', { name: 'Show description of Lobby' });
|
||||
assert.equal(await page.locator('.channel', { hasText: 'Games' }).locator('.desc').count(), 0);
|
||||
await page.locator('.panel').getByRole('button', { name: 'Close' }).click();
|
||||
await page.locator('.channel .name', { hasText: 'Games' }).hover();
|
||||
await page.waitForTimeout(2000);
|
||||
assert.equal(await page.locator('.desc-pop').count(), 0, 'no preview for a channel without description');
|
||||
for (const spot of [page.locator('.channel .name', { hasText: 'Lobby' }), marker]) {
|
||||
await spot.hover();
|
||||
await page.waitForTimeout(500);
|
||||
assert.equal(await page.locator('.desc-pop').count(), 0, 'no preview before 1 second');
|
||||
await page.locator('.desc-pop .html', { hasText: 'Welcome to the lobby' }).waitFor({ timeout: 3000 });
|
||||
// Opens at the pointer
|
||||
const at = (await spot.boundingBox())!;
|
||||
const pop = (await page.locator('.desc-pop').boundingBox())!;
|
||||
assert.ok(Math.abs(pop.x - (at.x + at.width / 2 + 12)) < 3 && Math.abs(pop.y - (at.y + at.height / 2 + 12)) < 3, 'preview at the pointer');
|
||||
await page.mouse.move(700, 400);
|
||||
await page.locator('.desc-pop').waitFor({ state: 'detached' });
|
||||
}
|
||||
await marker.click();
|
||||
await page.locator('.panel .html', { hasText: 'Welcome to the lobby' }).waitFor();
|
||||
console.log('ok: description previews on hover, marker opens it on click');
|
||||
|
||||
// Side chat sends to that channel while staying in Root
|
||||
const lobby = [...bob.channels.values()].find(c => c.name === 'Lobby')!;
|
||||
await page.locator('.panel [role=tab]', { hasText: 'Chat' }).click();
|
||||
@@ -760,6 +813,16 @@ try {
|
||||
await page.getByRole('button', { name: 'Delete channel' }).click();
|
||||
await bobSees('deleted', () => !chByName('Team Room'));
|
||||
console.log('ok: channel deleted');
|
||||
|
||||
// Toolbar: expand and collapse the whole tree
|
||||
await page.locator('.me').getByRole('button', { name: 'Expand all channels' }).click();
|
||||
await page.locator('.channel', { hasText: 'Minecraft' }).waitFor();
|
||||
await page.locator('.me').getByRole('button', { name: 'Collapse all channels' }).click();
|
||||
await page.locator('.channel', { hasText: 'Minecraft' }).waitFor({ state: 'detached' });
|
||||
// Channels with people in them stay open
|
||||
assert.notEqual(await page.locator('.me .who span').textContent(), 'Root');
|
||||
await page.locator('.sidebar .row.user.self').waitFor();
|
||||
console.log('ok: toolbar expands and collapses all channels, occupied ones stay open');
|
||||
}
|
||||
|
||||
// Without an upload host, images are sent inline within Mumble's image limit
|
||||
|
||||
@@ -81,7 +81,7 @@ const page = await app.firstWindow();
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
|
||||
await page.getByRole('button', { name: /Create a new identity/ }).click();
|
||||
await page.getByLabel('Name').fill('kibi');
|
||||
await page.getByLabel('Name', { exact: true }).fill('kibi');
|
||||
await page.getByRole('button', { name: 'Create', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Skip for now' }).click();
|
||||
await page.getByRole('button', { name: 'Done' }).click();
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// A bare browser window for the web E2E: no preload, so the page runs as it would in a browser.
|
||||
const { app, BrowserWindow, session } = require('electron');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
app.whenReady().then(() => {
|
||||
session.defaultSession.setPermissionRequestHandler((_wc, _permission, cb) => cb(true));
|
||||
// Electron would open a save dialog; a browser saves to the downloads folder
|
||||
session.defaultSession.on('will-download', (_e, item) => {
|
||||
const file = path.join(process.env.MUMH5_DOWNLOADS, item.getFilename());
|
||||
item.setSavePath(file);
|
||||
item.once('done', (_ev, state) => { if (state === 'completed') fs.writeFileSync(file + '.done', ''); });
|
||||
});
|
||||
const win = new BrowserWindow({ width: 1280, height: 800, webPreferences: { contextIsolation: true, sandbox: true, nodeIntegration: false } });
|
||||
win.loadURL(process.env.MUMH5_WEB_URL);
|
||||
});
|
||||
app.on('window-all-closed', () => app.quit());
|
||||
@@ -0,0 +1,168 @@
|
||||
// Drives the browser build through the web proxy against a real Mumble server.
|
||||
// npm run build:web && MUMBLE_TEST_HOST=localhost:64739 npm run test:e2e:web
|
||||
// The page runs in a plain Chromium window (Electron without the preload), served by the bundled proxy.
|
||||
import { _electron as electron } from 'playwright-core';
|
||||
import electronPath from 'electron';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdtempSync, existsSync, statSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { createCodec } from '../../src/core/proto.ts';
|
||||
import { decodeVoice, type VoicePacket } from '../../src/core/voice-packet.ts';
|
||||
import { MumbleClient } from '../../src/core/client.ts';
|
||||
import type { Transport } from '../../src/core/transport.ts';
|
||||
import { openTls } from '../../electron/tls-transport.ts';
|
||||
import { generateIdentity } from '../../electron/identity.ts';
|
||||
|
||||
const target = process.env.MUMBLE_TEST_HOST;
|
||||
if (!target) {
|
||||
console.log('MUMBLE_TEST_HOST not set, skipping');
|
||||
process.exit(0);
|
||||
}
|
||||
const [host, port] = target.split(':');
|
||||
const root = path.resolve(import.meta.dirname, '../..');
|
||||
|
||||
function headless(username: string): Promise<MumbleClient> {
|
||||
const id = generateIdentity(username);
|
||||
const client = new MumbleClient(createCodec());
|
||||
const t: Transport = { onData: null, onClose: null, send: b => conn.send(b), close: () => conn.close() };
|
||||
const conn = openTls(host, Number(port), id.certPem, id.keyPem, {
|
||||
onSecure: () => {}, onData: c => t.onData?.(c), onClose: r => t.onClose?.(r)
|
||||
});
|
||||
client.connect(t, { username, os: 'test' });
|
||||
return new Promise((res, rej) => { client.on('synced', () => res(client)); client.on('close', rej); });
|
||||
}
|
||||
|
||||
function within<T>(p: Promise<T>, label: string, ms = 10000): Promise<T> {
|
||||
return Promise.race([p, new Promise<T>((_, rej) => setTimeout(() => rej(new Error(`Timed out waiting for: ${label}`)), ms))]);
|
||||
}
|
||||
|
||||
// The bundled proxy, as it would be deployed: one allowed server, serving dist-web
|
||||
const proxyPort = 18000 + Math.floor(Math.random() * 1000);
|
||||
const { ELECTRON_RUN_AS_NODE, ...env } = process.env;
|
||||
const proxy = spawn(process.execPath, [path.join(root, 'dist-proxy/proxy.mjs')], {
|
||||
env: { ...env, MUMH5_PORT: String(proxyPort), MUMH5_SERVERS: `${target}=Test Server` }, stdio: ['ignore', 'pipe', 'inherit']
|
||||
});
|
||||
await within(new Promise<void>((res, rej) => {
|
||||
proxy.stdout.on('data', d => { if (String(d).includes('listening')) res(); });
|
||||
proxy.on('exit', code => rej(new Error(`proxy exited with ${code}`)));
|
||||
}), 'proxy start');
|
||||
|
||||
const downloads = mkdtempSync(path.join(tmpdir(), 'mumh5-e2e-dl-'));
|
||||
const app = await electron.launch({
|
||||
executablePath: electronPath as unknown as string,
|
||||
args: [path.join(root, 'test/e2e/web-shell.cjs'), `--user-data-dir=${mkdtempSync(path.join(tmpdir(), 'mumh5-e2e-'))}`, '--ozone-platform=x11',
|
||||
'--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'],
|
||||
env: { ...env, MUMH5_WEB_URL: `http://127.0.0.1:${proxyPort}/`, MUMH5_DOWNLOADS: downloads } as Record<string, string>
|
||||
});
|
||||
const bob = await headless('bob');
|
||||
const name = `webalice${Date.now() % 100000}`;
|
||||
try {
|
||||
const page = await app.firstWindow();
|
||||
page.on('console', m => { if (m.type() === 'error') console.log('[page]', m.text()); });
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
assert.equal(await page.evaluate(() => 'mumh5Native' in window), false, 'no desktop API in the page');
|
||||
|
||||
// Identity: created through the proxy, kept in the browser
|
||||
await page.getByRole('dialog', { name: 'Set up your identity' }).waitFor();
|
||||
await page.getByText(/passed to the proxy at/).waitFor();
|
||||
await page.getByRole('button', { name: /Create a new identity/ }).click();
|
||||
await page.getByLabel('Name', { exact: true }).fill('alice');
|
||||
await page.getByRole('button', { name: 'Create', exact: true }).click();
|
||||
// Backup: downloaded as a .p12 file
|
||||
await page.getByLabel(/^Backup password/).fill('backup-pass');
|
||||
await page.getByLabel('Repeat password').fill('backup-pass');
|
||||
await page.getByRole('button', { name: 'Save backup...' }).click();
|
||||
await page.getByText('Backup saved to your downloads as alice.p12').waitFor();
|
||||
const saved = path.join(downloads, 'alice.p12');
|
||||
for (let i = 0; i < 50 && !existsSync(saved + '.done'); i++) await new Promise(r => setTimeout(r, 100));
|
||||
assert.ok(existsSync(saved + '.done') && statSync(saved).size > 1000, 'backup file downloaded');
|
||||
await page.getByRole('button', { name: 'Done' }).click();
|
||||
await page.getByRole('dialog', { name: 'Set up your identity' }).waitFor({ state: 'detached' });
|
||||
const stored = await page.evaluate(() => JSON.parse(localStorage.getItem('mumh5.identities')!));
|
||||
assert.equal(stored.identities.length, 1);
|
||||
assert.equal(stored.setupDone, true);
|
||||
console.log('ok: identity created through the proxy and backed up');
|
||||
|
||||
// Desktop-only features are not offered
|
||||
assert.equal(await page.getByTitle('Browse public servers').count(), 0);
|
||||
await page.getByTitle('Settings').click();
|
||||
await page.getByRole('tab', { name: 'Chat and files' }).click();
|
||||
assert.ok(await page.getByLabel('Link previews').locator('option[value=direct]').isDisabled());
|
||||
await page.getByRole('tab', { name: 'Identities' }).click();
|
||||
await page.getByText('alice', { exact: true }).first().waitFor();
|
||||
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
||||
console.log('ok: public list and direct previews are desktop only');
|
||||
|
||||
// The connect dialog offers the proxy's servers
|
||||
await page.getByTitle('Add a server').click();
|
||||
const pick = page.getByLabel('Server', { exact: true });
|
||||
await pick.waitFor();
|
||||
await page.waitForFunction(v => (document.getElementById('c-host') as HTMLSelectElement)?.value === v, `${host}:${port}`);
|
||||
assert.equal(await page.getByLabel('Label').inputValue(), 'Test Server');
|
||||
await page.getByLabel('Username').fill(name);
|
||||
await page.getByRole('button', { name: 'Save and connect' }).click();
|
||||
await page.getByText(/Connected/).first().waitFor();
|
||||
await page.locator('.sidebar').getByText('bob').waitFor();
|
||||
console.log('ok: connected through the proxy, sees bob');
|
||||
|
||||
// Text both ways
|
||||
const got = new Promise<string>(res => bob.on('text', m => res(m.html)));
|
||||
await page.getByLabel('Message').fill('hello from the browser');
|
||||
await page.keyboard.press('Enter');
|
||||
assert.match(await within(got, 'text from the page'), /hello from the browser/);
|
||||
bob.sendText({ channels: [0] }, 'hello browser');
|
||||
await page.locator('.msg', { hasText: 'hello browser' }).waitFor();
|
||||
console.log('ok: text in both directions');
|
||||
|
||||
// Voice goes through the TCP tunnel
|
||||
const session = () => [...bob.users.values()].find(u => u.name === name)!.session;
|
||||
const stream = new Promise<VoicePacket[]>(res => {
|
||||
const packets: VoicePacket[] = [];
|
||||
const off = bob.on('voice', raw => {
|
||||
const p = decodeVoice(raw);
|
||||
if (!p || p.session !== session()) return;
|
||||
packets.push(p);
|
||||
if (packets.length >= 20) { off(); res(packets); }
|
||||
});
|
||||
});
|
||||
await page.getByTitle('Settings').click();
|
||||
await page.getByRole('radio', { name: /Always on/ }).click();
|
||||
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
||||
const packets = await within(stream, 'voice packets from the page', 20000);
|
||||
assert.ok(packets.every(p => p.opus.length > 0));
|
||||
console.log(`ok: page sends Opus voice through the proxy (${packets[0].opus.length} bytes per packet)`);
|
||||
|
||||
await page.locator('.me').getByRole('button', { name: 'Server information' }).click();
|
||||
await page.getByRole('dialog', { name: 'Server information' }).getByText('Through the TCP connection').waitFor();
|
||||
await page.getByRole('dialog', { name: 'Server information' }).getByRole('button', { name: 'Close', exact: true }).last().click();
|
||||
console.log('ok: server information shows voice over TCP');
|
||||
|
||||
// Own information: with the proxy on this machine the server sees our own address, shown as such
|
||||
await page.locator('.sidebar .row.user.self').click({ button: 'right' });
|
||||
await page.getByRole('menuitem', { name: 'Information' }).click();
|
||||
const own = page.getByRole('dialog', { name: /^Information:/ });
|
||||
await own.getByText('Address', { exact: true }).waitFor();
|
||||
assert.equal(await own.getByText('Seen by the server').count(), 0);
|
||||
await own.getByRole('button', { name: 'Close', exact: true }).last().click();
|
||||
console.log('ok: own information shows the address the server sees');
|
||||
|
||||
// The identity survives a reload and the server recognizes the same certificate
|
||||
const hash = () => [...bob.users.values()].find(u => u.name === name)?.hash;
|
||||
const before = hash();
|
||||
assert.ok(before, 'bob sees the certificate hash');
|
||||
await page.reload();
|
||||
await page.locator('.rail .tile[title^="Test Server"]').click();
|
||||
await page.getByText(/Connected/).first().waitFor();
|
||||
const deadline = Date.now() + 5000;
|
||||
while (hash() !== before && Date.now() < deadline) await new Promise(r => setTimeout(r, 100));
|
||||
assert.equal(hash(), before, 'same certificate after a reload');
|
||||
console.log('ok: identity kept across a reload');
|
||||
|
||||
console.log('WEB E2E PASSED');
|
||||
} finally {
|
||||
bob.disconnect();
|
||||
await app.close().catch(() => {});
|
||||
proxy.kill();
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
// The web proxy: allowlist, identity endpoints and the WebSocket bridge. The bridge tests need a
|
||||
// real Mumble server and are skipped unless MUMBLE_TEST_HOST is set (see server.test.ts).
|
||||
import { test } from 'node:test';
|
||||
import net from 'node:net';
|
||||
import assert from 'node:assert/strict';
|
||||
import { proxyLine } from '../electron/tls-transport.ts';
|
||||
import { startProxy, defaults, parseServers, isPrivateAddress, type ProxyConfig } from '../server/proxy.ts';
|
||||
import { WebSocket as WsClient } from 'ws';
|
||||
import { WebSocketTransport } from '../src/core/ws-transport.ts';
|
||||
import { createCodec } from '../src/core/proto.ts';
|
||||
import { MumbleClient } from '../src/core/client.ts';
|
||||
|
||||
const target = process.env.MUMBLE_TEST_HOST;
|
||||
|
||||
async function withProxy(config: Partial<ProxyConfig>, fn: (base: string) => Promise<void>): Promise<void> {
|
||||
const proxy = await startProxy({ ...defaults, port: 0, origins: ['*'], ...config });
|
||||
try { await fn(`http://127.0.0.1:${proxy.port}`); } finally { await proxy.close(); }
|
||||
}
|
||||
|
||||
const post = (base: string, route: string, body: unknown, headers: Record<string, string> = {}) =>
|
||||
fetch(`${base}/api/${route}`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(body) });
|
||||
|
||||
test('server list parsing', () => {
|
||||
assert.deepEqual(parseServers('Mumble.Example.com, voice.example.org:1234=Friends ,[::1]:5=Local'), [
|
||||
{ host: 'mumble.example.com', port: 64738, label: 'mumble.example.com' },
|
||||
{ host: 'voice.example.org', port: 1234, label: 'Friends' },
|
||||
{ host: '::1', port: 5, label: 'Local' }
|
||||
]);
|
||||
assert.deepEqual(parseServers(''), []);
|
||||
});
|
||||
|
||||
test('private address detection', () => {
|
||||
for (const a of ['127.0.0.1', '10.1.2.3', '192.168.1.1', '172.20.0.1', '169.254.1.1', '100.64.0.1', '::1', 'fd00::1', 'fe80::1', '::ffff:10.0.0.1']) assert.ok(isPrivateAddress(a), a);
|
||||
for (const a of ['8.8.8.8', '172.32.0.1', '2001:4860:4860::8888', '::ffff:8.8.8.8']) assert.ok(!isPrivateAddress(a), a);
|
||||
});
|
||||
|
||||
test('refuses to start without allowed servers', async () => {
|
||||
await assert.rejects(startProxy({ ...defaults, port: 0 }), /No servers allowed/);
|
||||
});
|
||||
|
||||
test('config lists the allowed servers', async () => {
|
||||
await withProxy({ servers: parseServers('voice.example.org=Friends') }, async base => {
|
||||
assert.deepEqual(await (await fetch(`${base}/api/config`)).json(), { servers: [{ host: 'voice.example.org', port: 64738, label: 'Friends' }], any: false });
|
||||
});
|
||||
});
|
||||
|
||||
test('identity create, describe, export and import round trip', async () => {
|
||||
await withProxy({ servers: parseServers('voice.example.org') }, async base => {
|
||||
const created = await (await post(base, 'identity/create', { name: 'web user', email: '' })).json();
|
||||
assert.match(created.certPem, /BEGIN CERTIFICATE/);
|
||||
assert.match(created.keyPem, /PRIVATE KEY/);
|
||||
assert.match(created.cert.subject, /CN=web user/);
|
||||
assert.equal(created.commonName, 'web user');
|
||||
|
||||
const { p12 } = await (await post(base, 'identity/export', { certPem: created.certPem, keyPem: created.keyPem, password: 'secret-pass', name: 'web user' })).json();
|
||||
const wrong = await post(base, 'identity/import', { p12, password: 'nope' });
|
||||
assert.equal(wrong.status, 400);
|
||||
const imported = await (await post(base, 'identity/import', { p12, password: 'secret-pass' })).json();
|
||||
assert.equal(imported.fingerprint, created.fingerprint);
|
||||
|
||||
const der = created.certPem.replace(/-----[^-]+-----|\s/g, '');
|
||||
const [described] = await (await post(base, 'certs/describe', { ders: [der] })).json();
|
||||
assert.equal(described.fingerprint256, created.cert.fingerprint256);
|
||||
});
|
||||
});
|
||||
|
||||
test('requests from other origins are refused', async () => {
|
||||
await withProxy({ servers: parseServers('voice.example.org'), origins: [] }, async base => {
|
||||
const cross = await post(base, 'identity/create', { name: 'x' }, { Origin: 'https://evil.example' });
|
||||
assert.equal(cross.status, 403);
|
||||
const same = await post(base, 'certs/describe', { ders: [] }, { Origin: base });
|
||||
assert.equal(same.status, 200);
|
||||
});
|
||||
});
|
||||
|
||||
test('static files are served without leaving the folder', async () => {
|
||||
await withProxy({ servers: parseServers('voice.example.org'), staticDir: 'test' }, async base => {
|
||||
const ok = await fetch(`${base}/proxy.test.ts`);
|
||||
assert.equal(ok.status, 200);
|
||||
assert.equal((await fetch(`${base}/..%2Fpackage.json`)).status, 404);
|
||||
assert.equal((await fetch(`${base}/%2e%2e/package.json`)).status, 404);
|
||||
});
|
||||
});
|
||||
|
||||
test('behind a reverse proxy, limits count the forwarded client address', async () => {
|
||||
await withProxy({ servers: parseServers('voice.example.org'), trustProxy: true, maxPerAddress: 1 }, async base => {
|
||||
const open = (forwarded: string) => new Promise<string>(resolve => {
|
||||
const ws = new WsClient(`${base.replace(/^http/, 'ws')}/api/connect`, { headers: { 'X-Forwarded-For': forwarded } });
|
||||
ws.on('open', () => resolve('open'));
|
||||
ws.on('unexpected-response', (_req, res) => resolve(String(res.statusCode)));
|
||||
ws.on('error', () => resolve('error'));
|
||||
});
|
||||
assert.equal(await open('203.0.113.1'), 'open');
|
||||
assert.equal(await open('203.0.113.2'), 'open');
|
||||
// A made-up first entry does not hide the address the reverse proxy saw
|
||||
assert.equal(await open('198.51.100.9, 203.0.113.1'), '503');
|
||||
});
|
||||
});
|
||||
|
||||
test('PROXY protocol lines', () => {
|
||||
assert.equal(proxyLine('203.0.113.7', 40000, '127.0.0.1', 64738), 'PROXY TCP4 203.0.113.7 127.0.0.1 40000 64738\r\n');
|
||||
assert.equal(proxyLine('::ffff:203.0.113.7', 40000, '::ffff:10.0.0.2', 64738), 'PROXY TCP4 203.0.113.7 10.0.0.2 40000 64738\r\n');
|
||||
assert.equal(proxyLine('2001:db8::7', 40000, '::1', 64738), 'PROXY TCP6 2001:db8::7 ::1 40000 64738\r\n');
|
||||
assert.equal(proxyLine('203.0.113.7', 40000, '::1', 64738), 'PROXY TCP6 ::ffff:203.0.113.7 ::1 40000 64738\r\n');
|
||||
assert.equal(proxyLine('2001:db8::7', 40000, '127.0.0.1', 64738), 'PROXY UNKNOWN\r\n');
|
||||
});
|
||||
|
||||
test('the client address is announced to the server before TLS when asked to', async () => {
|
||||
// Stands in for go-mmproxy: reads the first bytes of the connection
|
||||
let gotFirst!: (s: string) => void;
|
||||
const first = new Promise<string>(resolve => { gotFirst = resolve; });
|
||||
const upstream = net.createServer(s => s.once('data', d => { gotFirst(d.toString('latin1')); s.destroy(); }));
|
||||
await new Promise<void>(resolve => upstream.listen(0, '127.0.0.1', resolve));
|
||||
const port = (upstream.address() as net.AddressInfo).port;
|
||||
try {
|
||||
await withProxy({ servers: parseServers(`127.0.0.1:${port}`), sendProxy: true, trustProxy: true }, async base => {
|
||||
const id = await identity(base, 'a');
|
||||
const ws = new WsClient(wsUrl(base), { headers: { 'X-Forwarded-For': '203.0.113.7' } });
|
||||
ws.on('open', () => ws.send(JSON.stringify({ host: '127.0.0.1', port, ...id })));
|
||||
ws.on('error', () => {});
|
||||
assert.match(await first, new RegExp(`^PROXY TCP4 203\\.0\\.113\\.7 127\\.0\\.0\\.1 \\d+ ${port}\\r\\n`));
|
||||
ws.terminate();
|
||||
});
|
||||
} finally {
|
||||
upstream.close();
|
||||
}
|
||||
});
|
||||
|
||||
const identity = async (base: string, name: string) => (await post(base, 'identity/create', { name })).json() as Promise<{ certPem: string; keyPem: string }>;
|
||||
const wsUrl = (base: string) => `${base.replace(/^http/, 'ws')}/api/connect`;
|
||||
|
||||
test('servers off the allowlist are refused', async () => {
|
||||
await withProxy({ servers: parseServers('voice.example.org') }, async base => {
|
||||
const t = new WebSocketTransport(wsUrl(base), { host: '127.0.0.1', port: 64738 }, await identity(base, 'a'));
|
||||
await assert.rejects(t.secure, /does not allow connecting to 127\.0\.0\.1:64738/);
|
||||
});
|
||||
});
|
||||
|
||||
test('private addresses are refused when any server is allowed', async () => {
|
||||
await withProxy({ allowAny: true }, async base => {
|
||||
const id = await identity(base, 'a');
|
||||
await assert.rejects(new WebSocketTransport(wsUrl(base), { host: '127.0.0.1', port: 64738 }, id).secure, /private addresses/);
|
||||
await assert.rejects(new WebSocketTransport(wsUrl(base), { host: 'localhost', port: 64738 }, id).secure, /private addresses/);
|
||||
});
|
||||
});
|
||||
|
||||
test('a listed server on a private address is reachable when any server is allowed', async () => {
|
||||
// Refuses the TLS handshake, which is enough to see the connection was attempted
|
||||
const local = net.createServer(s => s.destroy());
|
||||
await new Promise<void>(resolve => local.listen(0, '127.0.0.1', resolve));
|
||||
const port = (local.address() as net.AddressInfo).port;
|
||||
try {
|
||||
await withProxy({ allowAny: true, servers: parseServers(`127.0.0.1:${port},localhost:${port}`) }, async base => {
|
||||
const id = await identity(base, 'a');
|
||||
for (const host of ['127.0.0.1', 'localhost']) {
|
||||
const err = await new WebSocketTransport(wsUrl(base), { host, port }, id).secure.then(() => null, e => e as Error);
|
||||
assert.ok(err && !/private addresses|does not allow/.test(err.message), `${host}: ${err?.message}`);
|
||||
}
|
||||
// Other private addresses stay blocked
|
||||
await assert.rejects(new WebSocketTransport(wsUrl(base), { host: '127.0.0.1', port: port + 1 }, id).secure, /private addresses/);
|
||||
});
|
||||
} finally {
|
||||
local.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('two clients talk through the proxy', { skip: !target }, async () => {
|
||||
const [host, port = '64738'] = target!.split(':');
|
||||
await withProxy({ servers: parseServers(target!) }, async base => {
|
||||
const connect = async (name: string) => {
|
||||
const transport = new WebSocketTransport(wsUrl(base), { host, port: Number(port) }, await identity(base, name));
|
||||
const info = await transport.secure;
|
||||
assert.match(info.fingerprint, /^([0-9A-F]{2}:){31}[0-9A-F]{2}$/);
|
||||
assert.ok(info.chain.length >= 1);
|
||||
assert.equal(info.clientAddress, '127.0.0.1');
|
||||
const client = new MumbleClient(createCodec());
|
||||
client.connect(transport, { username: name, os: 'test' });
|
||||
await new Promise<void>((resolve, reject) => { client.on('synced', () => resolve()); client.on('close', reject); });
|
||||
return client;
|
||||
};
|
||||
const a = await connect(`proxy-a-${Date.now() % 100000}`);
|
||||
const b = await connect(`proxy-b-${Date.now() % 100000}`);
|
||||
const got = new Promise<string>(resolve => b.on('text', m => resolve(m.html)));
|
||||
a.sendText({ channels: [0] }, 'hello through the proxy');
|
||||
assert.equal(await got, 'hello through the proxy');
|
||||
// No UDP in a browser: voice stays on the TCP tunnel
|
||||
assert.equal(a.udpOk, false);
|
||||
a.disconnect();
|
||||
b.disconnect();
|
||||
});
|
||||
});
|
||||
@@ -11,5 +11,5 @@
|
||||
"skipLibCheck": true,
|
||||
"types": ["node", "svelte", "vite/client"]
|
||||
},
|
||||
"include": ["src/**/*", "electron/**/*", "test/**/*"]
|
||||
"include": ["src/**/*", "electron/**/*", "server/**/*", "test/**/*"]
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
export default defineConfig({
|
||||
export default defineConfig(({ mode }) => ({
|
||||
base: './',
|
||||
plugins: [svelte()],
|
||||
build: { target: 'chrome120', outDir: 'dist', emptyOutDir: true }
|
||||
});
|
||||
build: { target: 'chrome120', outDir: 'dist', emptyOutDir: true },
|
||||
// npm run dev:web: the page comes from Vite, the API from the proxy that script starts next to it
|
||||
server: mode === 'web' ? { proxy: { '/api': { target: 'http://127.0.0.1:8080', ws: true } } } : undefined
|
||||
}));
|
||||
|
||||