diff --git a/.gitignore b/.gitignore index 2aa68c9..95533f8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules/ dist/ dist-web/ +dist-proxy/ dist-electron/ release/ *.log diff --git a/CHANGELOG.md b/CHANGELOG.md index 0640877..543a0a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to mumh5. Versions follow the `version` in `package.json`. ### Added +- Browser version: `npm run build:web` builds the web app and a small self-hosted proxy that bridges browsers to Mumble servers on an allowlist. Identities are kept in the browser, voice goes through the TCP tunnel. See "Browser version" in the README. - Link previews in chat: title, description and image for web links (up to two per message). By default they are fetched by your f0ckm upload host, so the linked sites never see your IP address. Can be switched to "fetched by this computer" or off in Settings, Chat and files. - Voice tiles on small windows: when the window is too narrow for the member list, the people in your voice channel appear as tiles above the chat, light up while they talk, and keep mute and deafen at hand. The tiles can be collapsed. - Per-person volume slider (0 to 300%) directly in the user context menu. Double-click resets it to 100%. diff --git a/CLAUDE.md b/CLAUDE.md index 503fa0a..9759383 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,7 @@ ## Project overview -mumh5 is a Mumble client: Electron desktop app (Windows, macOS, Linux), Svelte 5 + TypeScript + Vite UI, Discord-like layout, responsive down to phone width. It must stay compatible with stock Murmur servers and with people on the desktop Mumble client. A browser build (through a self-hosted WebSocket proxy with a server allowlist) is planned. +mumh5 is a Mumble client: Electron desktop app (Windows, macOS, Linux), Svelte 5 + TypeScript + Vite UI, Discord-like layout, responsive down to phone width. It must stay compatible with stock Murmur servers and with people on the desktop Mumble client. There is also a browser build that connects through a self-hosted WebSocket proxy with a server allowlist (`server/`). File sharing uploads to a f0ckm instance (`../f0ckm`, `src/chat_upload_handler.mjs` there) with an upload-only key; links are posted in chat. @@ -22,6 +22,10 @@ npm test # unit tests; server tests too when MUMBLE_TEST_HOST is s npm run build # renderer to dist/, main+preload to dist-electron/ (esbuild) npm run test:e2e # drives the built app (needs npm run build first) npm run screenshots # regenerates docs/screenshots (see test/e2e/screenshots.ts) +npm run build:web # browser build to dist-web/, proxy bundle to dist-proxy/proxy.mjs +npm run dev:web # browser build with hot reload: starts the proxy (any server allowed) and Vite +npm run proxy # run the proxy from source (MUMH5_SERVERS=host:port required) +npm run test:e2e:web # drives the browser build through the proxy (needs npm run build:web first) npm run proto # regenerate src/core/mumble-pb.js and mumble-udp-pb.js from proto/ ``` @@ -38,7 +42,9 @@ A reused test server keeps registrations and channels from earlier runs; tests m ## Architecture - `electron/` (Node, main process): window, TLS sockets to Mumble servers (`tls-transport.ts`), encrypted UDP voice (`udp-voice.ts`, `ocb2.ts`, tested against Mumble's OCB2 vectors), identities and PKCS#12 (`identity.ts`, `identity-store.ts`), certificate parsing (`certs.ts`), tray (`tray.ts`). The renderer only gets the narrow `window.mumh5Native` API from `preload.ts` (context isolation, sandbox). +- `server/` (Node): the web proxy. `proxy.ts` serves `dist-web`, bridges WebSocket connections to Mumble over TLS (reusing `electron/tls-transport.ts`) and has stateless identity endpoints. It stores nothing; the browser keeps identities in localStorage and sends one with each connect. - `src/core/` (browser-safe TypeScript, also runs in Node for tests): framing and codec (`proto.ts`), the Mumble client state machine (`client.ts`), voice packet formats (`voice-packet.ts`). No DOM, no Electron, no Node imports here. +- `src/lib/native.ts`: `desktop` is the Electron preload API or null; `native` is what both platforms provide (identities, certificates), backed by `web.svelte.ts` in the browser build (`isWeb`, vite `--mode web`). Desktop-only features check `desktop`. - `src/lib/`: app state. `session.svelte.ts` has one `Session` per server plus the `sessions` manager; `session` is a Proxy to the active one. `audio/voice.svelte.ts` is the voice engine (WebCodecs Opus, capture and playback AudioWorklets). `html.ts` sanitizes incoming HTML and serializes outgoing rich text. - `src/ui/`: Svelte components. `App.svelte` owns layout and global dialogs (`ui.svelte.ts` store). diff --git a/README.md b/README.md index c3c7ae5..0124713 100644 --- a/README.md +++ b/README.md @@ -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. - **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. -- **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. ### 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 | | 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 | -| 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 | | 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 | @@ -80,7 +80,7 @@ mumh5 keeps the foundation and replaces the experience. - Messages grouped by author, direct messages, unread badges - File sharing: drag and drop, paste, or the attach button, with upload progress - 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 - Incoming HTML is sanitized; remote images from unknown hosts are never loaded - 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 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 -- **Your identity is a certificate** that stays on your computer, stored with owner-only permissions. mumh5 reminds you to back it up. +- **Your identity is a certificate** that stays on your computer, stored with owner-only permissions. mumh5 reminds you to back it up. (The browser version is different, see above.) - **Server certificates are pinned** the first time you connect. If one changes, mumh5 stops before sending your password and asks you. -- **Chats don't phone home.** Media is only loaded from your upload host and hosts you add yourself. YouTube 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. --- @@ -179,8 +254,7 @@ Without an upload host, mumh5 still sends images, scaled to fit the server's lim - System-wide push to talk - Whisper and shout - Rich chat between mumh5 users: replies, reactions, edits, typing indicators -- Video and screen sharing between mumh5 users -- A browser version, through a small self-hosted WebSocket proxy +- Screen sharing between mumh5 users in a channel, with sound (one application or the whole system, also on Linux through PipeWire). Anyone can start a stream; no extra key, your Mumble certificate is your identity. Streams go directly between clients with WebRTC, set up through the Mumble server, with an optional self-hosted relay for many viewers - Release builds for all platforms --- @@ -201,6 +275,7 @@ docker run -d --name mumh5-test-murmur -p 64739:64738 -p 64739:64738/udp \ MUMBLE_TEST_HOST=localhost:64739 npm test npm run build MUMBLE_TEST_HOST=localhost:64739 MUMBLE_SUPERUSER_PASSWORD=testsuper npm run test:e2e +npm run build:web && MUMBLE_TEST_HOST=localhost:64739 npm run test:e2e:web # browser build through the proxy ``` On Linux the end-to-end test forces X11, so it can run on a virtual display: `Xvfb :99 & DISPLAY=:99 npm run test:e2e`. See [CLAUDE.md](CLAUDE.md) for the architecture and project conventions. diff --git a/docs/screenshots/main.png b/docs/screenshots/main.png index 876c251..ae72e88 100644 Binary files a/docs/screenshots/main.png and b/docs/screenshots/main.png differ diff --git a/docs/screenshots/mobile.png b/docs/screenshots/mobile.png index ef6095d..1d616e7 100644 Binary files a/docs/screenshots/mobile.png and b/docs/screenshots/mobile.png differ diff --git a/docs/screenshots/profile.png b/docs/screenshots/profile.png index 3f49f6a..64b93f3 100644 Binary files a/docs/screenshots/profile.png and b/docs/screenshots/profile.png differ diff --git a/docs/screenshots/settings-appearance.png b/docs/screenshots/settings-appearance.png index 071e553..0e464e3 100644 Binary files a/docs/screenshots/settings-appearance.png and b/docs/screenshots/settings-appearance.png differ diff --git a/docs/screenshots/theme-frost.png b/docs/screenshots/theme-frost.png index ebde921..623b319 100644 Binary files a/docs/screenshots/theme-frost.png and b/docs/screenshots/theme-frost.png differ diff --git a/docs/screenshots/theme-light.png b/docs/screenshots/theme-light.png index 5e75e90..27aa522 100644 Binary files a/docs/screenshots/theme-light.png and b/docs/screenshots/theme-light.png differ diff --git a/docs/screenshots/theme-midnight.png b/docs/screenshots/theme-midnight.png index 13f76dd..b6681c9 100644 Binary files a/docs/screenshots/theme-midnight.png and b/docs/screenshots/theme-midnight.png differ diff --git a/docs/screenshots/theme-win95.png b/docs/screenshots/theme-win95.png index 6b263d6..6fc906b 100644 Binary files a/docs/screenshots/theme-win95.png and b/docs/screenshots/theme-win95.png differ diff --git a/docs/screenshots/voice-settings.png b/docs/screenshots/voice-settings.png index 8b2f2d9..67c55c4 100644 Binary files a/docs/screenshots/voice-settings.png and b/docs/screenshots/voice-settings.png differ diff --git a/electron/tls-transport.ts b/electron/tls-transport.ts index d1c93ac..107cf67 100644 --- a/electron/tls-transport.ts +++ b/electron/tls-transport.ts @@ -1,4 +1,5 @@ import tls from 'node:tls'; +import net from 'node:net'; import { describeCert, type CertDetails } from './certs.ts'; export interface TlsHandlers { @@ -9,13 +10,37 @@ export interface TlsHandlers { // Opens a TLS connection to a Mumble server. Most servers use self-signed certificates, // so verification is left to the caller (trust on first use via the sha256 fingerprint). -export function openTls(host: string, port: number, cert: string, key: string, h: TlsHandlers) { +export interface TlsOptions { + // Custom DNS resolution (the web proxy uses it to refuse private addresses) + lookup?: net.LookupFunction; + // Announce this client address with a PROXY protocol v1 line before TLS starts, for a + // receiver such as go-mmproxy that passes it on to a server without PROXY support + proxyClient?: string; +} + +// PROXY protocol v1 line. Source and destination must be the same family; an IPv4 client is +// written as a mapped address towards an IPv6 destination, anything else is announced as unknown. +export function proxyLine(client: string, clientPort: number, dest: string, destPort: number): string { + const d = dest.replace(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i, '$1'); + const c = client.replace(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i, '$1'); + if (net.isIPv4(c) && net.isIPv4(d)) return `PROXY TCP4 ${c} ${d} ${clientPort} ${destPort}\r\n`; + if (net.isIPv6(d) && net.isIP(c)) return `PROXY TCP6 ${net.isIPv4(c) ? `::ffff:${c}` : c} ${d} ${clientPort} ${destPort}\r\n`; + return 'PROXY UNKNOWN\r\n'; +} + +export function openTls(host: string, port: number, cert: string, key: string, h: TlsHandlers, opts: TlsOptions = {}) { let closeReason = 'Connection closed'; - const socket = tls.connect({ - host, port, cert, key, - rejectUnauthorized: false, - servername: /^[\d.:]+$/.test(host) ? undefined : host - }); + const servername = /^[\d.:]+$/.test(host) ? undefined : host; + let socket: tls.TLSSocket; + if (opts.proxyClient) { + // The PROXY line goes first, in the clear; TLS then runs over the same connection + const raw = net.connect({ host, port, lookup: opts.lookup }); + raw.once('connect', () => raw.write(proxyLine(opts.proxyClient!, raw.localPort ?? 0, raw.remoteAddress ?? '', raw.remotePort ?? port))); + raw.on('error', (e: Error) => { closeReason = e.message; socket.destroy(); }); + socket = tls.connect({ socket: raw, cert, key, rejectUnauthorized: false, servername }); + } else { + socket = tls.connect({ host, port, cert, key, lookup: opts.lookup, rejectUnauthorized: false, servername }); + } socket.setNoDelay(true); socket.setKeepAlive(true, 30000); socket.setTimeout(20000, () => { diff --git a/package-lock.json b/package-lock.json index 4bcc2fe..ce52b6a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@tsconfig/svelte": "^5.0.8", "@types/node": "^26.6.3", "@types/node-forge": "^1.3.14", + "@types/ws": "^8.18.2", "electron": "^44.5.1", "electron-builder": "^26.15.3", "esbuild": "^0.28.2", @@ -26,7 +27,8 @@ "svelte": "^5.57.1", "svelte-check": "^4.7.6", "typescript": "^6.0.3", - "vite": "^8.3.1" + "vite": "^8.3.1", + "ws": "^8.22.0" } }, "node_modules/@babel/helper-string-parser": { @@ -1513,6 +1515,16 @@ "license": "MIT", "optional": true }, + "node_modules/@types/ws": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@xmldom/xmldom": { "version": "0.8.15", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz", @@ -5736,6 +5748,28 @@ "dev": true, "license": "ISC" }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xmlbuilder": { "version": "15.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", diff --git a/package.json b/package.json index 890a4fd..0504d72 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,9 @@ "scripts": { "dev": "node scripts/dev.mjs", "build": "vite build && node scripts/build-electron.mjs", - "build:web": "vite build --mode web --outDir dist-web", + "build:web": "vite build --mode web --outDir dist-web && node scripts/build-proxy.mjs", + "dev:web": "node scripts/dev-web.mjs", + "proxy": "node server/main.ts", "check": "svelte-check --tsconfig ./tsconfig.json", "test": "node --test test/*.test.ts", "dist": "npm run build && electron-builder", @@ -17,6 +19,7 @@ "dist:win": "npm run build && electron-builder --win", "dist:mac": "npm run build && electron-builder --mac", "test:e2e": "node test/e2e/app.e2e.ts", + "test:e2e:web": "node test/e2e/web.e2e.ts", "proto": "pbjs -t static-module -w es6 --keep-case --no-delimited --no-service --no-comments --force-number proto/Mumble.proto -o src/core/mumble-pb.js && pbjs -t static-module -w es6 --keep-case --no-delimited --no-service --no-comments --force-number proto/MumbleUDP.proto -o src/core/mumble-udp-pb.js", "screenshots": "node test/e2e/screenshots.ts" }, @@ -90,6 +93,7 @@ "@tsconfig/svelte": "^5.0.8", "@types/node": "^26.6.3", "@types/node-forge": "^1.3.14", + "@types/ws": "^8.18.2", "electron": "^44.5.1", "electron-builder": "^26.15.3", "esbuild": "^0.28.2", @@ -98,7 +102,8 @@ "svelte": "^5.57.1", "svelte-check": "^4.7.6", "typescript": "^6.0.3", - "vite": "^8.3.1" + "vite": "^8.3.1", + "ws": "^8.22.0" }, "dependencies": { "dompurify": "^3.4.16", diff --git a/scripts/build-proxy.mjs b/scripts/build-proxy.mjs new file mode 100644 index 0000000..f4d2315 --- /dev/null +++ b/scripts/build-proxy.mjs @@ -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' +}); diff --git a/scripts/dev-web.mjs b/scripts/dev-web.mjs new file mode 100644 index 0000000..a1f599b --- /dev/null +++ b/scripts/dev-web.mjs @@ -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); diff --git a/server/main.ts b/server/main.ts new file mode 100644 index 0000000..ddde361 --- /dev/null +++ b/server/main.ts @@ -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); +} diff --git a/server/proxy.ts b/server/proxy.ts new file mode 100644 index 0000000..1e872ae --- /dev/null +++ b/server/proxy.ts @@ -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 = { + '.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 }> { + 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(); + // Identity requests per address in the current minute; key generation is the costly part + const identityUse = new Map(); + 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 { + 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 { + 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 { + 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 | 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((resolve, reject) => { + server.once('error', reject); + server.listen(config.port, config.bind, resolve); + }); + return { + port: (server.address() as net.AddressInfo).port, + close: () => new Promise(resolve => { + clearInterval(sweep); + for (const ws of wss.clients) ws.terminate(); + wss.close(); + server.close(() => resolve()); + server.closeAllConnections(); + }) + }; +} diff --git a/src/App.svelte b/src/App.svelte index ac110f2..82cc44f 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -14,6 +14,7 @@ import UserInfoDialog from './ui/UserInfoDialog.svelte'; import ChannelDialog from './ui/ChannelDialog.svelte'; import PublicServers from './ui/PublicServers.svelte'; + import ServerInfoDialog from './ui/ServerInfoDialog.svelte'; import ResizeHandle from './ui/ResizeHandle.svelte'; import { identities } from './lib/identities.svelte.ts'; import { session } from './lib/session.svelte.ts'; @@ -115,6 +116,7 @@ {#if editing} (editing = null)} />{/if} {#if ui.addServer} (ui.addServer = null)} />{/if} {#if ui.publicServers} (ui.publicServers = false)} />{/if} +{#if ui.serverInfo && session.status === 'connected'} (ui.serverInfo = false)} />{/if} {#if ui.settingsOpen} { ui.settingsOpen = false; ui.settingsTab = 'voice'; }} />{/if} {#if ui.quickAudio}{/if} {#if ui.userInfo != null && session.status === 'connected'} diff --git a/src/core/transport.ts b/src/core/transport.ts index b0f8b22..400ef63 100644 --- a/src/core/transport.ts +++ b/src/core/transport.ts @@ -51,4 +51,7 @@ export interface ServerCertInfo { authorized: boolean; // true if it chains to a trusted CA authError: string | null; // why it is not trusted, e.g. DEPTH_ZERO_SELF_SIGNED_CERT chain: CertDetails[]; // leaf first + // Browser build only: this client's address as the web proxy saw it. The Mumble server + // sees the proxy's address instead and cannot be told the original one. + clientAddress?: string; } diff --git a/src/core/ws-transport.ts b/src/core/ws-transport.ts new file mode 100644 index 0000000..c3a7b3c --- /dev/null +++ b/src/core/ws-transport.ts @@ -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; + 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); + } + + close(): void { + this.ws.close(); + } +} diff --git a/src/lib/hints.ts b/src/lib/hints.ts new file mode 100644 index 0000000..8b54b4b --- /dev/null +++ b/src/lib/hints.ts @@ -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); +} diff --git a/src/lib/icons.ts b/src/lib/icons.ts index b5f3fd1..4035bd5 100644 --- a/src/lib/icons.ts +++ b/src/lib/icons.ts @@ -6,6 +6,9 @@ export const ICON_PATHS: Record = { 'headphones-off': 'M3 14v-2a9 9 0 0 1 18 0v2M21 14v7h-4v-7zM3 14v7h4v-7zM3 3l18 18', settings: 'M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6', plus: 'M12 5v14M5 12h14', + filter: 'M3 4h18l-7 8v7l-4 2v-9z', + info: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zM12 11v6M12 7h.01', + 'arrow-right': 'M5 12h14M13 6l6 6-6 6', paperclip: 'M21.4 11.1l-9.2 9.2a6 6 0 0 1-8.5-8.5l9.2-9.2a4 4 0 0 1 5.7 5.7l-9.2 9.2a2 2 0 0 1-2.8-2.8l8.5-8.5', hash: 'M4 9h16M4 15h16M10 3L8 21M16 3l-2 18', channel: 'M11 5L6 9H2v6h4l5 4zM15.5 8.5a5 5 0 0 1 0 7', diff --git a/src/lib/native.ts b/src/lib/native.ts index d0c1fd7..eddfde7 100644 --- a/src/lib/native.ts +++ b/src/lib/native.ts @@ -1,8 +1,27 @@ import type { Transport, ServerCertInfo, CertDetails, UdpChannel } from '../core/transport.ts'; +import { webApi, openWebTransport } from './web.svelte.ts'; -interface NativeApi { +// What both the desktop app and the browser build (through the web proxy) provide +export interface PlatformApi { platformInfo(): Promise<{ os: string; osVersion: string }>; describeCerts(ders: Uint8Array[]): Promise; + identities: { + list(): Promise<{ identities: IdentitySummary[]; setupDone: boolean }>; + create(name: string, email: string): Promise; + importP12(bytes: Uint8Array, password: string, name: string): Promise; + findMumble(): Promise<{ name: string } | null>; + importMumble(name: string): Promise; + // Where the backup was saved, or null when cancelled + exportP12(id: string, password: string): Promise; + setDefault(id: string): Promise; + rename(id: string, name: string): Promise; + remove(id: string): Promise; + finishSetup(): Promise; + }; +} + +// The desktop app's preload API +interface NativeApi extends PlatformApi { updateTray(state: { icon: string; tooltip: string; connected: boolean; server: string; muted: boolean; deafened: boolean; mode: string }): void; onTrayAction(fn: (action: string) => void): void; linkPreview(url: string): Promise; @@ -10,18 +29,6 @@ interface NativeApi { pingServer(host: string, port: number): Promise; onContextMenu(fn: (params: ContextParams) => void): void; editAction(action: string, arg?: unknown): void; - identities: { - list(): Promise<{ identities: IdentitySummary[]; setupDone: boolean }>; - create(name: string, email: string): Promise; - importP12(bytes: Uint8Array, password: string, name: string): Promise; - findMumble(): Promise<{ name: string } | null>; - importMumble(name: string): Promise; - exportP12(id: string, password: string): Promise; - setDefault(id: string): Promise; - rename(id: string, name: string): Promise; - remove(id: string): Promise; - finishSetup(): Promise; - }; open(connId: string, host: string, port: number, identityId?: string): Promise; send(connId: string, bytes: Uint8Array): void; close(connId: string): void; @@ -71,7 +78,18 @@ declare global { interface Window { mumh5Native?: NativeApi } } -export const native: NativeApi | null = typeof window !== 'undefined' ? window.mumh5Native ?? null : null; +// Desktop-only features (tray, UDP voice, public list, direct link previews) check `desktop` +export const desktop: NativeApi | null = typeof window !== 'undefined' ? window.mumh5Native ?? null : null; +// The browser build (vite --mode web) talks to the web proxy instead +export const isWeb = !desktop && import.meta.env?.MODE === 'web'; +export const native: PlatformApi | null = desktop ?? (isWeb ? webApi : null); + +// A connection to a Mumble server: TLS in the desktop app, the proxy's WebSocket in the browser +export async function openTransport(host: string, port: number, identityId?: string): Promise<(Transport & { secure: Promise }) | null> { + if (desktop) return new ElectronTransport(desktop, host, port, identityId); + if (isWeb) return openWebTransport(host, port, identityId); + return null; +} // Transport backed by a TLS socket in the Electron main process. // `secure` resolves once the TLS handshake is done, before any Mumble data is sent, diff --git a/src/lib/preview.ts b/src/lib/preview.ts index 7e991a1..6335215 100644 --- a/src/lib/preview.ts +++ b/src/lib/preview.ts @@ -1,18 +1,28 @@ -import { native, type LinkPreviewData } from './native.ts'; +import { desktop, type LinkPreviewData } from './native.ts'; import { store } from './settings.svelte.ts'; // One request per URL and mode per app run const cache = new Map>(); -export function linkPreview(url: string): Promise { +// 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 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 { + const { uploadHost, uploadKey } = store.settings; + const mode = previewMode(); if (mode === 'off') return Promise.resolve(null); const id = `${mode}|${url}`; let p = cache.get(id); if (!p) { p = mode === 'direct' - ? (native?.linkPreview(url) ?? Promise.resolve(null)).catch(() => null) + ? (desktop?.linkPreview(url) ?? Promise.resolve(null)).catch(() => null) : fetch(`${uploadHost.replace(/\/+$/, '')}/api/chat/preview?url=${encodeURIComponent(url)}`, { headers: { 'X-API-Key': uploadKey } }) .then(r => r.json()) .then(d => (d?.success ? d as LinkPreviewData : null)) diff --git a/src/lib/session.svelte.ts b/src/lib/session.svelte.ts index 217248e..c5d7873 100644 --- a/src/lib/session.svelte.ts +++ b/src/lib/session.svelte.ts @@ -1,6 +1,6 @@ import { createCodec } from '../core/proto.ts'; import { MumbleClient, describeDenial, type Channel, type User, type TextMessage } from '../core/client.ts'; -import { native, ElectronTransport } from './native.ts'; +import { native, openTransport } from './native.ts'; import type { ServerCertInfo } from '../core/transport.ts'; import { store, type SavedServer } from './settings.svelte.ts'; import { renderIncoming, textToHtml, escapeHtml, formatBytes, type Embed } from './html.ts'; @@ -155,12 +155,21 @@ export class Session { this.expanded[id] = !this.isExpanded(id); } + // Collapsing goes back to the default: channels with people in them stay open setAllExpanded(open: boolean): void { const all: Record = {}; - 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; } + // 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 get voiceTransport(): { udp: boolean; rtt: number } { void this.tick; @@ -216,13 +225,16 @@ export class Session { this.status = 'connecting'; if (!native) { - this.fail('Connecting needs the desktop app for now. The web version will connect through a proxy.'); + this.fail('Connecting needs the desktop app or the web build served by a mumh5 proxy.'); return; } - const transport = new ElectronTransport(native, server.host, server.port, server.identityId); + let transport; let info; try { + transport = (await openTransport(server.host, server.port, server.identityId))!; + // Superseded while the identity was being prepared + if (attempt !== this.attempt) return transport.close(); info = await transport.secure; } catch (e) { if (attempt === this.attempt) this.fail((e as Error).message); @@ -663,8 +675,17 @@ export class Session { // ─── Voice state and moderation ───────────────────────────────────────────── - setSelfMute(v: boolean) { this.client?.setSelfMute(v); } - setSelfDeaf(v: boolean) { this.client?.setSelfDeaf(v); } + // The choice is remembered, so it also works while disconnected and carries to the next connect + 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); } setServerMute(session: number, v: boolean) { this.client?.setUserState(session, { mute: v }); } @@ -683,6 +704,10 @@ class Sessions { activeId = $state(null); voiceId = $state(null); 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 private saved = new Map(); @@ -725,8 +750,15 @@ class Sessions { } onSynced(s: Session): void { - if (!this.voiceId || !this.voiceSession?.client) this.setVoice(s, true); - else this.toBackground(s); + const want = { mute: this.mute, deaf: this.deaf }; + 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 { diff --git a/src/lib/settings.svelte.ts b/src/lib/settings.svelte.ts index 965137b..00b862c 100644 --- a/src/lib/settings.svelte.ts +++ b/src/lib/settings.svelte.ts @@ -24,6 +24,7 @@ export interface Settings { theme: ThemeId; publicListUrl: string; // where the public server list comes from arrangement: Arrangement; + hints: boolean; // tooltips (title attributes) when resting the pointer on buttons } // Window layouts; classic and stacked follow the Mumble desktop client's layouts of the same name @@ -46,11 +47,12 @@ const defaults: Settings = { embedHosts: [], sendThumbnails: true, compact: false, - youtube: 'click', + youtube: 'off', linkPreviews: 'host', theme: 'dark', publicListUrl: 'https://publist.mumble.info/v1/list?version=1.5.0', - arrangement: 'side' + arrangement: 'side', + hints: false }; class SettingsStore { @@ -58,9 +60,17 @@ class SettingsStore { servers = $state(loadList('mumh5.servers')); // Pinned sha256 fingerprints of server certificates, keyed by host:port (trust on first use) pins = $state>(load('mumh5.pins', {})); + // Username to suggest when connecting with an identity, keyed by identity id + identityNames = $state>(load('mumh5.identityNames', {})); // Local nicknames for other users, only visible to us; keyed by certificate hash (or name) nicknames = $state>(load('mumh5.nicknames', {})); + setIdentityName(id: string, name: string) { + if (name.trim()) this.identityNames[id] = name.trim(); + else delete this.identityNames[id]; + save('mumh5.identityNames', $state.snapshot(this.identityNames)); + } + saveSettings() { save('mumh5.settings', $state.snapshot(this.settings)); } upsertServer(s: SavedServer) { diff --git a/src/lib/text-menu.ts b/src/lib/text-menu.ts index f8e341f..1faacd2 100644 --- a/src/lib/text-menu.ts +++ b/src/lib/text-menu.ts @@ -1,11 +1,11 @@ -import { native } from './native.ts'; +import { desktop } from './native.ts'; import { menu, type MenuItem } from './menu.svelte.ts'; // Right-click menu for text fields, messages, links and images. Elements with their own menu // (users, channels, servers) call preventDefault, so Electron does not report those. export function startTextMenu(): void { - if (!native) return; - const api = native; + if (!desktop) return; + const api = desktop; api.onContextMenu(p => { // Edit commands act on the focused element; keep it focused through the menu diff --git a/src/lib/tray.svelte.ts b/src/lib/tray.svelte.ts index 3c8cd0a..f639089 100644 --- a/src/lib/tray.svelte.ts +++ b/src/lib/tray.svelte.ts @@ -1,4 +1,4 @@ -import { native } from './native.ts'; +import { desktop } from './native.ts'; import { ICON_PATHS } from './icons.ts'; import { session, sessions } from './session.svelte.ts'; import { voice, type TransmitMode } from './audio/voice.svelte.ts'; @@ -40,8 +40,8 @@ function drawIcon(look: TrayLook): string { // Mirrors voice state into the system tray and handles its menu export function startTray(): void { - if (!native) return; - const api = native; + if (!desktop) return; + const api = desktop; $effect.root(() => { $effect(() => { diff --git a/src/lib/ui.svelte.ts b/src/lib/ui.svelte.ts index c58214a..9dc8a07 100644 --- a/src/lib/ui.svelte.ts +++ b/src/lib/ui.svelte.ts @@ -25,8 +25,11 @@ class Ui { wizard = $state<'setup' | 'add' | null>(null); // Quick audio popover from right-clicking mute (input) or deafen (output) quickAudio = $state<{ kind: 'input' | 'output'; x: number; y: number } | null>(null); - settingsTab = $state<'voice' | 'sounds' | 'appearance' | 'files' | 'identities'>('voice'); + settingsTab = $state<'voice' | 'sounds' | 'appearance' | 'accessibility' | 'files' | 'identities'>('voice'); publicServers = $state(false); + serverInfo = $state(false); + // Set by the toolbar to open the own profile with the description editor + editDescription = $state(false); // Add-server dialog, optionally prefilled (from the public list) addServer = $state<{ host?: string; port?: number; label?: string } | null>(null); // Channel dialog: create a subchannel of `parent`, or edit channel `id` diff --git a/src/lib/web.svelte.ts b/src/lib/web.svelte.ts new file mode 100644 index 0000000..810d551 --- /dev/null +++ b/src/lib/web.svelte.ts @@ -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(route: string, body?: unknown): Promise { + 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 { + let d = read(); + if (!d.identities.length) { + add(await call('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('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('identity/create', { name, email }), name), + importP12: async (bytes, password, name) => add(await call('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 { + 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([]); + any = $state(false); + loaded = $state(false); + error = $state(''); + + async load(): Promise { + 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(); diff --git a/src/main.ts b/src/main.ts index e2c8b9c..4d0ac2f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,7 +5,13 @@ import App from './App.svelte'; import './themes.css'; import { startTray } from './lib/tray.svelte.ts'; import { startTextMenu } from './lib/text-menu.ts'; +import { startHints } from './lib/hints.ts'; +import { isWeb } from './lib/native.ts'; +import { proxyInfo } from './lib/web.svelte.ts'; + +if (isWeb) proxyInfo.load(); mount(App, { target: document.getElementById('app')! }); startTray(); startTextMenu(); +startHints(); diff --git a/src/themes.css b/src/themes.css index 7faf7ba..2e31f9a 100644 --- a/src/themes.css +++ b/src/themes.css @@ -164,6 +164,8 @@ box-shadow: var(--sunken); } :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"] input[type=range] { accent-color: #000080; } diff --git a/src/ui/ChannelNode.svelte b/src/ui/ChannelNode.svelte index d672b43..a288377 100644 --- a/src/ui/ChannelNode.svelte +++ b/src/ui/ChannelNode.svelte @@ -10,6 +10,7 @@ import { store } from '../lib/settings.svelte.ts'; import { drag } from '../lib/drag.svelte.ts'; import { sounds } from '../lib/audio/sounds.svelte.ts'; + import { renderIncoming } from '../lib/html.ts'; import type { Channel } from '../core/client.ts'; let { channel, depth = 0, onnavigate }: { channel: Channel; depth?: number; onnavigate: () => void } = $props(); @@ -22,6 +23,39 @@ const unread = $derived(session.unread[`ch:${channel.id}`] ?? 0); const total = $derived(session.subtreeUserCount(channel.id)); + // Resting on a channel that has a description for 1 second opens a quick preview next to its row + const hasDescription = $derived(!!(channel.description || channel.descriptionHash)); + const descriptionHtml = $derived(channel.description ? renderIncoming(channel.description, store.trustedHosts).html : ''); + let preview = $state<{ x: number; y: number } | null>(null); + let hoverTimer: ReturnType | undefined; + // Where the pointer is while it rests on the row; the preview opens there + let pointer = { x: 0, y: 0 }; + function trackPointer(e: MouseEvent) { + pointer = { x: e.clientX, y: e.clientY }; + } + function hoverRow(e: MouseEvent) { + if (!hasDescription) return; + trackPointer(e); + clearTimeout(hoverTimer); + hoverTimer = setTimeout(() => { + // Long descriptions are only announced by hash; the text is fetched now + session.loadDescription(channel.id); + // Just below and right of the pointer, kept inside the window + preview = { x: Math.max(8, Math.min(pointer.x + 12, window.innerWidth - 348)), y: Math.max(8, Math.min(pointer.y + 12, window.innerHeight - 340)) }; + }, 1000); + } + // Short grace period, so the pointer can move from the row into the preview + function leaveRow() { + clearTimeout(hoverTimer); + hoverTimer = setTimeout(() => (preview = null), 250); + } + function showDescription() { + clearTimeout(hoverTimer); + preview = null; + session.showChannel(channel.id, 'info'); + ui.panelOpen = true; + } + // Single click previews, double click acts. The single action waits briefly so a // double click does not also flash the preview. let timer: ReturnType | undefined; @@ -69,7 +103,8 @@ oncontextmenu={e => menu.show(e, channelMenu(channel))} role="presentation" ondragover={e => { if (canDrop) { e.preventDefault(); over = true; } }} ondragleave={() => (over = false)} - ondrop={onDrop}> + ondrop={onDrop} + onmouseenter={hoverRow} onmousemove={trackPointer} onmouseleave={leaveRow}> {#if children.length || users.length} + {#if hasDescription} + + {/if} + {#if preview} + + {/if} {#if users.length && expanded}
    @@ -144,6 +190,15 @@ .caret.collapsed svg { transform: rotate(-90deg); } .name { flex: 1; min-width: 0; display: flex; align-items: center; gap: 6px; padding: 4px 0; text-align: left; color: inherit; user-select: none; } .label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .desc { flex: none; width: 20px; height: 20px; display: inline-flex; align-items: center; justify-content: center; } + .desc span { width: 9px; height: 9px; background: var(--warn); } + .desc:hover span { outline: 2px solid var(--warn); outline-offset: 1px; } + .desc-pop { position: fixed; z-index: 40; width: 340px; max-width: calc(100vw - 16px); max-height: 320px; overflow: auto; padding: 10px 12px; + background: var(--bg-2); border: 1px solid var(--line); color: var(--text); font-size: 14px; box-shadow: 0 8px 32px rgb(0 0 0 / 0.4); } + .desc-pop strong { display: block; margin-bottom: 6px; } + .desc-pop p { margin: 0; color: var(--text-dim); } + .desc-pop .html { overflow-wrap: anywhere; user-select: text; } + .desc-pop .html :global(img) { max-width: 100%; } .count { font-size: 11px; color: var(--text-faint); border: 1px solid var(--line); padding: 0 5px; line-height: 16px; } .badge { background: var(--danger); color: #fff; font-size: 11px; font-weight: 700; padding: 0 5px; line-height: 16px; } .user { gap: 8px; text-align: left; font-size: 14px; user-select: none; } diff --git a/src/ui/Composer.svelte b/src/ui/Composer.svelte index 4905335..c6ac013 100644 --- a/src/ui/Composer.svelte +++ b/src/ui/Composer.svelte @@ -132,11 +132,11 @@ diff --git a/src/ui/IdentityList.svelte b/src/ui/IdentityList.svelte index 9df9019..6e07e1f 100644 --- a/src/ui/IdentityList.svelte +++ b/src/ui/IdentityList.svelte @@ -1,5 +1,5 @@ + + + {#if info && server} +

    Server

    +
    +
    Address
    {server.host}:{server.port}
    +
    Version
    {info.version || 'unknown'}
    +
    People
    {info.users}{info.config.maxUsers ? ` of ${info.config.maxUsers}` : ''}
    +
    Channels
    {info.channels}
    +
    + +

    Connection

    +
    +
    Ping (TCP)
    {session.rtt ? `${session.rtt} ms` : 'n/a'}
    +
    Voice
    {voice.udp ? `Encrypted UDP${voice.rtt ? `, ${Math.round(voice.rtt)} ms` : ''}` : 'Through the TCP connection'}
    +
    Certificate
    {cert ? (cert.authorized ? 'Signed by a trusted authority' : 'Self-signed or untrusted, pinned on first connect') : 'unknown'}
    + {#if cert}
    SHA-256
    {cert.fingerprint}
    {/if} +
    + +

    Limits

    +
    +
    Voice bitrate
    {info.maxBandwidth ? `up to ${Math.round(info.maxBandwidth / 1000)} kbit/s` : 'unknown'}
    +
    Message length
    {info.config.messageLength ? `${info.config.messageLength} characters` : 'unlimited'}
    +
    Image messages
    {info.config.imageMessageLength ? `${info.config.imageMessageLength} characters` : 'unlimited'}
    +
    HTML in messages
    {info.config.allowHtml ? 'allowed' : 'not allowed'}
    +
    + {/if} + + {#snippet footer()} + + {/snippet} +
    + + diff --git a/src/ui/ServerRail.svelte b/src/ui/ServerRail.svelte index 6f0b935..6e541d2 100644 --- a/src/ui/ServerRail.svelte +++ b/src/ui/ServerRail.svelte @@ -3,6 +3,7 @@ import { store, type SavedServer } from '../lib/settings.svelte.ts'; import { sessions } from '../lib/session.svelte.ts'; import { menu } from '../lib/menu.svelte.ts'; + import { desktop } from '../lib/native.ts'; import { makeServerIcon } from '../lib/upload.ts'; let { onadd, onedit, onsettings, onbrowse }: @@ -88,7 +89,8 @@ {#if dropIndex === store.servers.length && dragging}
    {/if} - + + {#if desktop}{/if}
    diff --git a/src/ui/SettingsDialog.svelte b/src/ui/SettingsDialog.svelte index 922131b..1d30326 100644 --- a/src/ui/SettingsDialog.svelte +++ b/src/ui/SettingsDialog.svelte @@ -5,16 +5,20 @@ import AppearanceSettings from './AppearanceSettings.svelte'; import SoundSettings from './SoundSettings.svelte'; import { store } from '../lib/settings.svelte.ts'; - import { native } from '../lib/native.ts'; + import { native, desktop, isWeb } from '../lib/native.ts'; import { fetchUploadInfo } from '../lib/upload.ts'; + import { previewMode } from '../lib/preview.ts'; - let { onclose, tab: initialTab = 'voice' }: { onclose: () => void; tab?: 'voice' | 'sounds' | 'appearance' | 'files' | 'identities' } = $props(); + let { onclose, tab: initialTab = 'voice' }: { onclose: () => void; tab?: 'voice' | 'sounds' | 'appearance' | 'accessibility' | 'files' | 'identities' } = $props(); // svelte-ignore state_referenced_locally - let tab = $state<'voice' | 'sounds' | 'appearance' | 'files' | 'identities'>(initialTab); + let tab = $state<'voice' | 'sounds' | 'appearance' | 'accessibility' | 'files' | 'identities'>(initialTab); const s = store.settings; let embedHosts = $state(s.embedHosts.join('\n')); let testResult = $state(''); + // 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 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 }[] = [ { id: 'voice', label: 'Voice' }, { id: 'sounds', label: 'Sounds' }, { id: 'appearance', label: 'Appearance' }, + { id: 'accessibility', label: 'Accessibility' }, { id: 'files', label: 'Chat and files' }, ...(native ? [{ id: 'identities' as Tab, label: 'Identities' }] : []) ]; @@ -58,6 +63,9 @@ {:else if tab === 'appearance'} + {:else if tab === 'accessibility'} + +

    Hints are the small labels that appear on hover. Screen readers get the names either way.

    {:else if tab === 'files'}

    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.

    @@ -65,39 +73,39 @@
    -
    - -
    - +
    {#if testResult}

    {testResult}

    {/if} - +

    Embeds

    Links from other hosts stay plain links, so opening a chat never contacts servers you did not choose.

    - { s.linkPreviews = e.currentTarget.value as typeof s.linkPreviews; saveFiles(); }}> + + - {#if s.linkPreviews === 'host' && !(s.uploadHost && s.uploadKey)}

    Needs an upload host and key above; until then no previews are shown.

    {/if} {:else} -

    Servers recognize you by your certificate. The default is used unless a server is set to another one.

    +

    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.' : ''}

    {/if} @@ -116,5 +124,7 @@ .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 input { width: auto; } + .off { color: var(--text-faint); } + select:disabled, input:disabled { opacity: 0.5; } textarea { resize: vertical; } diff --git a/src/ui/Sidebar.svelte b/src/ui/Sidebar.svelte index 8b98324..6ccfb94 100644 --- a/src/ui/Sidebar.svelte +++ b/src/ui/Sidebar.svelte @@ -22,6 +22,9 @@ const roots = $derived(session.childrenOf(null)); 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 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') { e.preventDefault(); const r = (e.currentTarget as HTMLElement).getBoundingClientRect(); @@ -111,8 +128,8 @@ {/if} - {#if self} -
    +
    + {#if self} - - -
    - {/if} + {:else} + + {session.status === 'connecting' ? 'Connecting...' : 'Not connected'} + {deafened ? 'Deafened' : muted ? 'Muted' : 'Microphone on'} + + {/if} +
    + + + + + + + + + +
    +
    diff --git a/src/ui/UserInfoDialog.svelte b/src/ui/UserInfoDialog.svelte index 0872731..57ecbab 100644 --- a/src/ui/UserInfoDialog.svelte +++ b/src/ui/UserInfoDialog.svelte @@ -23,6 +23,11 @@ const client = session.client; const user = $derived(session.user(who)); + const isSelf = who === session.self?.session; + // Browser build: our address as the web proxy saw it + // A proxy on this same machine connects from our own address, so what the server sees is ours + const viaProxy = session.serverCert?.clientAddress ?? ''; + const ownAddress = /^(127\.|::1$|localhost$)/.test(viaProxy) ? '' : viaProxy; const off = client?.on('userStats', msg => { if (msg.session !== who) return; @@ -84,7 +89,13 @@
    Ping (UDP)
    {stats.udp_packets ? ping(stats.udp_ping_avg, stats.udp_ping_var) : 'n/a'}
    Pings received
    {stats.tcp_packets ?? 0} over TCP, {stats.udp_packets ?? 0} over UDP
    {#if stats.bandwidth != null}
    Bandwidth
    {formatBytes(stats.bandwidth)}/s
    {/if} - {#if stats.address?.length}
    Address
    {address(stats.address)}
    {/if} + {#if isSelf && ownAddress} + +
    Address
    {ownAddress}
    + {#if stats.address?.length && address(stats.address) !== ownAddress}
    Seen by the server
    {address(stats.address)} (the web proxy)
    {/if} + {:else if stats.address?.length} +
    Address
    {address(stats.address)}
    + {/if} {#if stats.version} diff --git a/test/e2e/app.e2e.ts b/test/e2e/app.e2e.ts index 425bb01..06e8866 100644 --- a/test/e2e/app.e2e.ts +++ b/test/e2e/app.e2e.ts @@ -78,7 +78,8 @@ try { // First start: identity setup wizard await page.getByRole('dialog', { name: 'Set up your identity' }).waitFor(); await page.getByRole('button', { name: /Create a new identity/ }).click(); - await page.getByLabel('Name').fill('alice'); + await page.getByLabel('Name', { exact: true }).fill('alice'); + assert.ok(await page.getByLabel('Use this name as my username').isChecked()); await page.getByRole('button', { name: 'Create', exact: true }).click(); await page.getByRole('button', { name: 'Skip for now' }).click(); await page.getByRole('button', { name: 'Done' }).click(); @@ -92,6 +93,18 @@ try { await page.getByRole('button', { name: 'Done', exact: true }).click(); 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 if (process.env.UPLOAD_HOST && process.env.UPLOAD_KEY) { await page.getByTitle('Settings').click(); @@ -107,7 +120,7 @@ try { await page.getByTitle('Add a server').click(); await page.getByLabel('Address').fill(host); await page.getByLabel('Port').fill(port); - await page.getByLabel('Username').fill('alice'); + assert.equal(await page.getByLabel('Username').inputValue(), 'alice', 'username suggested from the identity'); await page.getByLabel('Label').fill('Test Server'); await page.getByRole('button', { name: 'Save and connect' }).click(); await page.getByText(/Connected/).first().waitFor(); @@ -256,15 +269,23 @@ try { await page.locator('.chat .composer').getByRole('button', { name: 'Text formatting' }).click(); 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 https://youtu.be/dQw4w9WgXcQ?t=1m30s'); - 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(); 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(); 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 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)'); await page.getByRole('radio', { name: 'Dark' }).click(); assert.equal(await page.evaluate(() => document.documentElement.dataset.theme), undefined); + // Hints are off by default: the title under the pointer is set aside while hovered, and back afterwards + const hintTarget = page.locator('.rail').getByRole('button', { name: 'Add a server' }); + const titleOf = () => page.locator('.rail .tile.add').first().getAttribute('title'); + await page.getByRole('tab', { name: 'Accessibility' }).click(); + assert.equal(await page.getByLabel('Show hints when the pointer rests').isChecked(), false); await page.getByRole('button', { name: 'Done', exact: true }).click(); - console.log('ok: color schemes'); + await hintTarget.hover(); + assert.equal(await titleOf(), null, 'no tooltip while hints are off'); + await page.mouse.move(700, 400); + assert.equal(await titleOf(), 'Add a server'); + await page.getByTitle('Settings').click(); + await page.getByRole('tab', { name: 'Accessibility' }).click(); + await page.getByLabel('Show hints when the pointer rests').check(); + await page.getByRole('button', { name: 'Done', exact: true }).click(); + await hintTarget.hover(); + assert.equal(await titleOf(), 'Add a server', 'tooltip kept while hints are on'); + await page.mouse.move(700, 400); + await page.getByTitle('Settings').click(); + await page.getByRole('tab', { name: 'Accessibility' }).click(); + await page.getByLabel('Show hints when the pointer rests').uncheck(); + await page.getByRole('button', { name: 'Done', exact: true }).click(); + console.log('ok: color schemes, hints off by default and can be turned on'); // Layouts like the desktop client: classic (channels right of chat), stacked (channels above) const box = (sel: string) => page.locator(sel).first().boundingBox().then(b => b!); @@ -359,6 +400,15 @@ try { const row = pub.locator('tr', { hasText: 'E2E Murmur' }); await row.getByText(/^\d+ \/ \d+$/).waitFor({ timeout: 8000 }); 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(); assert.equal(await page.getByLabel('Address').inputValue(), host); assert.equal(await page.getByLabel('Port').inputValue(), port); @@ -532,6 +582,25 @@ try { await page.locator('.panel h3', { hasText: 'bob' }).waitFor(); 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 await page.locator('.sidebar .user.self').click({ button: 'right' }); await page.getByRole('menuitem', { name: 'Information' }).click(); @@ -590,6 +659,29 @@ try { assert.equal(await page.locator('.me .who span').textContent(), 'Root'); console.log('ok: single click shows description without joining'); + // Description marker: only on channels that have one. Resting on the row previews, clicking the marker opens the panel + const marker = page.locator('.channel', { hasText: 'Lobby' }).getByRole('button', { name: 'Show description of Lobby' }); + assert.equal(await page.locator('.channel', { hasText: 'Games' }).locator('.desc').count(), 0); + await page.locator('.panel').getByRole('button', { name: 'Close' }).click(); + await page.locator('.channel .name', { hasText: 'Games' }).hover(); + await page.waitForTimeout(2000); + assert.equal(await page.locator('.desc-pop').count(), 0, 'no preview for a channel without description'); + for (const spot of [page.locator('.channel .name', { hasText: 'Lobby' }), marker]) { + await spot.hover(); + await page.waitForTimeout(500); + assert.equal(await page.locator('.desc-pop').count(), 0, 'no preview before 1 second'); + await page.locator('.desc-pop .html', { hasText: 'Welcome to the lobby' }).waitFor({ timeout: 3000 }); + // Opens at the pointer + const at = (await spot.boundingBox())!; + const pop = (await page.locator('.desc-pop').boundingBox())!; + assert.ok(Math.abs(pop.x - (at.x + at.width / 2 + 12)) < 3 && Math.abs(pop.y - (at.y + at.height / 2 + 12)) < 3, 'preview at the pointer'); + await page.mouse.move(700, 400); + await page.locator('.desc-pop').waitFor({ state: 'detached' }); + } + await marker.click(); + await page.locator('.panel .html', { hasText: 'Welcome to the lobby' }).waitFor(); + console.log('ok: description previews on hover, marker opens it on click'); + // Side chat sends to that channel while staying in Root const lobby = [...bob.channels.values()].find(c => c.name === 'Lobby')!; await page.locator('.panel [role=tab]', { hasText: 'Chat' }).click(); @@ -721,6 +813,16 @@ try { await page.getByRole('button', { name: 'Delete channel' }).click(); await bobSees('deleted', () => !chByName('Team Room')); console.log('ok: channel deleted'); + + // Toolbar: expand and collapse the whole tree + await page.locator('.me').getByRole('button', { name: 'Expand all channels' }).click(); + await page.locator('.channel', { hasText: 'Minecraft' }).waitFor(); + await page.locator('.me').getByRole('button', { name: 'Collapse all channels' }).click(); + await page.locator('.channel', { hasText: 'Minecraft' }).waitFor({ state: 'detached' }); + // Channels with people in them stay open + assert.notEqual(await page.locator('.me .who span').textContent(), 'Root'); + await page.locator('.sidebar .row.user.self').waitFor(); + console.log('ok: toolbar expands and collapses all channels, occupied ones stay open'); } // Without an upload host, images are sent inline within Mumble's image limit @@ -729,6 +831,11 @@ try { await page.getByTitle('Settings').click(); await page.getByRole('tab', { name: 'Chat and files' }).click(); 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(); const big = path.join(userData, 'big.png'); writeFileSync(big, testPng(1600, 1200)); diff --git a/test/e2e/screenshots.ts b/test/e2e/screenshots.ts index c0dae2e..477e7d8 100644 --- a/test/e2e/screenshots.ts +++ b/test/e2e/screenshots.ts @@ -81,7 +81,7 @@ const page = await app.firstWindow(); await page.setViewportSize({ width: 1440, height: 900 }); await page.getByRole('button', { name: /Create a new identity/ }).click(); -await page.getByLabel('Name').fill('kibi'); +await page.getByLabel('Name', { exact: true }).fill('kibi'); await page.getByRole('button', { name: 'Create', exact: true }).click(); await page.getByRole('button', { name: 'Skip for now' }).click(); await page.getByRole('button', { name: 'Done' }).click(); diff --git a/test/e2e/web-shell.cjs b/test/e2e/web-shell.cjs new file mode 100644 index 0000000..9fdf083 --- /dev/null +++ b/test/e2e/web-shell.cjs @@ -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()); diff --git a/test/e2e/web.e2e.ts b/test/e2e/web.e2e.ts new file mode 100644 index 0000000..17bbad5 --- /dev/null +++ b/test/e2e/web.e2e.ts @@ -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 { + 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(p: Promise, label: string, ms = 10000): Promise { + return Promise.race([p, new Promise((_, 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((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 +}); +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(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(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(); +} diff --git a/test/proxy.test.ts b/test/proxy.test.ts new file mode 100644 index 0000000..68f1ec5 --- /dev/null +++ b/test/proxy.test.ts @@ -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, fn: (base: string) => Promise): Promise { + 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 = {}) => + 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(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(resolve => { gotFirst = resolve; }); + const upstream = net.createServer(s => s.once('data', d => { gotFirst(d.toString('latin1')); s.destroy(); })); + await new Promise(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(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((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(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(); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 7b82203..cd37871 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,5 +11,5 @@ "skipLibCheck": true, "types": ["node", "svelte", "vite/client"] }, - "include": ["src/**/*", "electron/**/*", "test/**/*"] + "include": ["src/**/*", "electron/**/*", "server/**/*", "test/**/*"] } diff --git a/vite.config.ts b/vite.config.ts index 44208e2..ed6c3e7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,8 +1,10 @@ import { defineConfig } from 'vite'; import { svelte } from '@sveltejs/vite-plugin-svelte'; -export default defineConfig({ +export default defineConfig(({ mode }) => ({ base: './', plugins: [svelte()], - build: { target: 'chrome120', outDir: 'dist', emptyOutDir: true } -}); + build: { target: 'chrome120', outDir: 'dist', emptyOutDir: true }, + // npm run dev:web: the page comes from Vite, the API from the proxy that script starts next to it + server: mode === 'web' ? { proxy: { '/api': { target: 'http://127.0.0.1:8080', ws: true } } } : undefined +}));