Merge pull request 'Server info and ui polish' (#4) from server-info-and-ui-polish into main

Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
2026-10-01 19:43:18 +00:00
54 changed files with 1740 additions and 127 deletions
+1
View File
@@ -1,6 +1,7 @@
node_modules/ node_modules/
dist/ dist/
dist-web/ dist-web/
dist-proxy/
dist-electron/ dist-electron/
release/ release/
*.log *.log
+1
View File
@@ -6,6 +6,7 @@ All notable changes to mumh5. Versions follow the `version` in `package.json`.
### Added ### 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. - 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. - 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%. - Per-person volume slider (0 to 300%) directly in the user context menu. Double-click resets it to 100%.
+7 -1
View File
@@ -9,7 +9,7 @@
## Project overview ## 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. 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 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 test:e2e # drives the built app (needs npm run build first)
npm run screenshots # regenerates docs/screenshots (see test/e2e/screenshots.ts) 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/ 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 ## 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). - `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/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/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). - `src/ui/`: Svelte components. `App.svelte` owns layout and global dialogs (`ui.svelte.ts` store).
+82 -7
View File
@@ -20,7 +20,7 @@ mumh5 keeps the foundation and replaces the experience.
- **Feels familiar.** A server rail, a channel tree, a real chat with message grouping, profiles, right-click menus everywhere. Anyone who has used Discord knows where things are. - **Feels familiar.** A server rail, a channel tree, a real chat with message grouping, profiles, right-click menus everywhere. Anyone who has used Discord knows where things are.
- **Chat that is actually useful.** Drop in files, paste screenshots, share videos and YouTube links. Formatting works, links work, and everything stays compatible with the desktop client. - **Chat that is actually useful.** Drop in files, paste screenshots, share videos and YouTube links. Formatting works, links work, and everything stays compatible with the desktop client.
- **Several servers at once.** Stay in the text chat of every server you like, with unread badges, while voice runs where you actually are. - **Several servers at once.** Stay in the text chat of every server you like, with unread badges, while voice runs where you actually are.
- **Private by default.** Opening a chat never contacts servers you didn't choose. No remote images or trackers, and YouTube stays click-to-play. - **Private by default.** Opening a chat never contacts servers you didn't choose. No remote images or trackers, and YouTube links stay plain links unless you turn on the click-to-play player.
- **Runs everywhere.** Windows, macOS and Linux, with a layout that also works on narrow windows and small screens. - **Runs everywhere.** Windows, macOS and Linux, with a layout that also works on narrow windows and small screens.
### mumh5 compared to the Mumble desktop client ### mumh5 compared to the Mumble desktop client
@@ -30,7 +30,7 @@ mumh5 keeps the foundation and replaces the experience.
| Interface | Qt widgets, dense dialogs | Modern layout (server rail, channel tree, chat, side panels), or the classic and stacked layouts of the desktop client | | Interface | Qt widgets, dense dialogs | Modern layout (server rail, channel tree, chat, side panels), or the classic and stacked layouts of the desktop client |
| Several servers at the same time | One connection | Many; voice on one, text on all, unread badges | | Several servers at the same time | One connection | Many; voice on one, text on all, unread badges |
| Sharing files | Images only, pasted inline and heavily limited | Any allowed file through your own [f0ckm](#file-sharing-with-f0ckm) host, inline players for images, video and audio. Images still work without one | | Sharing files | Images only, pasted inline and heavily limited | Any allowed file through your own [f0ckm](#file-sharing-with-f0ckm) host, inline players for images, video and audio. Images still work without one |
| YouTube links | Plain links | Click-to-play player (privacy mode), optional thumbnails | | YouTube links | Plain links | Plain links by default; optional click-to-play player (privacy mode) and thumbnails |
| Link previews | None | Title, description and image, fetched by your upload host so linked sites never see your IP | | Link previews | None | Title, description and image, fetched by your upload host so linked sites never see your IP |
| Chat per channel | One shared log for everything | Separate chats per channel and person, plus a side chat for any channel next to your own | | Chat per channel | One shared log for everything | Separate chats per channel and person, plus a side chat for any channel next to your own |
| Descriptions | Rich text editor | Rich text editor with pictures, and a live size counter against the server limit | | Descriptions | Rich text editor | Rich text editor with pictures, and a live size counter against the server limit |
@@ -80,7 +80,7 @@ mumh5 keeps the foundation and replaces the experience.
- Messages grouped by author, direct messages, unread badges - Messages grouped by author, direct messages, unread badges
- File sharing: drag and drop, paste, or the attach button, with upload progress - File sharing: drag and drop, paste, or the attach button, with upload progress
- Inline images, video and audio players for shared media - Inline images, video and audio players for shared media
- YouTube links as click-to-play players - YouTube links as click-to-play players (off by default)
- Link previews with title, description and image; fetched by your upload host by default, so posting a link cannot reveal who reads it - Link previews with title, description and image; fetched by your upload host by default, so posting a link cannot reveal who reads it
- Incoming HTML is sanitized; remote images from unknown hosts are never loaded - Incoming HTML is sanitized; remote images from unknown hosts are never loaded
- Messages stay compatible with the desktop client, including picture previews - Messages stay compatible with the desktop client, including picture previews
@@ -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 ## 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. 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,9 +242,9 @@ Without an upload host, mumh5 still sends images, scaled to fit the server's lim
## Privacy and security ## 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. - **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 is click-to-play in privacy mode. - **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. - **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 - System-wide push to talk
- Whisper and shout - Whisper and shout
- Rich chat between mumh5 users: replies, reactions, edits, typing indicators - Rich chat between mumh5 users: replies, reactions, edits, typing indicators
- Video and screen sharing between mumh5 users - 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
- A browser version, through a small self-hosted WebSocket proxy
- Release builds for all platforms - 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 MUMBLE_TEST_HOST=localhost:64739 npm test
npm run build npm run build
MUMBLE_TEST_HOST=localhost:64739 MUMBLE_SUPERUSER_PASSWORD=testsuper npm run test:e2e 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. 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.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 253 KiB

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 266 KiB

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 255 KiB

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 254 KiB

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 283 KiB

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 119 KiB

+31 -6
View File
@@ -1,4 +1,5 @@
import tls from 'node:tls'; import tls from 'node:tls';
import net from 'node:net';
import { describeCert, type CertDetails } from './certs.ts'; import { describeCert, type CertDetails } from './certs.ts';
export interface TlsHandlers { export interface TlsHandlers {
@@ -9,13 +10,37 @@ export interface TlsHandlers {
// Opens a TLS connection to a Mumble server. Most servers use self-signed certificates, // 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). // 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'; let closeReason = 'Connection closed';
const socket = tls.connect({ const servername = /^[\d.:]+$/.test(host) ? undefined : host;
host, port, cert, key, let socket: tls.TLSSocket;
rejectUnauthorized: false, if (opts.proxyClient) {
servername: /^[\d.:]+$/.test(host) ? undefined : host // 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.setNoDelay(true);
socket.setKeepAlive(true, 30000); socket.setKeepAlive(true, 30000);
socket.setTimeout(20000, () => { socket.setTimeout(20000, () => {
+35 -1
View File
@@ -18,6 +18,7 @@
"@tsconfig/svelte": "^5.0.8", "@tsconfig/svelte": "^5.0.8",
"@types/node": "^26.6.3", "@types/node": "^26.6.3",
"@types/node-forge": "^1.3.14", "@types/node-forge": "^1.3.14",
"@types/ws": "^8.18.2",
"electron": "^44.5.1", "electron": "^44.5.1",
"electron-builder": "^26.15.3", "electron-builder": "^26.15.3",
"esbuild": "^0.28.2", "esbuild": "^0.28.2",
@@ -26,7 +27,8 @@
"svelte": "^5.57.1", "svelte": "^5.57.1",
"svelte-check": "^4.7.6", "svelte-check": "^4.7.6",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"vite": "^8.3.1" "vite": "^8.3.1",
"ws": "^8.22.0"
} }
}, },
"node_modules/@babel/helper-string-parser": { "node_modules/@babel/helper-string-parser": {
@@ -1513,6 +1515,16 @@
"license": "MIT", "license": "MIT",
"optional": true "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": { "node_modules/@xmldom/xmldom": {
"version": "0.8.15", "version": "0.8.15",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz",
@@ -5736,6 +5748,28 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/xmlbuilder": {
"version": "15.1.1", "version": "15.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
+7 -2
View File
@@ -9,7 +9,9 @@
"scripts": { "scripts": {
"dev": "node scripts/dev.mjs", "dev": "node scripts/dev.mjs",
"build": "vite build && node scripts/build-electron.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", "check": "svelte-check --tsconfig ./tsconfig.json",
"test": "node --test test/*.test.ts", "test": "node --test test/*.test.ts",
"dist": "npm run build && electron-builder", "dist": "npm run build && electron-builder",
@@ -17,6 +19,7 @@
"dist:win": "npm run build && electron-builder --win", "dist:win": "npm run build && electron-builder --win",
"dist:mac": "npm run build && electron-builder --mac", "dist:mac": "npm run build && electron-builder --mac",
"test:e2e": "node test/e2e/app.e2e.ts", "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", "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" "screenshots": "node test/e2e/screenshots.ts"
}, },
@@ -90,6 +93,7 @@
"@tsconfig/svelte": "^5.0.8", "@tsconfig/svelte": "^5.0.8",
"@types/node": "^26.6.3", "@types/node": "^26.6.3",
"@types/node-forge": "^1.3.14", "@types/node-forge": "^1.3.14",
"@types/ws": "^8.18.2",
"electron": "^44.5.1", "electron": "^44.5.1",
"electron-builder": "^26.15.3", "electron-builder": "^26.15.3",
"esbuild": "^0.28.2", "esbuild": "^0.28.2",
@@ -98,7 +102,8 @@
"svelte": "^5.57.1", "svelte": "^5.57.1",
"svelte-check": "^4.7.6", "svelte-check": "^4.7.6",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"vite": "^8.3.1" "vite": "^8.3.1",
"ws": "^8.22.0"
}, },
"dependencies": { "dependencies": {
"dompurify": "^3.4.16", "dompurify": "^3.4.16",
+15
View File
@@ -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'
});
+19
View File
@@ -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);
+19
View File
@@ -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);
}
+318
View File
@@ -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();
})
};
}
+2
View File
@@ -14,6 +14,7 @@
import UserInfoDialog from './ui/UserInfoDialog.svelte'; import UserInfoDialog from './ui/UserInfoDialog.svelte';
import ChannelDialog from './ui/ChannelDialog.svelte'; import ChannelDialog from './ui/ChannelDialog.svelte';
import PublicServers from './ui/PublicServers.svelte'; import PublicServers from './ui/PublicServers.svelte';
import ServerInfoDialog from './ui/ServerInfoDialog.svelte';
import ResizeHandle from './ui/ResizeHandle.svelte'; import ResizeHandle from './ui/ResizeHandle.svelte';
import { identities } from './lib/identities.svelte.ts'; import { identities } from './lib/identities.svelte.ts';
import { session } from './lib/session.svelte.ts'; import { session } from './lib/session.svelte.ts';
@@ -115,6 +116,7 @@
{#if editing}<ConnectDialog server={editing.server} onclose={() => (editing = null)} />{/if} {#if editing}<ConnectDialog server={editing.server} onclose={() => (editing = null)} />{/if}
{#if ui.addServer}<ConnectDialog server={null} prefill={ui.addServer} onclose={() => (ui.addServer = null)} />{/if} {#if ui.addServer}<ConnectDialog server={null} prefill={ui.addServer} onclose={() => (ui.addServer = null)} />{/if}
{#if ui.publicServers}<PublicServers onclose={() => (ui.publicServers = false)} />{/if} {#if ui.publicServers}<PublicServers onclose={() => (ui.publicServers = false)} />{/if}
{#if ui.serverInfo && session.status === 'connected'}<ServerInfoDialog onclose={() => (ui.serverInfo = false)} />{/if}
{#if ui.settingsOpen}<SettingsDialog tab={ui.settingsTab} onclose={() => { ui.settingsOpen = false; ui.settingsTab = 'voice'; }} />{/if} {#if ui.settingsOpen}<SettingsDialog tab={ui.settingsTab} onclose={() => { ui.settingsOpen = false; ui.settingsTab = 'voice'; }} />{/if}
{#if ui.quickAudio}<QuickAudio />{/if} {#if ui.quickAudio}<QuickAudio />{/if}
{#if ui.userInfo != null && session.status === 'connected'} {#if ui.userInfo != null && session.status === 'connected'}
+3
View File
@@ -51,4 +51,7 @@ export interface ServerCertInfo {
authorized: boolean; // true if it chains to a trusted CA 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 authError: string | null; // why it is not trusted, e.g. DEPTH_ZERO_SELF_SIGNED_CERT
chain: CertDetails[]; // leaf first chain: CertDetails[]; // leaf first
// Browser build only: this client's address as the web proxy saw it. The Mumble server
// sees the proxy's address instead and cannot be told the original one.
clientAddress?: string;
} }
+50
View File
@@ -0,0 +1,50 @@
import type { Transport, ServerCertInfo } from './transport.ts';
// Transport through the mumh5 web proxy: one WebSocket per Mumble connection. The proxy opens
// the TLS socket and presents the client certificate, since a browser can do neither.
// `secure` resolves once the proxy has finished the TLS handshake, before any Mumble data is
// sent, so the caller can check the server certificate before sending a password.
// There is no UDP here; voice goes through the TCP tunnel.
export class WebSocketTransport implements Transport {
onData: ((chunk: Uint8Array) => void) | null = null;
onClose: ((reason: string) => void) | null = null;
readonly secure: Promise<ServerCertInfo>;
private ws: WebSocket;
constructor(url: string, target: { host: string; port: number }, identity: { certPem: string; keyPem: string }) {
let resolveSecure!: (i: ServerCertInfo) => void;
let rejectSecure!: (e: Error) => void;
this.secure = new Promise((res, rej) => { resolveSecure = res; rejectSecure = rej; });
// Callers that never await `secure` still get onClose
this.secure.catch(() => {});
let reason = '';
let opened = false;
const ws = new WebSocket(url);
this.ws = ws;
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
opened = true;
ws.send(JSON.stringify({ host: target.host, port: target.port, certPem: identity.certPem, keyPem: identity.keyPem }));
};
ws.onmessage = e => {
if (typeof e.data !== 'string') return this.onData?.(new Uint8Array(e.data as ArrayBuffer));
let msg: any;
try { msg = JSON.parse(e.data); } catch { return; }
if (msg.type === 'secure') resolveSecure(msg.info);
else if (msg.type === 'close') reason = String(msg.reason ?? '');
};
ws.onclose = () => {
reason ||= opened ? 'Connection closed' : 'Could not reach the proxy';
rejectSecure(new Error(reason));
this.onClose?.(reason);
};
}
send(bytes: Uint8Array): void {
if (this.ws.readyState === WebSocket.OPEN) this.ws.send(bytes as Uint8Array<ArrayBuffer>);
}
close(): void {
this.ws.close();
}
}
+33
View File
@@ -0,0 +1,33 @@
import { store } from './settings.svelte.ts';
// Hints are the browser tooltips from title attributes. With the setting off, the title of
// whatever is under the pointer is set aside while it is hovered and put back afterwards, so
// the attributes stay in place for screen readers and everything else that reads them.
export function startHints(): void {
let held: { el: Element; title: string; labelled: boolean }[] = [];
const restore = () => {
for (const h of held) {
// Leave it alone if the app set a new title in the meantime
if (!h.el.hasAttribute('title')) h.el.setAttribute('title', h.title);
if (h.labelled) h.el.removeAttribute('aria-label');
}
held = [];
};
document.addEventListener('mouseover', e => {
restore();
if (store.settings.hints) return;
// A title on any ancestor would show too
for (let el = e.target as Element | null; el; el = el.parentElement) {
const title = el.getAttribute('title');
if (!title) continue;
// Keep the accessible name of icon buttons that are only named by their title
const labelled = !el.hasAttribute('aria-label') && !el.textContent?.trim();
if (labelled) el.setAttribute('aria-label', title);
el.removeAttribute('title');
held.push({ el, title, labelled });
}
}, true);
document.addEventListener('mouseleave', restore);
}
+3
View File
@@ -6,6 +6,9 @@ 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', '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', settings: 'M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6',
plus: 'M12 5v14M5 12h14', 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', 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',
hash: 'M4 9h16M4 15h16M10 3L8 21M16 3l-2 18', hash: 'M4 9h16M4 15h16M10 3L8 21M16 3l-2 18',
channel: 'M11 5L6 9H2v6h4l5 4zM15.5 8.5a5 5 0 0 1 0 7', channel: 'M11 5L6 9H2v6h4l5 4zM15.5 8.5a5 5 0 0 1 0 7',
+32 -14
View File
@@ -1,8 +1,27 @@
import type { Transport, ServerCertInfo, CertDetails, UdpChannel } from '../core/transport.ts'; 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 }>; platformInfo(): Promise<{ os: string; osVersion: string }>;
describeCerts(ders: Uint8Array[]): Promise<CertDetails[]>; 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; updateTray(state: { icon: string; tooltip: string; connected: boolean; server: string; muted: boolean; deafened: boolean; mode: string }): void;
onTrayAction(fn: (action: string) => void): void; onTrayAction(fn: (action: string) => void): void;
linkPreview(url: string): Promise<LinkPreviewData | null>; linkPreview(url: string): Promise<LinkPreviewData | null>;
@@ -10,18 +29,6 @@ interface NativeApi {
pingServer(host: string, port: number): Promise<PingResult | null>; pingServer(host: string, port: number): Promise<PingResult | null>;
onContextMenu(fn: (params: ContextParams) => void): void; onContextMenu(fn: (params: ContextParams) => void): void;
editAction(action: string, arg?: unknown): 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>; open(connId: string, host: string, port: number, identityId?: string): Promise<void>;
send(connId: string, bytes: Uint8Array): void; send(connId: string, bytes: Uint8Array): void;
close(connId: string): void; close(connId: string): void;
@@ -71,7 +78,18 @@ declare global {
interface Window { mumh5Native?: NativeApi } 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. // 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, // `secure` resolves once the TLS handshake is done, before any Mumble data is sent,
+14 -4
View File
@@ -1,18 +1,28 @@
import { native, type LinkPreviewData } from './native.ts'; import { desktop, type LinkPreviewData } from './native.ts';
import { store } from './settings.svelte.ts'; import { store } from './settings.svelte.ts';
// One request per URL and mode per app run // One request per URL and mode per app run
const cache = new Map<string, Promise<LinkPreviewData | null>>(); const cache = new Map<string, Promise<LinkPreviewData | null>>();
export function linkPreview(url: 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; const { linkPreviews, uploadHost, uploadKey } = store.settings;
const mode = 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> {
const { uploadHost, uploadKey } = store.settings;
const mode = previewMode();
if (mode === 'off') return Promise.resolve(null); if (mode === 'off') return Promise.resolve(null);
const id = `${mode}|${url}`; const id = `${mode}|${url}`;
let p = cache.get(id); let p = cache.get(id);
if (!p) { if (!p) {
p = mode === 'direct' 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 } }) : fetch(`${uploadHost.replace(/\/+$/, '')}/api/chat/preview?url=${encodeURIComponent(url)}`, { headers: { 'X-API-Key': uploadKey } })
.then(r => r.json()) .then(r => r.json())
.then(d => (d?.success ? d as LinkPreviewData : null)) .then(d => (d?.success ? d as LinkPreviewData : null))
+40 -8
View File
@@ -1,6 +1,6 @@
import { createCodec } from '../core/proto.ts'; import { createCodec } from '../core/proto.ts';
import { MumbleClient, describeDenial, type Channel, type User, type TextMessage } from '../core/client.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 type { ServerCertInfo } from '../core/transport.ts';
import { store, type SavedServer } from './settings.svelte.ts'; import { store, type SavedServer } from './settings.svelte.ts';
import { renderIncoming, textToHtml, escapeHtml, formatBytes, type Embed } from './html.ts'; import { renderIncoming, textToHtml, escapeHtml, formatBytes, type Embed } from './html.ts';
@@ -155,12 +155,21 @@ export class Session {
this.expanded[id] = !this.isExpanded(id); this.expanded[id] = !this.isExpanded(id);
} }
// Collapsing goes back to the default: channels with people in them stay open
setAllExpanded(open: boolean): void { setAllExpanded(open: boolean): void {
const all: Record<number, boolean> = {}; 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; this.expanded = all;
} }
// What the server told us about itself, for the server information dialog
get serverInfo() {
void this.tick;
const c = this.client;
if (!c) return null;
return { version: c.serverVersion, maxBandwidth: c.maxBandwidth, config: { ...c.config }, users: c.users.size, channels: c.channels.size };
}
// How voice travels right now // How voice travels right now
get voiceTransport(): { udp: boolean; rtt: number } { get voiceTransport(): { udp: boolean; rtt: number } {
void this.tick; void this.tick;
@@ -216,13 +225,16 @@ export class Session {
this.status = 'connecting'; this.status = 'connecting';
if (!native) { 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; return;
} }
const transport = new ElectronTransport(native, server.host, server.port, server.identityId); let transport;
let info; let info;
try { 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; info = await transport.secure;
} catch (e) { } catch (e) {
if (attempt === this.attempt) this.fail((e as Error).message); if (attempt === this.attempt) this.fail((e as Error).message);
@@ -663,8 +675,17 @@ export class Session {
// ─── Voice state and moderation ───────────────────────────────────────────── // ─── Voice state and moderation ─────────────────────────────────────────────
setSelfMute(v: boolean) { this.client?.setSelfMute(v); } // The choice is remembered, so it also works while disconnected and carries to the next connect
setSelfDeaf(v: boolean) { this.client?.setSelfDeaf(v); } setSelfMute(v: boolean) {
sessions.mute = v;
if (!v) sessions.deaf = false;
this.client?.setSelfMute(v);
}
setSelfDeaf(v: boolean) {
sessions.deaf = v;
if (v) sessions.mute = true;
this.client?.setSelfDeaf(v);
}
kick(session: number, reason: string, ban = false) { this.client?.kick(session, reason, ban); } kick(session: number, reason: string, ban = false) { this.client?.kick(session, reason, ban); }
setServerMute(session: number, v: boolean) { this.client?.setUserState(session, { mute: v }); } setServerMute(session: number, v: boolean) { this.client?.setUserState(session, { mute: v }); }
@@ -683,6 +704,10 @@ class Sessions {
activeId = $state<string | null>(null); activeId = $state<string | null>(null);
voiceId = $state<string | null>(null); voiceId = $state<string | null>(null);
private idle = new Session(); private idle = new Session();
// Self mute/deaf as the user last chose it, like the desktop client: shown and toggled
// while disconnected and applied to servers when they connect
mute = $state(false);
deaf = $state(false);
// Self mute/deaf before we deafened a background server, to restore later // Self mute/deaf before we deafened a background server, to restore later
private saved = new Map<Session, { mute: boolean; deaf: boolean }>(); private saved = new Map<Session, { mute: boolean; deaf: boolean }>();
@@ -725,8 +750,15 @@ class Sessions {
} }
onSynced(s: Session): void { onSynced(s: Session): void {
if (!this.voiceId || !this.voiceSession?.client) this.setVoice(s, true); const want = { mute: this.mute, deaf: this.deaf };
else this.toBackground(s); if (!this.voiceId || !this.voiceSession?.client) {
this.setVoice(s, true);
if (want.mute) s.client?.send('UserState', { self_mute: true, self_deaf: want.deaf });
} else {
this.toBackground(s);
// A fresh login is unmuted; restore the user's choice when voice comes here
if (this.saved.has(s)) this.saved.set(s, want);
}
} }
onClosed(s: Session): void { onClosed(s: Session): void {
+12 -2
View File
@@ -24,6 +24,7 @@ export interface Settings {
theme: ThemeId; theme: ThemeId;
publicListUrl: string; // where the public server list comes from publicListUrl: string; // where the public server list comes from
arrangement: Arrangement; 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 // Window layouts; classic and stacked follow the Mumble desktop client's layouts of the same name
@@ -46,11 +47,12 @@ const defaults: Settings = {
embedHosts: [], embedHosts: [],
sendThumbnails: true, sendThumbnails: true,
compact: false, compact: false,
youtube: 'click', youtube: 'off',
linkPreviews: 'host', linkPreviews: 'host',
theme: 'dark', theme: 'dark',
publicListUrl: 'https://publist.mumble.info/v1/list?version=1.5.0', publicListUrl: 'https://publist.mumble.info/v1/list?version=1.5.0',
arrangement: 'side' arrangement: 'side',
hints: false
}; };
class SettingsStore { class SettingsStore {
@@ -58,9 +60,17 @@ class SettingsStore {
servers = $state<SavedServer[]>(loadList('mumh5.servers')); servers = $state<SavedServer[]>(loadList('mumh5.servers'));
// Pinned sha256 fingerprints of server certificates, keyed by host:port (trust on first use) // Pinned sha256 fingerprints of server certificates, keyed by host:port (trust on first use)
pins = $state<Record<string, string>>(load('mumh5.pins', {})); 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) // Local nicknames for other users, only visible to us; keyed by certificate hash (or name)
nicknames = $state<Record<string, string>>(load('mumh5.nicknames', {})); 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)); } saveSettings() { save('mumh5.settings', $state.snapshot(this.settings)); }
upsertServer(s: SavedServer) { upsertServer(s: SavedServer) {
+3 -3
View File
@@ -1,11 +1,11 @@
import { native } from './native.ts'; import { desktop } from './native.ts';
import { menu, type MenuItem } from './menu.svelte.ts'; import { menu, type MenuItem } from './menu.svelte.ts';
// Right-click menu for text fields, messages, links and images. Elements with their own menu // 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. // (users, channels, servers) call preventDefault, so Electron does not report those.
export function startTextMenu(): void { export function startTextMenu(): void {
if (!native) return; if (!desktop) return;
const api = native; const api = desktop;
api.onContextMenu(p => { api.onContextMenu(p => {
// Edit commands act on the focused element; keep it focused through the menu // Edit commands act on the focused element; keep it focused through the menu
+3 -3
View File
@@ -1,4 +1,4 @@
import { native } from './native.ts'; import { desktop } from './native.ts';
import { ICON_PATHS } from './icons.ts'; import { ICON_PATHS } from './icons.ts';
import { session, sessions } from './session.svelte.ts'; import { session, sessions } from './session.svelte.ts';
import { voice, type TransmitMode } from './audio/voice.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 // Mirrors voice state into the system tray and handles its menu
export function startTray(): void { export function startTray(): void {
if (!native) return; if (!desktop) return;
const api = native; const api = desktop;
$effect.root(() => { $effect.root(() => {
$effect(() => { $effect(() => {
+4 -1
View File
@@ -25,8 +25,11 @@ class Ui {
wizard = $state<'setup' | 'add' | null>(null); wizard = $state<'setup' | 'add' | null>(null);
// Quick audio popover from right-clicking mute (input) or deafen (output) // Quick audio popover from right-clicking mute (input) or deafen (output)
quickAudio = $state<{ kind: 'input' | 'output'; x: number; y: number } | null>(null); 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); 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) // Add-server dialog, optionally prefilled (from the public list)
addServer = $state<{ host?: string; port?: number; label?: string } | null>(null); addServer = $state<{ host?: string; port?: number; label?: string } | null>(null);
// Channel dialog: create a subchannel of `parent`, or edit channel `id` // Channel dialog: create a subchannel of `parent`, or edit channel `id`
+153
View File
@@ -0,0 +1,153 @@
// The browser build: what the desktop app does in its main process goes through the mumh5 web
// proxy instead (server/proxy.ts). Identities are kept in this browser and sent to the proxy
// when connecting, because the proxy presents the certificate to the Mumble server.
import type { CertDetails } from '../core/transport.ts';
import { WebSocketTransport } from '../core/ws-transport.ts';
import type { IdentitySummary, PlatformApi } from './native.ts';
interface StoredIdentity {
id: string;
name: string;
certPem: string;
keyPem: string;
fingerprint: string;
cert: CertDetails;
createdAt: string;
}
interface StoreData { identities: StoredIdentity[]; defaultId: string | null; setupDone: boolean }
const KEY = 'mumh5.identities';
// The proxy normally serves the page; VITE_PROXY_URL points a separately hosted page at it
const base = (() => {
const fixed = import.meta.env?.VITE_PROXY_URL as string | undefined;
if (fixed) return fixed.replace(/\/*$/, '/');
return typeof location !== 'undefined' ? new URL('.', location.href).href : '';
})();
async function call<T>(route: string, body?: unknown): Promise<T> {
let res: Response;
try {
res = await fetch(`${base}api/${route}`, body === undefined ? undefined
: { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
} catch {
throw new Error('Could not reach the proxy.');
}
const data = await res.json().catch(() => null);
if (!res.ok) throw new Error(data?.error ?? `The proxy answered ${res.status}.`);
return data as T;
}
const toBase64 = (bytes: Uint8Array) => { let s = ''; for (const b of bytes) s += String.fromCharCode(b); return btoa(s); };
const fromBase64 = (b64: string) => Uint8Array.from(atob(b64), c => c.charCodeAt(0));
function read(): StoreData {
try {
const d = JSON.parse(localStorage.getItem(KEY) ?? 'null');
if (d && Array.isArray(d.identities)) return d;
} catch { /* storage unavailable or damaged */ }
return { identities: [], defaultId: null, setupDone: false };
}
function write(d: StoreData): void {
// Unlike settings, losing this silently would lose the user's registrations
try { localStorage.setItem(KEY, JSON.stringify(d)); } catch { throw new Error('This browser does not allow storing the identity (private window or storage disabled).'); }
}
const summary = (d: StoreData, i: StoredIdentity): IdentitySummary =>
({ id: i.id, name: i.name, fingerprint: i.fingerprint, isDefault: i.id === d.defaultId, cert: i.cert });
type Fresh = { certPem: string; keyPem: string; fingerprint: string; cert: CertDetails; commonName: string };
function add(fresh: Fresh, name: string): IdentitySummary {
const d = read();
const existing = d.identities.find(i => i.fingerprint === fresh.fingerprint);
if (existing) throw new Error(`This certificate is already stored as "${existing.name}".`);
const stored: StoredIdentity = {
id: crypto.randomUUID(), name: name.trim() || fresh.commonName || 'Identity', certPem: fresh.certPem, keyPem: fresh.keyPem,
fingerprint: fresh.fingerprint, cert: fresh.cert, createdAt: new Date().toISOString()
};
d.identities.push(stored);
d.defaultId ??= stored.id;
write(d);
return summary(d, stored);
}
// The identity to connect with: the requested one, else the default, else a new one
async function identityFor(id?: string): Promise<StoredIdentity> {
let d = read();
if (!d.identities.length) {
add(await call<Fresh>('identity/create', { name: 'mumh5 user', email: '' }), 'My identity');
d = read();
}
return d.identities.find(i => i.id === id) ?? d.identities.find(i => i.id === d.defaultId) ?? d.identities[0];
}
function browserVersion(): string {
const m = /(Firefox|Edg|Chrome|Version)\/(\d+)/.exec(navigator.userAgent);
if (!m) return '';
return `${m[1] === 'Edg' ? 'Edge' : m[1] === 'Version' ? 'Safari' : m[1]} ${m[2]}`;
}
export const webApi: PlatformApi = {
platformInfo: async () => ({ os: 'Web', osVersion: browserVersion() }),
describeCerts: ders => call<CertDetails[]>('certs/describe', { ders: ders.map(toBase64) }),
identities: {
list: async () => { const d = read(); return { identities: d.identities.map(i => summary(d, i)), setupDone: d.setupDone }; },
create: async (name, email) => add(await call<Fresh>('identity/create', { name, email }), name),
importP12: async (bytes, password, name) => add(await call<Fresh>('identity/import', { p12: toBase64(bytes), password }), name),
// The desktop client's settings are out of a browser's reach
findMumble: async () => null,
importMumble: async () => { throw new Error('Not available in the browser. Export the certificate from Mumble and import the file.'); },
// Returns the file name; the browser decides where downloads go
exportP12: async (id, password) => {
const i = read().identities.find(x => x.id === id);
if (!i) throw new Error('Identity not found.');
const { p12 } = await call<{ p12: string }>('identity/export', { certPem: i.certPem, keyPem: i.keyPem, password, name: i.name });
const file = `${i.name.replace(/[^\w.-]+/g, '_') || 'identity'}.p12`;
const url = URL.createObjectURL(new Blob([fromBase64(p12)], { type: 'application/x-pkcs12' }));
const a = document.createElement('a');
a.href = url;
a.download = file;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 10000);
return `your downloads as ${file}`;
},
setDefault: async id => { const d = read(); if (d.identities.some(i => i.id === id)) d.defaultId = id; write(d); },
rename: async (id, name) => { const d = read(); const i = d.identities.find(x => x.id === id); if (i && name.trim()) i.name = name.trim(); write(d); },
remove: async id => {
const d = read();
d.identities = d.identities.filter(i => i.id !== id);
if (d.defaultId === id) d.defaultId = d.identities[0]?.id ?? null;
write(d);
},
finishSetup: async () => { const d = read(); d.setupDone = true; write(d); }
}
};
export async function openWebTransport(host: string, port: number, identityId?: string): Promise<WebSocketTransport> {
const identity = await identityFor(identityId);
return new WebSocketTransport(`${base.replace(/^http/, 'ws')}api/connect`, { host, port }, identity);
}
// Which servers the proxy connects to; the connect dialog offers these
export interface ProxyServer { host: string; port: number; label: string }
class ProxyInfo {
servers = $state<ProxyServer[]>([]);
any = $state(false);
loaded = $state(false);
error = $state('');
async load(): Promise<void> {
try {
const c = await call<{ servers: ProxyServer[]; any: boolean }>('config');
this.servers = c.servers;
this.any = c.any;
this.error = '';
} catch (e) {
this.error = (e as Error).message;
}
this.loaded = true;
}
}
export const proxyInfo = new ProxyInfo();
+6
View File
@@ -5,7 +5,13 @@ import App from './App.svelte';
import './themes.css'; import './themes.css';
import { startTray } from './lib/tray.svelte.ts'; import { startTray } from './lib/tray.svelte.ts';
import { startTextMenu } from './lib/text-menu.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')! }); mount(App, { target: document.getElementById('app')! });
startTray(); startTray();
startTextMenu(); startTextMenu();
startHints();
+2
View File
@@ -164,6 +164,8 @@
box-shadow: var(--sunken); box-shadow: var(--sunken);
} }
:root[data-theme="win95"] .composer .box textarea { box-shadow: none; } :root[data-theme="win95"] .composer .box textarea { box-shadow: none; }
/* Raised frame in the window face color around the sunken input */
:root[data-theme="win95"] .composer { border: 2px outset #c0c0c0; }
:root[data-theme="win95"] .rich .area, :root[data-theme="win95"] .rich textarea { box-shadow: none; } :root[data-theme="win95"] .rich .area, :root[data-theme="win95"] .rich textarea { box-shadow: none; }
:root[data-theme="win95"] input[type=range] { accent-color: #000080; } :root[data-theme="win95"] input[type=range] { accent-color: #000080; }
+57 -2
View File
@@ -10,6 +10,7 @@
import { store } from '../lib/settings.svelte.ts'; import { store } from '../lib/settings.svelte.ts';
import { drag } from '../lib/drag.svelte.ts'; import { drag } from '../lib/drag.svelte.ts';
import { sounds } from '../lib/audio/sounds.svelte.ts'; import { sounds } from '../lib/audio/sounds.svelte.ts';
import { renderIncoming } from '../lib/html.ts';
import type { Channel } from '../core/client.ts'; import type { Channel } from '../core/client.ts';
let { channel, depth = 0, onnavigate }: { channel: Channel; depth?: number; onnavigate: () => void } = $props(); 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 unread = $derived(session.unread[`ch:${channel.id}`] ?? 0);
const total = $derived(session.subtreeUserCount(channel.id)); 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 // Single click previews, double click acts. The single action waits briefly so a
// double click does not also flash the preview. // double click does not also flash the preview.
let timer: ReturnType<typeof setTimeout> | undefined; let timer: ReturnType<typeof setTimeout> | undefined;
@@ -69,7 +103,8 @@
oncontextmenu={e => menu.show(e, channelMenu(channel))} role="presentation" oncontextmenu={e => menu.show(e, channelMenu(channel))} role="presentation"
ondragover={e => { if (canDrop) { e.preventDefault(); over = true; } }} ondragover={e => { if (canDrop) { e.preventDefault(); over = true; } }}
ondragleave={() => (over = false)} ondragleave={() => (over = false)}
ondrop={onDrop}> ondrop={onDrop}
onmouseenter={hoverRow} onmousemove={trackPointer} onmouseleave={leaveRow}>
{#if children.length || users.length} {#if children.length || users.length}
<button class="caret" class:collapsed={!expanded} onclick={() => session.toggleExpanded(channel.id)} <button class="caret" class:collapsed={!expanded} onclick={() => session.toggleExpanded(channel.id)}
aria-label={expanded ? 'Collapse' : 'Expand'} aria-expanded={expanded}> 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 --> <!-- 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} <button class="name" title="Click for details, double-click to join" {...channelClicks}
draggable={channel.id !== 0} 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; }}> ondragend={() => { drag.item = null; over = false; }}>
{#if !channel.canEnter}<Icon name="lock" size={14} />{/if} {#if !channel.canEnter}<Icon name="lock" size={14} />{/if}
<span class="label">{channel.name || 'Root'}</span> <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 !expanded && total > 0}<span class="count" title="{total} {total === 1 ? 'person' : 'people'} inside">{total}</span>{/if}
{#if unread}<span class="badge">{unread}</span>{/if} {#if unread}<span class="badge">{unread}</span>{/if}
</button> </button>
{#if hasDescription}
<button class="desc" title="Show description" aria-label="Show description of {channel.name || 'Root'}"
onclick={showDescription}><span></span></button>
{/if}
</div> </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} {#if users.length && expanded}
<ul class="users"> <ul class="users">
@@ -144,6 +190,15 @@
.caret.collapsed svg { transform: rotate(-90deg); } .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; } .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; } .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; } .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; } .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; } .user { gap: 8px; text-align: left; font-size: 14px; user-select: none; }
+4 -4
View File
@@ -132,11 +132,11 @@
</div> </div>
<style> <style>
/* Same height as the user panel at the bottom of the sidebar; grows for longer messages */ /* Same height as the bar at the bottom of the sidebar, buttons centered like there; grows for longer messages */
.composer { flex: none; min-height: var(--footer-h); display: flex; flex-direction: column; justify-content: flex-end; padding: 0; background: var(--bg-0); } .composer { flex: none; min-height: var(--footer-h); display: flex; flex-direction: column; justify-content: flex-end; padding: 0; background: var(--bg-0); border: 1px solid var(--line); }
.frame { background: var(--bg-3); } .frame { flex: 1; display: flex; flex-direction: column; background: var(--bg-3); }
.frame :global(.toolbar) { background: var(--bg-2); } .frame :global(.toolbar) { background: var(--bg-2); }
.box { display: flex; align-items: flex-end; gap: 4px; min-height: var(--footer-h); padding: 6px 4px; } .box { flex: 1; display: flex; align-items: center; gap: 4px; padding: 0 8px; }
.input { position: relative; flex: 1; min-width: 0; padding: 8px 4px; max-height: 200px; overflow-y: auto; line-height: 1.4; outline: none; overflow-wrap: anywhere; white-space: pre-wrap; } .input { position: relative; flex: 1; min-width: 0; padding: 8px 4px; max-height: 200px; overflow-y: auto; line-height: 1.4; outline: none; overflow-wrap: anywhere; white-space: pre-wrap; }
.input.blank::before { content: attr(data-placeholder); position: absolute; left: 4px; top: 8px; color: var(--text-faint); pointer-events: none; } .input.blank::before { content: attr(data-placeholder); position: absolute; left: 4px; top: 8px; color: var(--text-faint); pointer-events: none; }
.input :global(img) { max-height: 120px; } .input :global(img) { max-height: 120px; }
+47 -8
View File
@@ -4,6 +4,8 @@
import { sessions } from '../lib/session.svelte.ts'; import { sessions } from '../lib/session.svelte.ts';
import { identities } from '../lib/identities.svelte.ts'; import { identities } from '../lib/identities.svelte.ts';
import { makeServerIcon } from '../lib/upload.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 // 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(); 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 label = $state(initial?.label ?? pre.label ?? '');
let host = $state(initial?.host ?? pre.host ?? ''); let host = $state(initial?.host ?? pre.host ?? '');
let port = $state(initial?.port ?? pre.port ?? 64738); 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 password = $state(initial?.password ?? '');
let identityId = $state(initial?.identityId ?? ''); 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 icon = $state(initial?.icon ?? '');
let iconError = $state(''); let iconError = $state('');
let iconPicker: HTMLInputElement; 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() { async function pickIcon() {
const file = iconPicker.files?.[0]; const file = iconPicker.files?.[0];
iconPicker.value = ''; iconPicker.value = '';
@@ -62,18 +91,27 @@
<Modal title={initial ? 'Edit server' : 'Add a server'} {onclose}> <Modal title={initial ? 'Edit server' : 'Add a server'} {onclose}>
<form id="connect" onsubmit={saveAndConnect}> <form id="connect" onsubmit={saveAndConnect}>
<label for="c-host">Address</label> {#if listed}
<div class="row"> <label for="c-host">Server</label>
<input id="c-host" bind:value={host} placeholder="mumble.example.com" autocomplete="off" required /> <select id="c-host" value={pick} onchange={e => choose(e.currentTarget.value)}>
<input class="port" type="number" bind:value={port} min="1" max="65535" aria-label="Port" /> {#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}
</div> </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> <label for="c-user">Username</label>
<input id="c-user" bind:value={username} autocomplete="username" required /> <input id="c-user" bind:value={username} autocomplete="username" required />
<label for="c-pass">Password <span class="opt">(optional)</span></label> <label for="c-pass">Password <span class="opt">(optional)</span></label>
<input id="c-pass" type="password" bind:value={password} autocomplete="current-password" /> <input id="c-pass" type="password" bind:value={password} autocomplete="current-password" />
{#if identities.list.length > 1} {#if identities.list.length > 1}
<label for="c-id">Identity</label> <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> <option value="">Default ({identities.default?.name})</option>
{#each identities.list as i (i.id)}<option value={i.id}>{i.name}</option>{/each} {#each identities.list as i (i.id)}<option value={i.id}>{i.name}</option>{/each}
</select> </select>
@@ -104,6 +142,7 @@
.icon-row { display: flex; align-items: center; gap: 8px; } .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 { 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; } .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; } .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); } .opt { text-transform: none; font-weight: 400; letter-spacing: 0; color: var(--text-faint); }
</style> </style>
+2 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <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 { identities } from '../lib/identities.svelte.ts';
import { store } from '../lib/settings.svelte.ts'; import { store } from '../lib/settings.svelte.ts';
import { ui } from '../lib/ui.svelte.ts'; import { ui } from '../lib/ui.svelte.ts';
@@ -20,7 +20,7 @@
function exportId(i: IdentitySummary) { function exportId(i: IdentitySummary) {
ui.prompt = { 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.', text: 'The .p12 file contains your private key. Protect it with a password; the desktop Mumble client can import it.',
onsubmit: pw => act(async () => { onsubmit: pw => act(async () => {
const path = await native!.identities.exportP12(i.id, pw); const path = await native!.identities.exportP12(i.id, pw);
+15 -4
View File
@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import Modal from './Modal.svelte'; import Modal from './Modal.svelte';
import Icon from './Icon.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'; import { identities } from '../lib/identities.svelte.ts';
// setup: first start (marks setup as done at the end); add: adding another identity from Settings // 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) => { const create = (e: SubmitEvent) => {
e.preventDefault(); 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) => { const importFile = async (e: SubmitEvent) => {
@@ -93,6 +100,7 @@
<Modal {title} onclose={mode === 'setup' ? () => {} : onclose} width={560}> <Modal {title} onclose={mode === 'setup' ? () => {} : onclose} width={560}>
{#if step === 'welcome'} {#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> <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"> <div class="choices">
<button class="choice" onclick={() => (step = 'create')}> <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> <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> <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> <label for="w-name">Name</label>
<!-- svelte-ignore a11y_autofocus --> <!-- 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> <label for="w-email">Email <span class="opt">(optional, stored in the certificate)</span></label>
<input id="w-email" type="email" bind:value={email} /> <input id="w-email" type="email" bind:value={email} />
</form> </form>
@@ -130,7 +139,7 @@
{:else if step === 'backup' && result} {:else if step === 'backup' && result}
<p>Identity <strong>{result.name}</strong> is ready.</p> <p>Identity <strong>{result.name}</strong> is ready.</p>
<p class="mono">{result.fingerprint}</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> <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" /> <input id="w-bpass" type="password" bind:value={backupPassword} autocomplete="new-password" />
<label for="w-bconf">Repeat password</label> <label for="w-bconf">Repeat password</label>
@@ -163,6 +172,8 @@
</Modal> </Modal>
<style> <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 { color: var(--text-dim); margin: 10px 0 0; }
p strong { color: var(--text); } p strong { color: var(--text); }
.choices { display: flex; flex-direction: column; gap: 8px; margin-top: 16px; } .choices { display: flex; flex-direction: column; gap: 8px; margin-top: 16px; }
+57 -10
View File
@@ -1,9 +1,12 @@
<script lang="ts"> <script lang="ts">
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import Modal from './Modal.svelte'; import Modal from './Modal.svelte';
import { native, type PublicServer, type PingResult } from '../lib/native.ts'; import Icon from './Icon.svelte';
import { desktop as native, type PublicServer, type PingResult } from '../lib/native.ts';
import { store } from '../lib/settings.svelte.ts'; import { store } from '../lib/settings.svelte.ts';
import { ui } from '../lib/ui.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(); let { onclose }: { onclose: () => void } = $props();
@@ -12,7 +15,10 @@
let error = $state(''); let error = $state('');
let search = $state(''); let search = $state('');
let continent = $state(''); let continent = $state('');
let sort = $state<'users' | 'ping' | 'name' | 'country'>('users'); type Sort = 'users' | 'ping' | 'name' | 'country';
let sort = $state<Sort>('users');
// Each column has a natural order (most people, lowest ping, A to Z); a second click flips it
let reversed = $state(false);
let pings = $state<Record<string, PingResult | null>>({}); let pings = $state<Record<string, PingResult | null>>({});
let alive = true; let alive = true;
// Ping queue; the generation drops answers from a previous list // Ping queue; the generation drops answers from a previous list
@@ -53,14 +59,24 @@
const list = servers.filter(s => (!continent || s.continent === continent) && const list = servers.filter(s => (!continent || s.continent === continent) &&
(!q || s.name.toLowerCase().includes(q) || s.country.toLowerCase().includes(q) || s.host.toLowerCase().includes(q))); (!q || s.name.toLowerCase().includes(q) || s.country.toLowerCase().includes(q) || s.host.toLowerCase().includes(q)));
const p = (s: PublicServer) => pings[key(s)]; const p = (s: PublicServer) => pings[key(s)];
const dir = reversed ? -1 : 1;
return list.sort((a, b) => { return list.sort((a, b) => {
if (sort === 'name') return a.name.localeCompare(b.name); if (sort === 'name') return dir * a.name.localeCompare(b.name);
if (sort === 'country') return a.country.localeCompare(b.country) || a.name.localeCompare(b.name); if (sort === 'country') return dir * a.country.localeCompare(b.country) || a.name.localeCompare(b.name);
if (sort === 'ping') return (p(a)?.ping ?? 1e9) - (p(b)?.ping ?? 1e9); // Servers that have not answered stay at the bottom in both directions
return (p(b)?.users ?? -1) - (p(a)?.users ?? -1); const pa = p(a), pb = p(b);
if (!pa || !pb) return pa ? -1 : pb ? 1 : 0;
return dir * (sort === 'ping' ? pa.ping - pb.ping : pb.users - pa.users);
}).slice(0, 300); }).slice(0, 300);
}); });
function sortBy(col: Sort) {
reversed = sort === col && !reversed;
sort = col;
}
// Most people first counts as descending, the other columns start ascending
const ariaSort = (col: Sort) => sort !== col ? 'none' : reversed === (col === 'users') ? 'ascending' : 'descending';
// Ping what the filter shows, a few at a time; each answer fills in users and latency // Ping what the filter shows, a few at a time; each answer fills in users and latency
function pingVisible() { function pingVisible() {
queue = servers.filter(s => !(key(s) in pings)); queue = servers.filter(s => !(key(s) in pings));
@@ -80,6 +96,18 @@
}); });
} }
// 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 || 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);
sessions.open(server);
onclose();
}
function add(s: PublicServer) { function add(s: PublicServer) {
ui.addServer = { host: s.host, port: s.port, label: s.name }; ui.addServer = { host: s.host, port: s.port, label: s.name };
onclose(); onclose();
@@ -106,7 +134,7 @@
<option value="">All continents</option> <option value="">All continents</option>
{#each Object.entries(CONTINENTS) as [code, name] (code)}<option value={code}>{name}</option>{/each} {#each Object.entries(CONTINENTS) as [code, name] (code)}<option value={code}>{name}</option>{/each}
</select> </select>
<select bind:value={sort} aria-label="Sort by"> <select bind:value={sort} onchange={() => (reversed = false)} aria-label="Sort by">
<option value="users">Most people</option> <option value="users">Most people</option>
<option value="ping">Lowest ping</option> <option value="ping">Lowest ping</option>
<option value="name">Name</option> <option value="name">Name</option>
@@ -121,7 +149,14 @@
<div class="row"><button class="btn" onclick={load}>Try again</button><button class="btn" onclick={changeSource}>Use another list...</button></div> <div class="row"><button class="btn" onclick={load}>Try again</button><button class="btn" onclick={changeSource}>Use another list...</button></div>
{:else} {:else}
<table> <table>
<thead><tr><th>Server</th><th>Country</th><th class="num">People</th><th class="num">Ping</th><th></th></tr></thead> <thead><tr>
{#each [['name', 'Server'], ['country', 'Country'], ['users', 'People'], ['ping', 'Ping']] as const as [col, text] (col)}
<th class:num={col === 'users' || col === 'ping'} aria-sort={ariaSort(col)}>
<button class="sort" class:on={sort === col} title="Sort by {text.toLowerCase()}" onclick={() => sortBy(col)}>{text}<span class="caret" class:up={ariaSort(col) === 'ascending'}></span></button>
</th>
{/each}
<th></th>
</tr></thead>
<tbody> <tbody>
{#each shown as s (key(s))} {#each shown as s (key(s))}
{@const p = pings[key(s)]} {@const p = pings[key(s)]}
@@ -134,7 +169,12 @@
<td>{s.country}</td> <td>{s.country}</td>
<td class="num">{p ? `${p.users} / ${p.maxUsers}` : key(s) in pings && p === null ? '...' : ''}</td> <td class="num">{p ? `${p.users} / ${p.maxUsers}` : key(s) in pings && p === null ? '...' : ''}</td>
<td class="num" class:good={p && p.ping < 60} class:bad={p && p.ping > 180}>{p ? `${Math.round(p.ping)} ms` : ''}</td> <td class="num" class:good={p && p.ping < 60} class:bad={p && p.ping > 180}>{p ? `${Math.round(p.ping)} ms` : ''}</td>
<td class="act"><button class="btn" onclick={() => add(s)}>Add</button></td> <td class="act">
<span class="acts">
<button class="icon-btn" title="Add to your servers" aria-label="Add" onclick={() => add(s)}><Icon name="plus" size={18} /></button>
<button class="icon-btn go" title="Connect" aria-label="Connect" onclick={() => connect(s)}><Icon name="arrow-right" size={18} /></button>
</span>
</td>
</tr> </tr>
{:else} {:else}
<tr><td colspan="5" class="dim">No servers match.</td></tr> <tr><td colspan="5" class="dim">No servers match.</td></tr>
@@ -157,6 +197,12 @@
.bar select { width: auto; flex: 0 0 auto; } .bar select { width: auto; flex: 0 0 auto; }
table { width: 100%; border-collapse: collapse; margin-top: 12px; font-size: 14px; } table { width: 100%; border-collapse: collapse; margin-top: 12px; font-size: 14px; }
th { text-align: left; font-size: 12px; color: var(--text-faint); font-weight: 600; padding: 6px 8px; border-bottom: 1px solid var(--line); position: sticky; top: 0; background: var(--bg-1); } th { text-align: left; font-size: 12px; color: var(--text-faint); font-weight: 600; padding: 6px 8px; border-bottom: 1px solid var(--line); position: sticky; top: 0; background: var(--bg-1); }
.sort { font: inherit; color: inherit; display: inline-flex; align-items: center; gap: 6px; }
.sort:hover, .sort.on { color: var(--text); }
/* Triangle from borders; only the active column shows it */
.caret { border: 4px solid transparent; border-top-color: currentColor; margin-top: 4px; visibility: hidden; }
.caret.up { border-top-color: transparent; border-bottom-color: currentColor; margin-top: -4px; }
.sort.on .caret { visibility: visible; }
td { padding: 6px 8px; border-bottom: 1px solid var(--line); vertical-align: middle; } td { padding: 6px 8px; border-bottom: 1px solid var(--line); vertical-align: middle; }
tr:hover td { background: var(--bg-hover); } tr:hover td { background: var(--bg-hover); }
.name { max-width: 360px; } .name { max-width: 360px; }
@@ -167,7 +213,8 @@
.good { color: var(--speaking); } .good { color: var(--speaking); }
.bad { color: var(--warn); } .bad { color: var(--warn); }
.act { width: 1%; } .act { width: 1%; }
.act .btn { padding: 4px 12px; font-size: 13px; } .acts { display: flex; gap: 4px; }
.go, .go:hover { color: var(--speaking); }
.dim { color: var(--text-dim); } .dim { color: var(--text-dim); }
.small { font-size: 12px; margin-top: 10px; } .small { font-size: 12px; margin-top: 10px; }
.error { color: var(--danger); } .error { color: var(--danger); }
+8
View File
@@ -8,6 +8,7 @@
import { session } from '../lib/session.svelte.ts'; import { session } from '../lib/session.svelte.ts';
import { store } from '../lib/settings.svelte.ts'; import { store } from '../lib/settings.svelte.ts';
import { menu } from '../lib/menu.svelte.ts'; import { menu } from '../lib/menu.svelte.ts';
import { ui } from '../lib/ui.svelte.ts';
import { userMenu, changeNamePrompt } from '../lib/actions.ts'; import { userMenu, changeNamePrompt } from '../lib/actions.ts';
import { voice } from '../lib/audio/voice.svelte.ts'; import { voice } from '../lib/audio/voice.svelte.ts';
import { renderIncoming } from '../lib/html.ts'; import { renderIncoming } from '../lib/html.ts';
@@ -45,6 +46,13 @@
editing = true; 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() { function saveComment() {
session.setComment(draft); session.setComment(draft);
editing = false; editing = false;
+53
View File
@@ -0,0 +1,53 @@
<script lang="ts">
import Modal from './Modal.svelte';
import { session } from '../lib/session.svelte.ts';
let { onclose }: { onclose: () => void } = $props();
const info = $derived(session.serverInfo);
const server = $derived(session.server);
const cert = $derived(session.serverCert);
const voice = $derived(session.voiceTransport);
</script>
<Modal title="Server information" {onclose} width={520}>
{#if info && server}
<h3>Server</h3>
<dl>
<dt>Address</dt><dd class="mono">{server.host}:{server.port}</dd>
<dt>Version</dt><dd>{info.version || 'unknown'}</dd>
<dt>People</dt><dd>{info.users}{info.config.maxUsers ? ` of ${info.config.maxUsers}` : ''}</dd>
<dt>Channels</dt><dd>{info.channels}</dd>
</dl>
<h3>Connection</h3>
<dl>
<dt>Ping (TCP)</dt><dd>{session.rtt ? `${session.rtt} ms` : 'n/a'}</dd>
<dt>Voice</dt><dd>{voice.udp ? `Encrypted UDP${voice.rtt ? `, ${Math.round(voice.rtt)} ms` : ''}` : 'Through the TCP connection'}</dd>
<dt>Certificate</dt><dd>{cert ? (cert.authorized ? 'Signed by a trusted authority' : 'Self-signed or untrusted, pinned on first connect') : 'unknown'}</dd>
{#if cert}<dt>SHA-256</dt><dd class="mono">{cert.fingerprint}</dd>{/if}
</dl>
<h3>Limits</h3>
<dl>
<dt>Voice bitrate</dt><dd>{info.maxBandwidth ? `up to ${Math.round(info.maxBandwidth / 1000)} kbit/s` : 'unknown'}</dd>
<dt>Message length</dt><dd>{info.config.messageLength ? `${info.config.messageLength} characters` : 'unlimited'}</dd>
<dt>Image messages</dt><dd>{info.config.imageMessageLength ? `${info.config.imageMessageLength} characters` : 'unlimited'}</dd>
<dt>HTML in messages</dt><dd>{info.config.allowHtml ? 'allowed' : 'not allowed'}</dd>
</dl>
{/if}
{#snippet footer()}
<button class="btn primary" onclick={onclose}>Close</button>
{/snippet}
</Modal>
<style>
h3 { margin: 18px 0 6px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-dim); }
h3:first-child { margin-top: 8px; }
dl { display: grid; grid-template-columns: 130px 1fr; gap: 4px 12px; margin: 0; font-size: 14px; }
dt { color: var(--text-faint); }
dd { margin: 0; overflow-wrap: anywhere; user-select: text; }
.mono { font: 12px var(--mono); }
@media (max-width: 500px) { dl { grid-template-columns: 1fr; } dt { margin-top: 6px; } }
</style>
+3 -1
View File
@@ -3,6 +3,7 @@
import { store, type SavedServer } from '../lib/settings.svelte.ts'; import { store, type SavedServer } from '../lib/settings.svelte.ts';
import { sessions } from '../lib/session.svelte.ts'; import { sessions } from '../lib/session.svelte.ts';
import { menu } from '../lib/menu.svelte.ts'; import { menu } from '../lib/menu.svelte.ts';
import { desktop } from '../lib/native.ts';
import { makeServerIcon } from '../lib/upload.ts'; import { makeServerIcon } from '../lib/upload.ts';
let { onadd, onedit, onsettings, onbrowse }: let { onadd, onedit, onsettings, onbrowse }:
@@ -88,7 +89,8 @@
{#if dropIndex === store.servers.length && dragging}<div class="drop-line"></div>{/if} {#if dropIndex === store.servers.length && dragging}<div class="drop-line"></div>{/if}
<input bind:this={iconPicker} type="file" accept="image/*" hidden onchange={pickIcon} /> <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="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> <div class="spacer"></div>
<button class="tile ghost" title="Settings" onclick={onsettings}><Icon name="settings" /></button> <button class="tile ghost" title="Settings" onclick={onsettings}><Icon name="settings" /></button>
</nav> </nav>
+25 -15
View File
@@ -5,16 +5,20 @@
import AppearanceSettings from './AppearanceSettings.svelte'; import AppearanceSettings from './AppearanceSettings.svelte';
import SoundSettings from './SoundSettings.svelte'; import SoundSettings from './SoundSettings.svelte';
import { store } from '../lib/settings.svelte.ts'; 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 { 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 // 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; const s = store.settings;
let embedHosts = $state(s.embedHosts.join('\n')); let embedHosts = $state(s.embedHosts.join('\n'));
let testResult = $state(''); let testResult = $state('');
// Settings that only work through the upload host are disabled until it is set up
const hasUpload = $derived(!!(s.uploadHost.trim() && s.uploadKey.trim()));
const needsUpload = $derived(hasUpload ? undefined : 'Needs an upload host and key');
// Changes apply right away // Changes apply right away
function saveFiles() { function saveFiles() {
@@ -35,11 +39,12 @@
} }
} }
type Tab = 'voice' | 'sounds' | 'appearance' | 'files' | 'identities'; type Tab = 'voice' | 'sounds' | 'appearance' | 'accessibility' | 'files' | 'identities';
const tabs: { id: Tab; label: string }[] = [ const tabs: { id: Tab; label: string }[] = [
{ id: 'voice', label: 'Voice' }, { id: 'voice', label: 'Voice' },
{ id: 'sounds', label: 'Sounds' }, { id: 'sounds', label: 'Sounds' },
{ id: 'appearance', label: 'Appearance' }, { id: 'appearance', label: 'Appearance' },
{ id: 'accessibility', label: 'Accessibility' },
{ id: 'files', label: 'Chat and files' }, { id: 'files', label: 'Chat and files' },
...(native ? [{ id: 'identities' as Tab, label: 'Identities' }] : []) ...(native ? [{ id: 'identities' as Tab, label: 'Identities' }] : [])
]; ];
@@ -58,6 +63,9 @@
<SoundSettings /> <SoundSettings />
{:else if tab === 'appearance'} {:else if tab === 'appearance'}
<AppearanceSettings /> <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'} {: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> <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> <label for="s-host">Upload host</label>
@@ -65,39 +73,39 @@
<label for="s-key">Upload key</label> <label for="s-key">Upload key</label>
<input id="s-key" type="password" bind:value={s.uploadKey} onchange={saveFiles} placeholder="cu_..." autocomplete="off" /> <input id="s-key" type="password" bind:value={s.uploadKey} onchange={saveFiles} placeholder="cu_..." autocomplete="off" />
<div class="row"> <div class="row">
<div> <div title={needsUpload}>
<label for="s-exp">Files expire after</label> <label for="s-exp" class:off={!hasUpload}>Files expire after</label>
<select id="s-exp" bind:value={s.uploadExpiry} onchange={saveFiles}> <select id="s-exp" bind:value={s.uploadExpiry} onchange={saveFiles} disabled={!hasUpload}>
<option value="1h">1 hour</option> <option value="1h">1 hour</option>
<option value="24h">24 hours</option> <option value="24h">24 hours</option>
<option value="7d">7 days</option> <option value="7d">7 days</option>
<option value="30d">30 days</option> <option value="30d">30 days</option>
</select> </select>
</div> </div>
<button class="btn" disabled={!s.uploadHost || !s.uploadKey} onclick={test}>Test key</button> <button class="btn" disabled={!hasUpload} title={needsUpload} onclick={test}>Test key</button>
</div> </div>
{#if testResult}<p class="help">{testResult}</p>{/if} {#if testResult}<p class="help">{testResult}</p>{/if}
<label class="check"><input type="checkbox" bind:checked={s.sendThumbnails} onchange={saveFiles} /> Send image previews for regular Mumble clients</label> <label class="check" class:off={!hasUpload} title={needsUpload}><input type="checkbox" bind:checked={s.sendThumbnails} onchange={saveFiles} disabled={!hasUpload} /> Send image previews for regular Mumble clients</label>
<h3>Embeds</h3> <h3>Embeds</h3>
<label for="s-embed">Also show media inline from these hosts</label> <label for="s-embed">Also show media inline from these hosts</label>
<textarea id="s-embed" bind:value={embedHosts} onchange={saveFiles} rows="3" placeholder="one host per line, e.g. i.example.com"></textarea> <textarea id="s-embed" bind:value={embedHosts} onchange={saveFiles} rows="3" placeholder="one host per line, e.g. i.example.com"></textarea>
<p class="help">Links from other hosts stay plain links, so opening a chat never contacts servers you did not choose.</p> <p class="help">Links from other hosts stay plain links, so opening a chat never contacts servers you did not choose.</p>
<label for="s-lp">Link previews</label> <label for="s-lp">Link previews</label>
<select id="s-lp" bind:value={s.linkPreviews} onchange={saveFiles}> <!-- Shows the mode in effect; a stored "upload host" choice comes back once the key is set -->
<option value="host">Fetched by the upload host (keeps your IP private)</option> <select id="s-lp" value={previewMode()} onchange={e => { s.linkPreviews = e.currentTarget.value as typeof s.linkPreviews; saveFiles(); }}>
<option value="direct">Fetched by this computer (linked sites see your IP)</option> <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" disabled={!desktop}>Fetched by this computer (linked sites see your IP){desktop ? '' : ' - needs the desktop app'}</option>
<option value="off">Off</option> <option value="off">Off</option>
</select> </select>
{#if s.linkPreviews === 'host' && !(s.uploadHost && s.uploadKey)}<p class="help">Needs an upload host and key above; until then no previews are shown.</p>{/if}
<label for="s-yt">YouTube links</label> <label for="s-yt">YouTube links</label>
<select id="s-yt" bind:value={s.youtube} onchange={saveFiles}> <select id="s-yt" bind:value={s.youtube} onchange={saveFiles}>
<option value="off">Plain links</option>
<option value="click">Player card, loads from YouTube when clicked</option> <option value="click">Player card, loads from YouTube when clicked</option>
<option value="thumbnail">Player card with thumbnail (loads the thumbnail from YouTube)</option> <option value="thumbnail">Player card with thumbnail (loads the thumbnail from YouTube)</option>
<option value="off">Plain links</option>
</select> </select>
{:else} {: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 /> <IdentityList />
{/if} {/if}
@@ -116,5 +124,7 @@
.row > div { flex: 1; } .row > div { flex: 1; }
.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 { 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; } .check input { width: auto; }
.off { color: var(--text-faint); }
select:disabled, input:disabled { opacity: 0.5; }
textarea { resize: vertical; } textarea { resize: vertical; }
</style> </style>
+65 -16
View File
@@ -22,6 +22,9 @@
const roots = $derived(session.childrenOf(null)); const roots = $derived(session.childrenOf(null));
const self = $derived(session.self); const self = $derived(session.self);
// Without a connection the buttons show the remembered choice, applied at the next connect
const muted = $derived(self ? self.selfMute : sessions.mute);
const deafened = $derived(self ? self.selfDeaf : sessions.deaf);
const cert = $derived(session.serverCert); const cert = $derived(session.serverCert);
const trusted = $derived(!!cert?.authorized); const trusted = $derived(!!cert?.authorized);
@@ -40,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') { function openQuick(e: MouseEvent, kind: 'input' | 'output') {
e.preventDefault(); e.preventDefault();
const r = (e.currentTarget as HTMLElement).getBoundingClientRect(); const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
@@ -111,8 +128,8 @@
<button class="btn" onclick={() => sessions.setVoice(sessions.active)}>Talk here</button> <button class="btn" onclick={() => sessions.setVoice(sessions.active)}>Talk here</button>
</div> </div>
{/if} {/if}
{#if self} <footer class="me">
<footer class="me"> {#if self}
<button class="profile" title="Your profile" onclick={() => { session.showUser(self.session); ui.panelOpen = true; }} <button class="profile" title="Your profile" onclick={() => { session.showUser(self.session); ui.panelOpen = true; }}
oncontextmenu={e => menu.show(e, userMenu(self))}> oncontextmenu={e => menu.show(e, userMenu(self))}>
<Avatar name={self.name} size={32} speaking={voice.transmitting} /> <Avatar name={self.name} size={32} speaking={voice.transmitting} />
@@ -121,18 +138,42 @@
<span>{voice.error ? 'Voice unavailable' : voice.settings.mode === 'ptt' && !self.selfMute ? (voice.pttDown ? 'Talking' : 'Push to talk') : session.channel(self.channelId)?.name || 'Root'}</span> <span>{voice.error ? 'Voice unavailable' : voice.settings.mode === 'ptt' && !self.selfMute ? (voice.pttDown ? 'Talking' : 'Push to talk') : session.channel(self.channelId)?.name || 'Root'}</span>
</span> </span>
</button> </button>
<button class="icon-btn" class:on={self.selfMute} title={self.selfMute ? 'Unmute (right-click for microphone options)' : 'Mute (right-click for microphone options)'} {:else}
aria-label={self.selfMute ? 'Unmute' : 'Mute'} <span class="who offline-who" title="Mute and deafen apply when you connect">
onclick={() => session.setSelfMute(!self.selfMute)} oncontextmenu={e => openQuick(e, 'input')}> <strong>{session.status === 'connecting' ? 'Connecting...' : 'Not connected'}</strong>
<Icon name={self.selfMute ? 'mic-off' : 'mic'} size={18} /> <span>{deafened ? 'Deafened' : muted ? 'Muted' : 'Microphone on'}</span>
</button> </span>
<button class="icon-btn" class:on={self.selfDeaf} title={self.selfDeaf ? 'Undeafen (right-click for output options)' : 'Deafen (right-click for output options)'} {/if}
aria-label={self.selfDeaf ? 'Undeafen' : 'Deafen'} <div class="tools">
onclick={() => session.setSelfDeaf(!self.selfDeaf)} oncontextmenu={e => openQuick(e, 'output')}> <button class="icon-btn info" title="Server information" aria-label="Server information"
<Icon name={self.selfDeaf ? 'headphones-off' : 'headphones'} size={18} /> disabled={session.status !== 'connected'} onclick={() => (ui.serverInfo = true)}>
</button> <Icon name="info" size={18} />
</footer> </button>
{/if} <button class="icon-btn" class:on={muted} title={muted ? 'Unmute (right-click for microphone options)' : 'Mute (right-click for microphone options)'}
aria-label={muted ? 'Unmute' : 'Mute'}
onclick={() => session.setSelfMute(!muted)} oncontextmenu={e => openQuick(e, 'input')}>
<Icon name={muted ? 'mic-off' : 'mic'} size={18} />
</button>
<button class="icon-btn" class:on={deafened} title={deafened ? 'Undeafen (right-click for output options)' : 'Deafen (right-click for output options)'}
aria-label={deafened ? 'Undeafen' : 'Deafen'}
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> </aside>
<style> <style>
@@ -162,10 +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 { 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 span { flex: 1; min-width: 0; }
.elsewhere .btn { padding: 4px 10px; font-size: 12px; } .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 */
.profile { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; padding: 2px; text-align: left; } .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 1 110px; min-width: 0; display: flex; align-items: center; gap: 8px; padding: 2px; text-align: left; }
.profile:hover { background: var(--bg-hover); } .profile:hover { background: var(--bg-hover); }
.who { flex: 1; min-width: 0; display: flex; flex-direction: column; line-height: 1.2; } .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 strong, .who span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.who span { font-size: 12px; color: var(--text-dim); } .who span { font-size: 12px; color: var(--text-dim); }
.info, .info:hover { color: var(--warn); }
.offline-who { flex: 1 1 110px; padding: 2px 2px 2px 6px; color: var(--text-dim); }
</style> </style>
+12 -1
View File
@@ -23,6 +23,11 @@
const client = session.client; const client = session.client;
const user = $derived(session.user(who)); 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 => { const off = client?.on('userStats', msg => {
if (msg.session !== who) return; 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>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> <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.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> </dl>
{#if stats.version} {#if stats.version}
+114 -7
View File
@@ -78,7 +78,8 @@ try {
// First start: identity setup wizard // First start: identity setup wizard
await page.getByRole('dialog', { name: 'Set up your identity' }).waitFor(); await page.getByRole('dialog', { name: 'Set up your identity' }).waitFor();
await page.getByRole('button', { name: /Create a new identity/ }).click(); 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: 'Create', exact: true }).click();
await page.getByRole('button', { name: 'Skip for now' }).click(); await page.getByRole('button', { name: 'Skip for now' }).click();
await page.getByRole('button', { name: 'Done' }).click(); await page.getByRole('button', { name: 'Done' }).click();
@@ -92,6 +93,18 @@ try {
await page.getByRole('button', { name: 'Done', exact: true }).click(); await page.getByRole('button', { name: 'Done', exact: true }).click();
console.log('ok: welcome page without composer, manage identities'); console.log('ok: welcome page without composer, manage identities');
// Mute and deafen are there without a connection
const me = page.locator('.me');
await me.getByText('Not connected').waitFor();
assert.ok(await me.getByRole('button', { name: 'Server information' }).isDisabled());
await me.getByRole('button', { name: 'Deafen', exact: true }).click();
await me.getByRole('button', { name: 'Unmute', exact: true }).waitFor();
await me.getByText('Deafened').waitFor();
await me.getByRole('button', { name: 'Unmute', exact: true }).click();
await me.getByRole('button', { name: 'Deafen', exact: true }).waitFor();
await me.getByRole('button', { name: 'Mute', exact: true }).waitFor();
console.log('ok: mute and deafen shown while disconnected');
// Settings: upload host + key // Settings: upload host + key
if (process.env.UPLOAD_HOST && process.env.UPLOAD_KEY) { if (process.env.UPLOAD_HOST && process.env.UPLOAD_KEY) {
await page.getByTitle('Settings').click(); await page.getByTitle('Settings').click();
@@ -107,7 +120,7 @@ try {
await page.getByTitle('Add a server').click(); await page.getByTitle('Add a server').click();
await page.getByLabel('Address').fill(host); await page.getByLabel('Address').fill(host);
await page.getByLabel('Port').fill(port); 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.getByLabel('Label').fill('Test Server');
await page.getByRole('button', { name: 'Save and connect' }).click(); await page.getByRole('button', { name: 'Save and connect' }).click();
await page.getByText(/Connected/).first().waitFor(); await page.getByText(/Connected/).first().waitFor();
@@ -256,15 +269,23 @@ try {
await page.locator('.chat .composer').getByRole('button', { name: 'Text formatting' }).click(); await page.locator('.chat .composer').getByRole('button', { name: 'Text formatting' }).click();
console.log('ok: formatted message from the toolbar'); console.log('ok: formatted message from the toolbar');
// YouTube links become a click-to-play card; nothing loads from YouTube before the click // YouTube links are plain links by default
bob.sendText({ channels: [0] }, 'watch <a href="https://youtu.be/dQw4w9WgXcQ?t=1m30s">https://youtu.be/dQw4w9WgXcQ?t=1m30s</a>'); bob.sendText({ channels: [0] }, 'watch <a href="https://youtu.be/dQw4w9WgXcQ?t=1m30s">https://youtu.be/dQw4w9WgXcQ?t=1m30s</a>');
const card = page.locator('.msg', { hasText: 'watch' }).locator('.yt'); const ytMsg = page.locator('.msg', { hasText: 'watch' });
await ytMsg.getByRole('link', { name: 'https://youtu.be/dQw4w9WgXcQ?t=1m30s' }).waitFor();
assert.equal(await ytMsg.locator('.yt').count(), 0, 'no YouTube card by default');
// Turned on, they become a click-to-play card; nothing loads from YouTube before the click
await page.getByTitle('Settings').click();
await page.getByRole('tab', { name: 'Chat and files' }).click();
await page.getByLabel('YouTube links').selectOption('click');
await page.getByRole('button', { name: 'Done', exact: true }).click();
const card = ytMsg.locator('.yt');
await card.getByText('from 1:30, click to play').waitFor(); await card.getByText('from 1:30, click to play').waitFor();
assert.equal(await card.locator('iframe').count(), 0); assert.equal(await card.locator('iframe').count(), 0);
assert.equal(await card.locator('.facade').evaluate(el => getComputedStyle(el).backgroundImage), 'none', 'no thumbnail request by default'); assert.equal(await card.locator('.facade').evaluate(el => getComputedStyle(el).backgroundImage), 'none', 'no thumbnail request in click mode');
await card.locator('.facade').click(); await card.locator('.facade').click();
assert.equal(await card.locator('iframe').getAttribute('src'), 'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?autoplay=1&rel=0&start=90'); assert.equal(await card.locator('iframe').getAttribute('src'), 'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?autoplay=1&rel=0&start=90');
console.log('ok: YouTube link embedded as click-to-play'); console.log('ok: YouTube link plain by default, click-to-play when turned on');
// Bob -> DM // Bob -> DM
const alice = [...bob.users.values()].find(u => u.name === 'alice')!; const alice = [...bob.users.values()].find(u => u.name === 'alice')!;
@@ -311,8 +332,28 @@ try {
assert.equal(await page.locator('.rail').evaluate(el => getComputedStyle(el).backgroundColor), 'rgb(0, 128, 128)'); assert.equal(await page.locator('.rail').evaluate(el => getComputedStyle(el).backgroundColor), 'rgb(0, 128, 128)');
await page.getByRole('radio', { name: 'Dark' }).click(); await page.getByRole('radio', { name: 'Dark' }).click();
assert.equal(await page.evaluate(() => document.documentElement.dataset.theme), undefined); 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(); 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) // 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!); const box = (sel: string) => page.locator(sel).first().boundingBox().then(b => b!);
@@ -359,6 +400,15 @@ try {
const row = pub.locator('tr', { hasText: 'E2E Murmur' }); const row = pub.locator('tr', { hasText: 'E2E Murmur' });
await row.getByText(/^\d+ \/ \d+$/).waitFor({ timeout: 8000 }); await row.getByText(/^\d+ \/ \d+$/).waitFor({ timeout: 8000 });
await row.getByText(/ms$/).waitFor(); await row.getByText(/ms$/).waitFor();
await row.getByRole('button', { name: 'Connect' }).waitFor();
// Column headers sort; a second click reverses
const firstRow = pub.locator('tbody tr').first();
await pub.getByRole('button', { name: 'Server', exact: true }).click();
await firstRow.getByText('E2E Murmur').waitFor();
await pub.getByRole('button', { name: 'Server', exact: true }).click();
await firstRow.getByText('Offline one').waitFor();
await pub.getByRole('button', { name: 'People', exact: true }).click();
await firstRow.getByText('E2E Murmur').waitFor();
await row.getByRole('button', { name: 'Add' }).click(); await row.getByRole('button', { name: 'Add' }).click();
assert.equal(await page.getByLabel('Address').inputValue(), host); assert.equal(await page.getByLabel('Address').inputValue(), host);
assert.equal(await page.getByLabel('Port').inputValue(), port); assert.equal(await page.getByLabel('Port').inputValue(), port);
@@ -532,6 +582,25 @@ try {
await page.locator('.panel h3', { hasText: 'bob' }).waitFor(); await page.locator('.panel h3', { hasText: 'bob' }).waitFor();
console.log('ok: user context menu and profile'); console.log('ok: user context menu and profile');
// Server information from the button next to mute
await page.locator('.me').getByRole('button', { name: 'Server information' }).click();
const srvInfo = page.getByRole('dialog', { name: 'Server information' });
await srvInfo.getByText(`${host}:${port}`).waitFor();
await srvInfo.getByText(/^\d+ of \d+$/).waitFor();
await srvInfo.getByText(/^\d+ characters$/).first().waitFor();
await srvInfo.getByRole('button', { name: 'Close', exact: true }).last().click();
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 // Information: own connection shows client details and the full certificate
await page.locator('.sidebar .user.self').click({ button: 'right' }); await page.locator('.sidebar .user.self').click({ button: 'right' });
await page.getByRole('menuitem', { name: 'Information' }).click(); await page.getByRole('menuitem', { name: 'Information' }).click();
@@ -590,6 +659,29 @@ try {
assert.equal(await page.locator('.me .who span').textContent(), 'Root'); assert.equal(await page.locator('.me .who span').textContent(), 'Root');
console.log('ok: single click shows description without joining'); 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 // Side chat sends to that channel while staying in Root
const lobby = [...bob.channels.values()].find(c => c.name === 'Lobby')!; const lobby = [...bob.channels.values()].find(c => c.name === 'Lobby')!;
await page.locator('.panel [role=tab]', { hasText: 'Chat' }).click(); await page.locator('.panel [role=tab]', { hasText: 'Chat' }).click();
@@ -721,6 +813,16 @@ try {
await page.getByRole('button', { name: 'Delete channel' }).click(); await page.getByRole('button', { name: 'Delete channel' }).click();
await bobSees('deleted', () => !chByName('Team Room')); await bobSees('deleted', () => !chByName('Team Room'));
console.log('ok: channel deleted'); 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 // Without an upload host, images are sent inline within Mumble's image limit
@@ -729,6 +831,11 @@ try {
await page.getByTitle('Settings').click(); await page.getByTitle('Settings').click();
await page.getByRole('tab', { name: 'Chat and files' }).click(); await page.getByRole('tab', { name: 'Chat and files' }).click();
await page.getByLabel('Upload host').fill(''); await page.getByLabel('Upload host').fill('');
// Settings that need the upload host are disabled and previews show as off
assert.ok(await page.getByLabel('Files expire after').isDisabled());
assert.ok(await page.getByLabel('Send image previews for regular Mumble clients').isDisabled());
assert.equal(await page.getByLabel('Link previews').inputValue(), 'off');
assert.ok(await page.getByLabel('Link previews').locator('option[value=host]').isDisabled());
await page.getByRole('button', { name: 'Done', exact: true }).click(); await page.getByRole('button', { name: 'Done', exact: true }).click();
const big = path.join(userData, 'big.png'); const big = path.join(userData, 'big.png');
writeFileSync(big, testPng(1600, 1200)); writeFileSync(big, testPng(1600, 1200));
+1 -1
View File
@@ -81,7 +81,7 @@ const page = await app.firstWindow();
await page.setViewportSize({ width: 1440, height: 900 }); await page.setViewportSize({ width: 1440, height: 900 });
await page.getByRole('button', { name: /Create a new identity/ }).click(); 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: 'Create', exact: true }).click();
await page.getByRole('button', { name: 'Skip for now' }).click(); await page.getByRole('button', { name: 'Skip for now' }).click();
await page.getByRole('button', { name: 'Done' }).click(); await page.getByRole('button', { name: 'Done' }).click();
+17
View File
@@ -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());
+168
View File
@@ -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();
}
+191
View File
@@ -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();
});
});
+1 -1
View File
@@ -11,5 +11,5 @@
"skipLibCheck": true, "skipLibCheck": true,
"types": ["node", "svelte", "vite/client"] "types": ["node", "svelte", "vite/client"]
}, },
"include": ["src/**/*", "electron/**/*", "test/**/*"] "include": ["src/**/*", "electron/**/*", "server/**/*", "test/**/*"]
} }
+5 -3
View File
@@ -1,8 +1,10 @@
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte'; import { svelte } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({ export default defineConfig(({ mode }) => ({
base: './', base: './',
plugins: [svelte()], 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
}));