Author SHA1 Message Date
kibi a35ecb61e6 Merge pull request 'Text channels' (#18) from text-channels into main
Reviewed-on: #18
2026-10-02 18:58:27 +00:00
kibiandClaude Opus 5.5 2883c61965 100 color schemes, a plainer README
Six more schemes by hand and 89 generated from a hue and an accent
(npm run themes), with a readability test over all of them and a search
box in the picker. The README describes the software plainly and covers
text channels, the Rooms interface and the new settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:57:47 +02:00
kibiandClaude Opus 5.5 165e8ef54f Give the microphone back while muted, for Bluetooth headsets
An open microphone puts a Bluetooth headset into its telephone mode. With
this setting (on by default on phones) the microphone is released while
muted and taken again on unmute.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:57:47 +02:00
kibiandClaude Opus 5.5 5c7942d600 Add the Rooms interface: one screen at a time, made for phones
A second shell on the same state, chosen in Settings, Appearance.
Rooms as a list with the people inside, a voice dock, a voice screen,
people and a You screen. Dialogs and menus are shared with the standard
interface. Moving channels and people by dragging is not in it yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:30:54 +02:00
kibiandClaude Opus 5.5 30a75a0629 Text channels between mumh5 users; drag channels in front of or behind another
Text channels are real Mumble channels with a marker in the description.
Their messages travel as plugin data to everyone on the server, are kept
by the clients and handed to people who connect later.

Channels can be dropped in front of or behind another channel, with a
line where they land. A description preview no longer sticks after a drop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:16:03 +02:00
kibi dc12d092b9 Merge pull request 'Screen sharing' (#17) from screen-sharing into main
Reviewed-on: #17
2026-10-01 23:01:00 +00:00
kibiandClaude Opus 5.5 06a185d778 Switch between cameras while sharing
The picture is swapped on the running stream, so viewers stay connected. The
new camera is opened first where the device allows two at once; phones close
the old one first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 01:00:04 +02:00
kibiandClaude Opus 5.5 c990593f9f Own camera is not mirrored in the large view
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 00:44:37 +02:00
kibiandClaude Opus 5.5 e41a2cfd70 Own camera: mirror consistently in preview, tile and large view; not a camera facing away
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 00:42:40 +02:00
kibi 2a5213c80c Merge pull request 'Screen sharing' (#16) from screen-sharing into main
Reviewed-on: #16
2026-10-01 22:39:02 +00:00
kibiandClaude Opus 5.5 fdf6c12afa Camera tiles show the whole picture in its own shape
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 00:38:37 +02:00
kibiandClaude Opus 5.5 5d43b8e4ae Built-in Opus codec for browsers without one; cameras on by default with per-person hide
- libopus in WebAssembly takes over voice where WebCodecs audio is missing
  (Firefox on Android); tested for tone, timing and bitrate, and in a browser
  forced onto it
- Cameras in the channel show by themselves once the IP note was confirmed;
  a person's camera can be hidden from the right-click menu, all of them with
  one button; an enlarged camera leaves its tile
- CSP allows wasm-unsafe-eval for the codec

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 00:36:43 +02:00
kibi 7ce351c46a Merge pull request 'Screen sharing' (#15) from screen-sharing into main
Reviewed-on: #15
2026-10-01 22:28:56 +00:00
kibiandClaude Opus 5.5 6368e63b0e Camera: fall back to plain constraints, explain a silent refusal; show voice failures above the chat
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 00:28:08 +02:00
kibiandClaude Opus 5.5 2700a4ec3d Relay: clients of the same relay reach each other behind a 1:1 NAT; logging and extra relay addresses
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 00:20:39 +02:00
kibi 645f83545d Merge pull request 'Camera rounds: several people's cameras at once in the voice tiles' (#14) from screen-sharing into main
Reviewed-on: #14
2026-10-01 22:16:16 +00:00
kibiandClaude Opus 5.5 1e4bda4e74 Camera rounds: several people's cameras at once in the voice tiles
- A viewer holds one screen and any number of cameras; cameras play in the
  tiles (own one mirrored), a click enlarges one
- "Watch all cameras" joins the round: cameras in the channel are watched as
  they come on. Turning your own camera on joins it too
- Sound, statistics and stop work per stream
- Relay quotas and port range raised: a round uses a route per pair of people

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 00:14:22 +02:00
kibi 909d756e56 Merge pull request 'Share dialog: say why the screen cannot be shared on phones' (#13) from screen-sharing into main
Reviewed-on: #13
2026-10-01 21:59:56 +00:00
kibiandClaude Opus 5.5 817f822771 Share dialog: say why the screen cannot be shared on phones
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 23:58:26 +02:00
kibi a0550834dc Merge pull request 'Camera sharing, and the proxy's relay for the desktop app' (#12) from screen-sharing into main
Reviewed-on: #12
2026-10-01 21:54:59 +00:00
kibiandClaude Opus 5.5 819db06bc6 Camera sharing, and the proxy's relay for the desktop app
- A camera is a second source in the share dialog; streams carry their kind,
  so icons and wording follow. Browsers without screen capture (phones) start
  on the camera.
- Settings, Voice: the address of a mumh5 web version as relay; its STUN and
  relay are then offered with every stream and take over when no direct
  connection comes up.
- The proxy hands out relay credentials at GET /api/relay, readable from any
  origin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 23:52:18 +02:00
kibi cbd71216a8 Merge pull request 'Add a stream relay (TURN) to the proxy for browsers that cannot connect directly' (#11) from screen-sharing into main
Reviewed-on: #11
2026-10-01 21:35:34 +00:00
kibiandClaude Opus 5.5 08aa5268d0 Add a stream relay (TURN) to the proxy for browsers that cannot connect directly
Vanadium forbids direct UDP for WebRTC, and mobile and company networks often
block direct connections; the only route then is a relay reached over TCP.

- server/turn.ts: STUN and TURN on one port, over UDP and TCP, with
  short-lived credentials from /api/turn, quotas and a peer filter
- The browser build fetches credentials and offers the relay automatically
- Stats for nerds says when a stream is relayed and how the relay is reached
- Tests: a TURN client over UDP and TCP, and a browser limited to the relay
  over TCP in the web E2E

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 23:30:09 +02:00
kibi 2498cf0d2d Merge pull request 'Screen sharing' (#10) from screen-sharing into main
Reviewed-on: #10
2026-10-01 21:14:33 +00:00
kibiandClaude Opus 5.5 98e44d7082 Settings: choose a STUN server from Google, Cloudflare or a typed address
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 23:13:40 +02:00
kibiandClaude Opus 5.5 0a290de229 Channel menu: Send message to channel opens the side chat ready to type
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 23:12:10 +02:00
kibiandClaude Opus 5.5 2510633538 Images in chat and descriptions open large on click
The channel description preview stays open underneath while an image from it
is shown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 23:07:24 +02:00
kibiandClaude Opus 5.5 e7b317313a Server rail: the server with voice is shown on top
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 23:04:53 +02:00
kibiandClaude Opus 5.5 f9e7042a73 Stacked layout: no chat title bar
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 23:03:22 +02:00
kibiandClaude Opus 5.5 7d1ad5423f Voice tiles fill the stage exactly at every size
Rows of equal tiles with the column count chosen for the best 16:9 fit; a
shorter last row stretches. In the meeting view the strip shares the width.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 23:00:37 +02:00
kibiandClaude Opus 5.5 b80eeafcc2 Web E2E: check the stream view at a phone-sized window
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 22:55:28 +02:00
kibi 873ee236dd Merge pull request 'Streams: long-press menu on touch screens, show connecting and failure over the picture' (#9) from screen-sharing into main
Reviewed-on: #9
2026-10-01 20:54:42 +00:00
kibiandClaude Opus 5.5 9832ba2749 Streams: long-press menu on touch screens, show connecting and failure over the picture
A stream counted as live as soon as its track was announced, before any route
existed, so a viewer whose connection never came up saw a black picture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 22:52:16 +02:00
kibi f9718e80a1 Merge pull request 'Screen sharing' (#8) from screen-sharing into main
Reviewed-on: #8
2026-10-01 20:40:25 +00:00
kibiandClaude Opus 5.5 950a1afad7 Stacked layout: drag the chat taller under a stream, never below its minimum
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 22:37:59 +02:00
kibiandClaude Opus 5.5 b5dda4e1c2 Add pop-out and full screen to the stream's right-click menu
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 22:35:52 +02:00
kibiandClaude Opus 5.5 e0e900625d Drop the hover time in the message margin; the date at the right edge replaces it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 22:34:35 +02:00
kibiandClaude Opus 5.5 e59c6804cb Show a message's full date at the right edge on hover
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 22:33:26 +02:00
kibiandClaude Opus 5.5 5cc379417b Stacked layout: the chat keeps about five lines under a stream
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 22:31:35 +02:00
kibiandClaude Opus 5.5 68f68d1d76 Screen sharing: stats for nerds on right-click of the stream
Codecs, picture size and frame rate, bitrate, loss, jitter and the network
path, per viewer for the sharer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 22:28:41 +02:00
kibi d0543688cd Merge pull request 'Screen sharing' (#7) from screen-sharing into main
Reviewed-on: #7
2026-10-01 20:27:09 +00:00
kibiandClaude Opus 5.5 a402d1c035 Streams open large by default; tiles never carry the picture
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 22:19:51 +02:00
kibiandClaude Opus 5.5 63d77c2bce Stacked layout stays stacked at any width and shows streams as a plain picture
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 22:17:35 +02:00
kibiandClaude Opus 5.5 9880ef840f Screen sharing: sounds for streams and viewers, viewer count as status
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 22:13:32 +02:00
kibiandClaude Opus 5.5 71b90ef80a Fall back to picture-in-picture when the stream window is refused
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 22:10:05 +02:00
kibiandClaude Opus 5.5 23833b1c8b Screen sharing: resizable tile stage, streams first, pop-out window
- The stage above the chat is as tall as the user drags it; tiles are sized to
  fill it, with a meeting-style look
- Tiles with a stream come first, at the top left
- The large picture can be moved to a separate window

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 22:09:34 +02:00
kibiandClaude Opus 5.5 5a7f243c1d Reword the screen sharing notice
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 21:55:01 +02:00
kibi 260080b474 Merge pull request 'Make the compose file work with older docker-compose' (#6) from screen-sharing into main
Reviewed-on: #6
2026-10-01 19:50:56 +00:00
kibiandClaude Opus 5.5 e649062d67 Make the compose file work with older docker-compose
List the variables instead of an optional env_file; the proxy treats empty
variables as unset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 21:46:45 +02:00
kibi fa5b92b906 Merge pull request 'Screen sharing' (#5) from screen-sharing into main
Reviewed-on: #5
2026-10-01 19:43:38 +00:00
kibi f8608a09db Merge pull request 'Server info and ui polish' (#4) from server-info-and-ui-polish into main
Reviewed-on: #4
2026-10-01 19:43:18 +00:00
kibiandClaude Opus 5.5 5159d8a518 Add a Docker image and compose file for the web proxy
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 21:41:42 +02:00
kibiandClaude Opus 5.5 826542ed8a Add a STUN responder to the proxy for screen sharing between browsers
The browser build uses it automatically, so no outside server is contacted.
Covered by unit tests and a two-browser step in the web E2E.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 21:38:42 +02:00
kibiandClaude Opus 5.5 13b7013004 Say that both sides need a STUN server for streams across the internet
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 21:30:17 +02:00
kibiandClaude Opus 5.5 3b23f69a5f Screen sharing: start dialog, sound, video tiles and a meeting view
- Start dialog with source list, live preview, sound and quality choices
- Sound on Linux through PipeWire: one program or everything except this
  app, fed into a virtual microphone and kept linked as programs come and go;
  system loopback on Windows; stereo Opus
- A sharer's tile shows the picture; clicking it opens a large view with the
  other tiles as a strip. Own stream is previewed in the own tile
- Stream volume for viewers, watch and stop from the profile panel
- Unit tests for the PipeWire link planner; the proxy test no longer writes to
  the root channel other tests listen in

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 21:19:29 +02:00
kibiandClaude Opus 5.5 0cce3c97c2 Add screen sharing between mumh5 users (picture only)
- Signals travel as Mumble plugin data (compressed, chunked under Murmur's
  1000 byte limit); the picture goes directly to each viewer over WebRTC
- Toolbar button with a source picker in the desktop app, an indicator next to
  people who share, click to watch, a view above the chat with full screen
- Late joiners learn about running streams; moving, stopping or disconnecting
  ends them for viewers
- A note about IP addresses before the first use, optional STUN server setting
- Unit tests for the signal codec and an E2E with two app instances

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 20:58:33 +02:00
kibiandClaude Opus 5.5 82972438fe Add the browser version, a toolbar, description previews and hints
Browser version
- Self-hosted proxy (server/) that serves the web build and bridges WebSocket
  connections to Mumble servers over TLS, with a server allowlist or allow-any mode
- WebSocket transport and a browser platform layer; identities live in the browser
- npm run build:web, dev:web, proxy and test:e2e:web; proxy unit tests

Interface
- Toolbar next to mute and deafen: description editor, settings, expand or collapse all
- Channels with a description show a marker; resting on the row previews it
- Collapse all keeps channels with people open
- Hints (title tooltips) can be switched on in a new Accessibility settings tab
- New identities can set the username suggested when connecting
- Own user information tells the client address from the one the server sees

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 20:47:10 +02:00
kibiandClaude Opus 5.5 74aaf635be Refresh README screenshots
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 18:59:47 +02:00
kibiandClaude Opus 5.5 dc9e6696c9 Add server information, an always-visible mute bar and public list actions
- Public servers: connect arrow and add button per row, sortable column headers
- Mute and deafen stay available while disconnected; the choice carries to the next connect
- Server information dialog, opened from a button next to mute
- Stacked layout: mute bar on top, without the profile
- Settings that need the upload host are disabled until it is set; link previews show the mode in effect
- YouTube links are plain links by default
- Composer has a border and matches the sidebar bar height
- Regenerate README screenshots

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 18:40:51 +02:00
kibi 8522dbc9ca Merge pull request 'Link previews voice tiles' (#3) from link-previews-voice-tiles into main
Reviewed-on: #3
2026-09-30 23:40:16 +00:00
kibi fad79d23e8 Merge pull request 'Add channel and permission management, sounds, themes and the public server list' (#2) from link-previews-voice-tiles into main
Reviewed-on: #2
2026-09-30 23:04:59 +00:00
kibi 769788dc1e Merge pull request 'Add link previews, voice tiles and a menu volume slider; tidy the profile' (#1) from link-previews-voice-tiles into main
Reviewed-on: #1
2026-09-30 22:06:15 +00:00
104 changed files with 9920 additions and 317 deletions
+10
View File
@@ -0,0 +1,10 @@
node_modules
dist
dist-web
dist-proxy
dist-electron
release
.git
docs
*.log
.env
+2
View File
@@ -1,6 +1,8 @@
node_modules/ node_modules/
dist/ dist/
dist-web/ dist-web/
dist-proxy/
dist-electron/ dist-electron/
release/ release/
*.log *.log
.env
+12
View File
@@ -6,6 +6,17 @@ All notable changes to mumh5. Versions follow the `version` in `package.json`.
### Added ### Added
- 95 more color schemes, 100 in all: six made by hand (Forest, Ember, Ocean, Plum, Paper, High contrast) and 89 generated from a hue and an accent (`npm run themes`), 64 dark and 25 light. The picker can be narrowed by name and by dark or light.
- Voice setting "Give the microphone back while muted", on by default on phones and tablets: a Bluetooth headset then stays out of its telephone mode while you are muted, so what you hear keeps its full quality.
- A second interface, "Rooms" (Settings, Appearance, Interface), made for phones first: one screen at a time with a bar for Rooms, Chat, Voice, People and You. Rooms are a list with the people inside shown inline and a Join button; your room and a mute and deafen dock stay in reach; the voice screen shows the room as tiles with large controls. Wide windows keep the rooms next to the chosen screen. It uses the same dialogs and menus as the standard interface. Not in it yet: moving channels and people by dragging.
- Text channels (mumh5 only): a channel marked as a text channel in its dialog is read and written without joining it, from any voice channel. Messages travel between mumh5 clients; other Mumble clients see an ordinary channel and none of the messages. The server keeps nothing: each client keeps the last 200 messages per channel and hands the last 50 to people who connect later. Everyone on the server who uses mumh5 can read them.
- Channels can be dragged in front of or behind another channel, not only into one. A line shows where the channel will land, the dragged channel is dimmed, the people under a channel count as part of its drop target, and resting on a closed channel opens it.
- Voice in browsers without an audio codec of their own (Firefox on Android): a built-in Opus codec takes over there.
- Cameras of people in your channel show by themselves, like in a video call; each one can be hidden from its right-click menu, and all of them switched off with one button.
- Camera sharing, as a second source in the screen sharing dialog, and camera rounds: several people's cameras at once in the voice tiles.
- A relay for streams in the web proxy, used automatically by the browser version and, with its address entered in Settings, by the desktop app, for networks and browsers that allow no direct connection.
- Screen sharing between mumh5 users in a channel: a dialog to choose the screen or window (with preview), the sound and the quality; sound from one program or the whole system on Linux through PipeWire. Viewers watch in the voice tiles, enlarge a stream like in a meeting and set its volume. Streams go directly between clients; a STUN server can be set in Settings, Voice.
- Browser version: `npm run build:web` builds the web app and a small self-hosted proxy that bridges browsers to Mumble servers on an allowlist. Identities are kept in the browser, voice goes through the TCP tunnel. See "Browser version" in the README.
- Link previews in chat: title, description and image for web links (up to two per message). By default they are fetched by your f0ckm upload host, so the linked sites never see your IP address. Can be switched to "fetched by this computer" or off in Settings, Chat and files. - Link previews in chat: title, description and image for web links (up to two per message). By default they are fetched by your f0ckm upload host, so the linked sites never see your IP address. Can be switched to "fetched by this computer" or off in Settings, Chat and files.
- Voice tiles on small windows: when the window is too narrow for the member list, the people in your voice channel appear as tiles above the chat, light up while they talk, and keep mute and deafen at hand. The tiles can be collapsed. - Voice tiles on small windows: when the window is too narrow for the member list, the people in your voice channel appear as tiles above the chat, light up while they talk, and keep mute and deafen at hand. The tiles can be collapsed.
- Per-person volume slider (0 to 300%) directly in the user context menu. Double-click resets it to 100%. - Per-person volume slider (0 to 300%) directly in the user context menu. Double-click resets it to 100%.
@@ -22,6 +33,7 @@ All notable changes to mumh5. Versions follow the `version` in `package.json`.
### Fixed ### Fixed
- A description preview could open after dropping a channel and stay on screen.
- Messages sent in quick, irregular bursts could still be dropped by the server's rate limit. The client now mirrors Murmur's bucket exactly (it only leaks whole seconds and restarts its timer on every message) and queues instead of losing messages. - Messages sent in quick, irregular bursts could still be dropped by the server's rate limit. The client now mirrors Murmur's bucket exactly (it only leaks whole seconds and restarts its timer on every message) and queues instead of losing messages.
## 0.1.0 ## 0.1.0
+18 -3
View File
@@ -9,7 +9,7 @@
## Project overview ## Project overview
mumh5 is a Mumble client: Electron desktop app (Windows, macOS, Linux), Svelte 5 + TypeScript + Vite UI, Discord-like layout, responsive down to phone width. It must stay compatible with stock Murmur servers and with people on the desktop Mumble client. A browser build (through a self-hosted WebSocket proxy with a server allowlist) is planned. mumh5 is a Mumble client: Electron desktop app (Windows, macOS, Linux), Svelte 5 + TypeScript + Vite UI, Discord-like layout, responsive down to phone width. It must stay compatible with stock Murmur servers and with people on the desktop Mumble client. There is also a browser build that connects through a self-hosted WebSocket proxy with a server allowlist (`server/`).
File sharing uploads to a f0ckm instance (`../f0ckm`, `src/chat_upload_handler.mjs` there) with an upload-only key; links are posted in chat. File sharing uploads to a f0ckm instance (`../f0ckm`, `src/chat_upload_handler.mjs` there) with an upload-only key; links are posted in chat.
@@ -22,6 +22,12 @@ npm test # unit tests; server tests too when MUMBLE_TEST_HOST is s
npm run build # renderer to dist/, main+preload to dist-electron/ (esbuild) npm run build # renderer to dist/, main+preload to dist-electron/ (esbuild)
npm run test:e2e # drives the built app (needs npm run build first) npm run test:e2e # drives the built app (needs npm run build first)
npm run screenshots # regenerates docs/screenshots (see test/e2e/screenshots.ts) npm run screenshots # regenerates docs/screenshots (see test/e2e/screenshots.ts)
npm run build:web # browser build to dist-web/, proxy bundle to dist-proxy/proxy.mjs
npm run dev:web # browser build with hot reload: starts the proxy (any server allowed) and Vite
npm run proxy # run the proxy from source (MUMH5_SERVERS=host:port required)
npm run test:e2e:web # drives the browser build through the proxy (needs npm run build:web first)
npm run test:e2e:share # screen sharing between two instances of the built app
npm run themes # regenerate src/themes-generated.css and src/lib/themes-generated.ts (scripts/gen-themes.mjs)
npm run proto # regenerate src/core/mumble-pb.js and mumble-udp-pb.js from proto/ npm run proto # regenerate src/core/mumble-pb.js and mumble-udp-pb.js from proto/
``` ```
@@ -38,16 +44,22 @@ A reused test server keeps registrations and channels from earlier runs; tests m
## Architecture ## Architecture
- `electron/` (Node, main process): window, TLS sockets to Mumble servers (`tls-transport.ts`), encrypted UDP voice (`udp-voice.ts`, `ocb2.ts`, tested against Mumble's OCB2 vectors), identities and PKCS#12 (`identity.ts`, `identity-store.ts`), certificate parsing (`certs.ts`), tray (`tray.ts`). The renderer only gets the narrow `window.mumh5Native` API from `preload.ts` (context isolation, sandbox). - `electron/` (Node, main process): window, TLS sockets to Mumble servers (`tls-transport.ts`), encrypted UDP voice (`udp-voice.ts`, `ocb2.ts`, tested against Mumble's OCB2 vectors), identities and PKCS#12 (`identity.ts`, `identity-store.ts`), certificate parsing (`certs.ts`), tray (`tray.ts`). The renderer only gets the narrow `window.mumh5Native` API from `preload.ts` (context isolation, sandbox).
- `server/` (Node): the web proxy. `proxy.ts` serves `dist-web`, bridges WebSocket connections to Mumble over TLS (reusing `electron/tls-transport.ts`) and has stateless identity endpoints. It stores nothing; the browser keeps identities in localStorage and sends one with each connect. `turn.ts` is its STUN and TURN server for screen sharing between browser users (UDP and TCP on one port, credentials from `GET /api/relay`, which any origin may read so the desktop app can use a proxy as its relay; secret made up at start). `localStorage mumh5.iceDebug = relay` or `relay-tcp` limits a client to relayed routes, which is how the E2E exercises the relay.
- `src/core/` (browser-safe TypeScript, also runs in Node for tests): framing and codec (`proto.ts`), the Mumble client state machine (`client.ts`), voice packet formats (`voice-packet.ts`). No DOM, no Electron, no Node imports here. - `src/core/` (browser-safe TypeScript, also runs in Node for tests): framing and codec (`proto.ts`), the Mumble client state machine (`client.ts`), voice packet formats (`voice-packet.ts`). No DOM, no Electron, no Node imports here.
- `src/lib/`: app state. `session.svelte.ts` has one `Session` per server plus the `sessions` manager; `session` is a Proxy to the active one. `audio/voice.svelte.ts` is the voice engine (WebCodecs Opus, capture and playback AudioWorklets). `html.ts` sanitizes incoming HTML and serializes outgoing rich text. - `src/lib/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); where a browser has no WebCodecs audio it uses libopus in WebAssembly (`audio/opus-wasm.ts`, `localStorage mumh5.forceWasmOpus = 1` forces it). `html.ts` sanitizes incoming HTML and serializes outgoing rich text.
- Screen sharing: `src/core/share-signal.ts` (signals as Mumble plugin data, id `mumh5.share`, compressed and chunked) and `src/lib/share.svelte.ts` (one WebRTC connection per viewer and stream, candidates inside the description, no trickle; a viewer holds one screen and any number of cameras in `share.watches`). The start dialog is `ShareDialog.svelte`, the tiles and the large view are `VoiceStage.svelte`. Desktop source listing is `share:sources` / `share:pick` in `electron/main.ts`; Linux sound is `electron/pipewire.ts` (a virtual microphone fed with pw-link, never with this app's own playback).
- `src/ui/`: Svelte components. `App.svelte` owns layout and global dialogs (`ui.svelte.ts` store). - `src/ui/`: Svelte components. `App.svelte` owns layout and global dialogs (`ui.svelte.ts` store).
- `src/ui2/`: the Rooms interface (`settings.interface === 'rooms'`), a second shell on the same state: `Shell.svelte` (screens and bar, rooms beside the screen from 900px), `Rooms`, `ChatScreen`, `VoiceScreen`, `People`, `You`, `Dock`. It reuses `ChatLog`, `Composer`, `VoiceStage` (`fill`), `RightPanel` (as a sheet) and every dialog and menu from `src/ui/`. A feature added to one interface needs its place in the other. Class names there must not collide with ones `themes.css` styles (`.tabs`, `.rail`, `.tile`).
- Text channels: `src/core/text-signal.ts` (a marker in the channel description, messages as plugin data, id `mumh5.text`, sent to everyone on the server) and `src/lib/text-channels.ts` (pacing under Murmur's plugin limit, messages kept in IndexedDB per server, catch-up from one other client after connecting). They share the `ch:<id>` chat view; `session.isText(id)` tells them apart. Channel order for drag and drop is `src/core/channel-order.ts`.
Voice runs on one server at a time (where you last joined a channel); background servers are auto self-deafened and restored when voice returns. Voice runs on one server at a time (where you last joined a channel); background servers are auto self-deafened and restored when voice returns.
## Conventions ## Conventions
- Svelte 5 runes only. Comment density: short comments that explain why, not what. - Svelte 5 runes only. Comment density: short comments that explain why, not what.
- The protobuf codec is generated static code (no eval) because the CSP forbids `unsafe-eval`. Field names keep snake_case from the .proto. - The protobuf codec is generated static code (no eval) because the CSP forbids `unsafe-eval`. `wasm-unsafe-eval` is allowed, for the built-in Opus codec only. Field names keep snake_case from the .proto.
- TypeScript files imported by Node tests use `.ts` extensions and erasable syntax only (no parameter properties, no enums). - TypeScript files imported by Node tests use `.ts` extensions and erasable syntax only (no parameter properties, no enums).
- New features get an E2E step in `test/e2e/app.e2e.ts`; protocol logic gets unit or server tests. - New features get an E2E step in `test/e2e/app.e2e.ts`; protocol logic gets unit or server tests.
- Privacy rule: opening a chat must never contact hosts the user did not choose. Remote media only from the upload host and user-listed hosts; YouTube is click-to-play (`youtube-nocookie.com`). - Privacy rule: opening a chat must never contact hosts the user did not choose. Remote media only from the upload host and user-listed hosts; YouTube is click-to-play (`youtube-nocookie.com`).
@@ -56,6 +68,7 @@ Voice runs on one server at a time (where you last joined a channel); background
- The CSP allows media only from `self`, `blob:` and http(s). `data:` audio is blocked silently: play user files through object URLs. Large user files go to IndexedDB (`src/lib/blobstore.ts`), not localStorage. - The CSP allows media only from `self`, `blob:` and http(s). `data:` audio is blocked silently: play user files through object URLs. Large user files go to IndexedDB (`src/lib/blobstore.ts`), not localStorage.
- `window.prompt` does not exist in Electron; use `ui.prompt`. - `window.prompt` does not exist in Electron; use `ui.prompt`.
- `desktopCapturer.getSources` can return an empty list on its first calls under X11 and takes seconds on the bare Xvfb test display; main retries and the dialog has Refresh. With `--use-fake-device-for-media-stream` the captured picture is Chromium's test pattern, not the screen. On Wayland the call itself opens the system picker, so it is never repeated.
- A drop handler must read derived state before clearing the drag item it derives from. - A drop handler must read derived state before clearing the drag item it derives from.
## Svelte reactivity pitfalls (all hit before) ## Svelte reactivity pitfalls (all hit before)
@@ -70,6 +83,8 @@ Voice runs on one server at a time (where you last joined a channel); background
- Murmur silently drops own UserState, TextMessage, ChannelState, ACL and Version past a leaky bucket (burst 5, 1/s). The client paces these; don't bypass `send()`. - Murmur silently drops own UserState, TextMessage, ChannelState, ACL and Version past a leaky bucket (burst 5, 1/s). The client paces these; don't bypass `send()`.
- 1.5 servers use the protobuf UDP voice format (type byte 0 + MumbleUDP.Audio) with clients announcing 1.5; older ones the legacy format. The UDPTunnel TCP body is the raw voice packet, not a protobuf message. Sequence numbers count 10 ms frames. - 1.5 servers use the protobuf UDP voice format (type byte 0 + MumbleUDP.Audio) with clients announcing 1.5; older ones the legacy format. The UDPTunnel TCP body is the raw voice packet, not a protobuf message. Sequence numbers count 10 ms frames.
- Long comments and descriptions arrive as a hash only; a new hash invalidates the old text; fetch with RequestBlob. - Long comments and descriptions arrive as a hash only; a new hash invalidates the old text; fetch with RequestBlob.
- Murmur drops PluginDataTransmission over 1000 bytes and rate limits it (measured: burst 15, then 4 per second, per sender, dropped silently); keep packets at 900 bytes and few.
- Murmur gives the id of a removed channel to the next one created; anything kept per channel id must be dropped on ChannelRemove.
- Renaming while connected is not possible; mumh5 reconnects with the new name. - Renaming while connected is not possible; mumh5 reconnects with the new name.
- Murmur never sends SuperUser (user id 0) PermissionQuery answers; treat SuperUser as allowed everything. - Murmur never sends SuperUser (user id 0) PermissionQuery answers; treat SuperUser as allowed everything.
- In ACLs, Write overrides denies. Grant test rights to one user (`$<certhash>` group), not `@all`. - In ACLs, Write overrides denies. Grant test rights to one user (`$<certhash>` group), not `@all`.
+18
View File
@@ -0,0 +1,18 @@
# The browser version: web app and proxy in one image.
# docker compose up -d --build
FROM node:22-slim AS build
WORKDIR /src
COPY package.json package-lock.json ./
# No install scripts: the Electron download they would trigger is only needed for the desktop app
RUN npm ci --ignore-scripts
COPY . .
RUN npm run build:web
FROM node:22-slim
WORKDIR /app
COPY --from=build /src/dist-web ./dist-web
COPY --from=build /src/dist-proxy ./dist-proxy
USER node
ENV MUMH5_PORT=8080
EXPOSE 8080/tcp 3478/udp 3478/tcp 49160-49659/udp
CMD ["node", "dist-proxy/proxy.mjs"]
+164 -71
View File
@@ -1,92 +1,92 @@
# mumh5 # mumh5
**Mumble, the way it should feel in 2026.** mumh5 is a client for [Mumble](https://www.mumble.info/), the open source voice chat. It runs on Windows, macOS and Linux, and in a browser through a small proxy you host yourself.
mumh5 is a modern client for [Mumble](https://www.mumble.info/), the free, open source, low-latency voice chat. It gives Mumble the kind of interface people know from Discord: servers on the left, channels and people in the middle, chat that can do more than text. Underneath, it's plain Mumble, so it connects to **any existing Mumble server** without changing anything on the server side. It connects to any Mumble server as it is. Nothing has to be installed or changed on the server, and people on the regular Mumble client see and hear you like anyone else.
Your community keeps its own server, its own rules and its own data. You get an app that's nice to use.
![mumh5 main window: channel tree, chat with a YouTube card, member list](docs/screenshots/main.png) ![mumh5 main window: channel tree, chat with a YouTube card, member list](docs/screenshots/main.png)
--- ---
## Why mumh5 ## What it does
Mumble has the best foundation of any voice chat: tiny latency, strong encryption, self-hosting, no accounts, no ads, no tracking. What held it back was the desktop client, which still looks and behaves like 2010. - **Voice**: Opus over encrypted UDP, with voice activity, push to talk or always on.
- **Chat**: a separate chat per channel and person, with files, pictures, formatting and link previews.
- **Several servers at once**: text on all of them, voice on the one where you joined a channel.
- **Screen and camera sharing** between mumh5 users in a channel.
- **Text channels** between mumh5 users: read and written without joining them.
- **Channel management**: create, edit, move and link channels, and edit permissions and groups.
- **Two interfaces**: Standard (channel tree, chat and members side by side) and Rooms (one screen at a time, made for phones).
- **100 color schemes**, from Dark and Light to Windows 95.
mumh5 keeps the foundation and replaces the experience. Some features only work between mumh5 users, because Mumble itself has no such thing: screen and camera sharing, text channels, and inline players for shared files. People on other Mumble clients are not disturbed by them; they see a normal channel, a normal link, or nothing.
- **Works with the servers you already use.** No plugins, no server changes, no migration. Friends on the regular Mumble client see you and hear you like anyone else. ### Not in mumh5 yet
- **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.
- **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 System-wide push to talk, whisper and shout, positional audio, the in-game overlay, recording, and the ban list and registered-user editors. The Mumble desktop client has these.
| | Mumble desktop | mumh5 |
|---|---|---|
| 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 |
| 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 |
| Identities | One certificate per installation | Several identities, a different one per server if you like; imports your desktop certificate |
| Server certificate trust | Remembered after a warning dialog | Pinned on first use, green header for CA-signed servers, full chain viewer |
| Fast actions in a row | The server silently drops them past its rate limit | Paced to the server's limit, nothing gets lost |
| Right-click on mute or deafen | Nothing | Quick device and volume options |
| Look | One Qt style (plus skins) | Five built-in color schemes, including Windows 95 |
| Channel and permission editing | Dialogs, drag and drop | Same power: create, edit, link, drag and drop, rule and group editor |
**Where the desktop client is still ahead, today:** system-wide push-to-talk, whisper and shout, positional audio, the in-game overlay, recording, and the ban list and registered-user editors. These are on the roadmap below.
--- ---
## Features ## Features
### Voice ### Voice
- Opus voice with voice activity detection, push to talk or always-on - Voice activity detection with a live level meter and an adjustable threshold, push to talk, or always on
- Live level meter with an adjustable threshold, so you set it once and forget it
- Noise suppression, echo cancellation and automatic gain control - Noise suppression, echo cancellation and automatic gain control
- Microphone and output device selection, input and output volume - Microphone and output selection, input and output volume
- Per-person volume (0 to 300%) and "mute for me" - Per-person volume (0 to 300%) and "mute for me"
- Speaking indicators in the channel tree, member list and your own panel - Speaking indicators in the channel tree, the member list and your own panel
- On small windows, your voice channel appears as tiles above the chat, lighting up as people talk, with mute and deafen at hand - Encrypted UDP (Mumble's OCB2-AES128), with fallback to the TCP connection and a TCP-only switch
- Low-latency voice over encrypted UDP (Mumble's OCB2-AES128), with automatic fallback to the TCP connection and a TCP-only switch - Bitrate limited to what the server allows
- Bitrate automatically limited to what the server allows - Right-click mute or deafen for quick device and volume options
- Right-click the mute or deafen button for quick device and volume options - Microphone test that plays your voice back to you
- Hear-yourself microphone test - Optional: give the microphone back while muted, so a Bluetooth headset stays out of its telephone mode. On by default on phones; not yet tried on a real phone
![Voice settings](docs/screenshots/voice-settings.png) ![Voice settings](docs/screenshots/voice-settings.png)
### Channels and people ### Channels and people
- Channel tree that starts collapsed; channels with people in them open up - Channel tree that starts collapsed; channels with people in them open up
- Single click previews a channel's description, double click joins - Single click shows a channel's description, double click joins
- Side chat: write in any channel without leaving yours - Side chat: write in any channel without leaving yours
- Profiles with rich descriptions (formatting, lists, links, pictures), local nicknames, registration status - Profiles with formatted descriptions, local nicknames and registration status
- Change your name (mumh5 reconnects for you) and register on a server with one click - Change your name (mumh5 reconnects for you) and register on a server
- Connection information: ping, packet loss, client version and, for admins, the user's certificate chain - Connection information: ping, packet loss, client version and, for admins, the certificate chain
- Moderation from the right-click menu: move, server mute and deafen, priority speaker, kick, ban, register - Moderation from the right-click menu: move, server mute and deafen, priority speaker, kick, ban, register
- Channel management: create, edit, delete, link, and drag and drop channels and people - Create, edit, delete and link channels. Drag a channel into another, or in front of or behind it; drag people between channels
- Full permission editor: rules per channel with inheritance, groups and members, like the desktop ACL editor - Permission editor: rules per channel with inheritance, groups and members
- If you get kicked, you see who kicked you and why, with a Reconnect button - If you are kicked, you see who did it and why, with a Reconnect button
![Profile with a formatted description](docs/screenshots/profile.png) ![Profile with a formatted description](docs/screenshots/profile.png)
### Chat ### Chat
- Messages grouped by author, direct messages, unread badges - Messages grouped by author, direct messages, unread badges
- File sharing: drag and drop, paste, or the attach button, with upload progress - Files by drag and drop, paste or the attach button, with upload progress (needs an [upload host](#file-sharing-with-f0ckm); pictures work without one)
- Inline images, video and audio players for shared media - Inline pictures, video and audio for shared files
- YouTube links as click-to-play players - YouTube links as click-to-play players (off by default)
- Link previews with title, description and image; fetched by your upload host by default, so posting a link cannot reveal who reads it - Link previews with title, description and picture
- Incoming HTML is sanitized; remote images from unknown hosts are never loaded - Incoming HTML is sanitized, and pictures from unknown hosts are never loaded
- Messages stay compatible with the desktop client, including picture previews - Messages stay readable in the Mumble desktop client
### Color schemes ### Text channels
Dark, Light, Midnight (true black), Frost, and a faithful **Windows 95** theme with beveled buttons, navy title bars and the teal desktop. - A channel marked as a text channel is read and written without joining it, from any voice channel
- It is a normal Mumble channel on the server, created and removed with the usual channel permissions
- The server keeps no messages. Each client keeps the last 200 per channel and passes the last 50 to people who connect later
- Everyone on the server who uses mumh5 can read them; there are no private text channels yet
- Needs a server from version 1.4 that allows HTML
### Screen and camera sharing
- Share a screen, a window or a camera with the mumh5 users in your channel. A dialog shows a preview and lets you choose the sound and the quality first
- Cameras appear in the voice tiles, several at once; a click enlarges one
- A screen opens large, with the people as a strip of tiles below, and can move to its own window
- Sound: on Linux one program or everything except mumh5 (through PipeWire); on Windows the whole system; in a browser what the browser offers
- The stream goes directly between the two clients (WebRTC), up to 8 viewers per stream. Only the setup messages pass through the Mumble server
- Sharer and viewer see each other's IP address; mumh5 says so before the first use
- Across the internet both sides need a STUN server. The browser version uses its proxy's, with a relay when no direct connection works. In the desktop app you set one in Settings, Voice. Nothing is contacted unless you set it
- Tested between two instances on one machine with a test picture. Sound capture, real screens, connections across the internet, Windows and the browser build are untested
### Interfaces and look
- Standard interface in three layouts: channels left and members right, chat left and channels right, or channels on top
- Rooms interface: rooms as a list with the people inside, a voice screen, a people screen, and mute and deafen always in reach. New and not complete: channels and people cannot be moved by dragging there yet
- 100 color schemes with a search box. Eleven are made by hand, the rest are generated from a hue and an accent
| Windows 95 | Light | Frost | Midnight | | Windows 95 | Light | Frost | Midnight |
|---|---|---|---| |---|---|---|---|
@@ -94,15 +94,15 @@ Dark, Light, Midnight (true black), Frost, and a faithful **Windows 95** theme w
### Sounds ### Sounds
- Notification sounds for joins, leaves, messages, mute, push to talk, kicks and more - Notification sounds for joins, leaves, messages, mute, push to talk, kicks and more
- Replace any sound with your own file, or load a whole sound pack at once (matched by file name) - Replace any sound with your own file, or load a sound pack at once (matched by file name)
### Servers and identity ### Servers and identity
- Server rail with custom icons, drag-and-drop ordering, connection status and unread counts - Server list with custom icons, your own order, connection status and unread counts
- Public server browser with live user counts and ping, like the desktop client - Public server browser with user counts and ping (desktop app)
- Several servers connected at once; voice follows the channel you join - Identity setup: create a certificate, import a `.p12`, or reuse the one from the Mumble desktop client
- Identity setup wizard: create a new certificate, import a `.p12`, or reuse your desktop Mumble identity - Several identities, and a different one per server if you like
- Password-protected backups of your identity - Password-protected backups of an identity
- Trust on first use for server certificates, a green header for CA-signed ones, and a certificate viewer - Server certificates are pinned on first use; a certificate viewer shows the chain
- Tray icon that shows whether you are talking, muted or deafened - Tray icon that shows whether you are talking, muted or deafened
<p align="center"><img src="docs/screenshots/mobile.png" alt="mumh5 in a narrow window" width="300"></p> <p align="center"><img src="docs/screenshots/mobile.png" alt="mumh5 in a narrow window" width="300"></p>
@@ -147,6 +147,99 @@ 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).
With Docker, the same thing is one command; settings go in a `.env` file next to `docker-compose.yml`:
```bash
echo 'MUMH5_SERVERS=mumble.example.com=My server' > .env
docker compose up -d --build
```
The container uses the host's network: the proxy listens on `127.0.0.1:8080` for your reverse proxy, and a Mumble server on the same machine is reachable as `localhost`.
For screen sharing, open these in the firewall (and forward them on a router in front of the server): port 3478 for UDP and TCP, and UDP 49160-49659. They carry STUN and the relay, which the browser version uses without any setting. Relayed streams pass through your server and use its bandwidth, a few Mbit/s per viewer; anyone who knows the site's address can get credentials for it (the desktop app asks from outside the site), limited by connection quotas per address; there is no bandwidth cap yet.
| 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_STUN_PORT`, `MUMH5_STUN_BIND` | `3478`, all addresses | Port for screen sharing between browser users: STUN over UDP, and the relay over UDP and TCP. Browsers reach it directly, not through nginx. `0` turns both off |
| `MUMH5_TURN` | on | The relay (TURN) for people who cannot connect directly: mobile networks, strict company networks, browsers that forbid direct UDP such as Vanadium. `0` leaves only STUN |
| `MUMH5_TURN_PORTS` | `49160-49659` | UDP ports the relayed streams use, one per relayed route |
| `MUMH5_TURN_URLS` | none | Extra relay addresses handed to clients, comma-separated, for example `turns:turn.example.com:443?transport=tcp` when a TLS front forwards to port 3478. For networks that only let port 443 out |
| `MUMH5_DEBUG` | off | Log relay events (credentials handed out, routes granted or refused) |
| `MUMH5_TURN_MAX`, `MUMH5_TURN_PER_ADDRESS` | `500`, `64` | Relayed routes in total and per client address. A camera round uses several per person |
| `MUMH5_TURN_IP` | found automatically | The server's public address, announced for relayed streams. Set it when the server sits behind a 1:1 NAT, as on many cloud hosts |
| `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 uses the browser's own audio codec where there is one, and a built-in one otherwise (Firefox on Android). Both are tested in Chromium only.
## File sharing with f0ckm ## File sharing with f0ckm
Mumble can only carry small inline images. mumh5 can share any file by uploading it to a f0ckm instance (a self-hosted imageboard by the same author) and posting the link. Other mumh5 users get inline players; desktop Mumble users get a normal link plus a small preview. Mumble can only carry small inline images. mumh5 can share any file by uploading it to a f0ckm instance (a self-hosted imageboard by the same author) and posting the link. Other mumh5 users get inline players; desktop Mumble users get a normal link plus a small preview.
@@ -167,9 +260,9 @@ Without an upload host, mumh5 still sends images, scaled to fit the server's lim
## Privacy and security ## Privacy and security
- **Your identity is a certificate** that stays on your computer, stored with owner-only permissions. mumh5 reminds you to back it up. - **Your identity is a certificate** that stays on your computer, stored with owner-only permissions. mumh5 reminds you to back it up. (The browser version is different, see above.)
- **Server certificates are pinned** the first time you connect. If one changes, mumh5 stops before sending your password and asks you. - **Server certificates are pinned** the first time you connect. If one changes, mumh5 stops before sending your password and asks you.
- **Chats don't phone home.** Media is only loaded from your upload host and hosts you add yourself. YouTube is click-to-play in privacy mode. - **Chats don't phone home.** Media is only loaded from your upload host and hosts you add yourself. YouTube players are off by default and click-to-play in privacy mode when enabled.
- **Strict content security policy** in the app, and all message HTML is sanitized. - **Strict content security policy** in the app, and all message HTML is sanitized.
--- ---
@@ -178,9 +271,10 @@ Without an upload host, mumh5 still sends images, scaled to fit the server's lim
- System-wide push to talk - System-wide push to talk
- Whisper and shout - Whisper and shout
- Text channels: private ones, and history while nobody is online (needs a helper on the server)
- Rooms interface: moving channels and people without dragging, gestures, more polish
- Rich chat between mumh5 users: replies, reactions, edits, typing indicators - Rich chat between mumh5 users: replies, reactions, edits, typing indicators
- Video and screen sharing between mumh5 users - Screen sharing: a media server for large rounds (today every sender uploads once per viewer, up to 8)
- A browser version, through a small self-hosted WebSocket proxy
- Release builds for all platforms - Release builds for all platforms
--- ---
@@ -201,6 +295,7 @@ docker run -d --name mumh5-test-murmur -p 64739:64738 -p 64739:64738/udp \
MUMBLE_TEST_HOST=localhost:64739 npm test MUMBLE_TEST_HOST=localhost:64739 npm test
npm run build npm run build
MUMBLE_TEST_HOST=localhost:64739 MUMBLE_SUPERUSER_PASSWORD=testsuper npm run test:e2e MUMBLE_TEST_HOST=localhost:64739 MUMBLE_SUPERUSER_PASSWORD=testsuper npm run test:e2e
npm run build:web && MUMBLE_TEST_HOST=localhost:64739 npm run test:e2e:web # browser build through the proxy
``` ```
On Linux the end-to-end test forces X11, so it can run on a virtual display: `Xvfb :99 & DISPLAY=:99 npm run test:e2e`. See [CLAUDE.md](CLAUDE.md) for the architecture and project conventions. On Linux the end-to-end test forces X11, so it can run on a virtual display: `Xvfb :99 & DISPLAY=:99 npm run test:e2e`. See [CLAUDE.md](CLAUDE.md) for the architecture and project conventions.
@@ -218,11 +313,9 @@ test/ unit, server and end-to-end tests
## AI disclosure and credits ## AI disclosure and credits
mumh5 is built by **Kibi** together with **Claude**, Anthropic's AI model, working in Claude Code. mumh5 is built by **Kibi** together with **Claude**, Anthropic's AI model, working in Claude Code. Kibi decides what mumh5 should be and whether a result is good enough. Claude writes most of the code, the tests and this documentation. Commits written with Claude carry a `Co-Authored-By: Claude` line.
Kibi sets the direction: what mumh5 should be, how it should look and feel, which trade-offs to make, and whether a result is good enough. Claude writes most of the code, the tests and this documentation, and checks each feature against a real Mumble server before calling it done. Commits written with Claude carry a `Co-Authored-By: Claude` line. Features are covered by automated tests, including end-to-end tests against a real Mumble server. If something is wrong, please open an issue.
We say this openly because you should know how the software you run was made. Features are covered by automated tests, including end-to-end tests against a real Mumble server. If something is wrong, please open an issue.
## License ## License
+8
View File
@@ -41,3 +41,11 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
Runtime and build dependencies (Electron, Svelte, protobufjs, node-forge, DOMPurify and Runtime and build dependencies (Electron, Svelte, protobufjs, node-forge, DOMPurify and
others) are distributed under their own licenses, found in each package under `node_modules/`. others) are distributed under their own licenses, found in each package under `node_modules/`.
## @evan/opus and libopus
The Opus codec compiled to WebAssembly (`opus.wasm`), used for voice in browsers that have no
audio codec of their own. The package is MIT licensed, Copyright (c) 2021-present evanwashere.
libopus is under the three-clause BSD license, Copyright 2001-2011 Xiph.Org, Skype Limited,
Octasic, Jean-Marc Valin, Timothy B. Terriberry, CSIRO, Gregory Maxwell, Mark Borgerding,
Erik de Castro Lopo.
+34
View File
@@ -0,0 +1,34 @@
# The mumh5 web proxy. Settings come from a .env file next to this one, for example:
# MUMH5_SERVERS=mumble.example.com=My server
# MUMH5_TRUST_PROXY=1
services:
mumh5:
build: .
restart: unless-stopped
# Host networking, so STUN sees each visitor's real address (through Docker's port
# forwarding it would often see Docker's own) and a Mumble server on this machine is
# reachable as localhost. The proxy listens on 127.0.0.1:8080 for nginx, on port 3478 (UDP and
# TCP) for STUN and the relay, and relays streams on UDP 49160-49659.
network_mode: host
# Passed on from .env (or the shell); empty means the proxy's default. Written out one by
# one because older docker-compose versions cannot mark an env_file as optional.
environment:
MUMH5_SERVERS: ${MUMH5_SERVERS:-}
MUMH5_ALLOW_ANY: ${MUMH5_ALLOW_ANY:-}
MUMH5_ALLOW_PRIVATE: ${MUMH5_ALLOW_PRIVATE:-}
MUMH5_PORT: ${MUMH5_PORT:-8080}
MUMH5_BIND: ${MUMH5_BIND:-127.0.0.1}
MUMH5_ORIGINS: ${MUMH5_ORIGINS:-}
MUMH5_TRUST_PROXY: ${MUMH5_TRUST_PROXY:-}
MUMH5_SEND_PROXY: ${MUMH5_SEND_PROXY:-}
MUMH5_STUN_PORT: ${MUMH5_STUN_PORT:-3478}
MUMH5_STUN_BIND: ${MUMH5_STUN_BIND:-}
MUMH5_TURN: ${MUMH5_TURN:-}
MUMH5_TURN_IP: ${MUMH5_TURN_IP:-}
MUMH5_TURN_PORTS: ${MUMH5_TURN_PORTS:-}
MUMH5_TURN_URLS: ${MUMH5_TURN_URLS:-}
MUMH5_DEBUG: ${MUMH5_DEBUG:-}
MUMH5_TURN_MAX: ${MUMH5_TURN_MAX:-}
MUMH5_TURN_PER_ADDRESS: ${MUMH5_TURN_PER_ADDRESS:-}
MUMH5_MAX_CONNECTIONS: ${MUMH5_MAX_CONNECTIONS:-}
MUMH5_MAX_PER_ADDRESS: ${MUMH5_MAX_PER_ADDRESS:-}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 253 KiB

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 266 KiB

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 255 KiB

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 254 KiB

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 283 KiB

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 119 KiB

+66 -2
View File
@@ -1,4 +1,4 @@
import { app, BrowserWindow, dialog, ipcMain, net, shell, session, systemPreferences, type WebContents } from 'electron'; import { app, BrowserWindow, desktopCapturer, dialog, ipcMain, net, shell, session, systemPreferences, type WebContents } from 'electron';
import { promises as fs } from 'node:fs'; import { promises as fs } from 'node:fs';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
@@ -9,9 +9,15 @@ import { fetchLinkPreview } from './link-preview.ts';
import { fetchPublicListWith, pingServer } from './publist.ts'; import { fetchPublicListWith, pingServer } from './publist.ts';
import { openTls } from './tls-transport.ts'; import { openTls } from './tls-transport.ts';
import { udpChannel } from './udp-voice.ts'; import { udpChannel } from './udp-voice.ts';
import { ShareAudio, pipewireAvailable, VIRTUAL_MIC_LABEL, type AudioTarget } from './pipewire.ts';
const devUrl = process.env.VITE_DEV_SERVER_URL; const devUrl = process.env.VITE_DEV_SERVER_URL;
// Screen sharing connects mumh5 users directly. Chromium would hide this computer's addresses
// behind names that only resolve on the local network, so two people on IPv6 could not reach
// each other without a helper server. Only this app's own page runs here.
app.commandLine.appendSwitch('disable-features', 'WebRtcHideLocalIpsWithMdns');
let identityStore: IdentityStore | null = null; let identityStore: IdentityStore | null = null;
const identities = () => (identityStore ??= new IdentityStore(app.getPath('userData'))); const identities = () => (identityStore ??= new IdentityStore(app.getPath('userData')));
@@ -120,6 +126,49 @@ ipcMain.handle('publist:ping', (_e, host: string, port: number) => pingServer(St
ipcMain.handle('preview:fetch', (_e, url: string) => fetchLinkPreview(String(url).slice(0, 2048))); ipcMain.handle('preview:fetch', (_e, url: string) => fetchLinkPreview(String(url).slice(0, 2048)));
// ─── Screen sharing ───────────────────────────────────────────────────────────
// The renderer lists the sources, the user picks one, and the next getDisplayMedia call gets it.
// On Wayland, listing already opens the system's picker and returns the one chosen source;
// `chosen` tells the renderer not to ask a second time.
let screenSources: Electron.DesktopCapturerSource[] = [];
let pickedSource: string | null = null;
const onWayland = () => process.platform === 'linux' && app.commandLine.getSwitchValue('ozone-platform') !== 'x11' &&
(process.env.XDG_SESSION_TYPE === 'wayland' || !!process.env.WAYLAND_DISPLAY);
// Sound: PipeWire on Linux (one program or everything but this app), system loopback on Windows
const shareAudio = new ShareAudio(() => app.getAppMetrics().map(m => m.pid));
ipcMain.handle('share:info', async () => ({
portal: onWayland(),
audio: process.platform === 'win32' ? 'loopback' : (await pipewireAvailable()) ? 'pipewire' : 'none',
micLabel: VIRTUAL_MIC_LABEL
}));
ipcMain.handle('share:audioApps', () => shareAudio.apps().catch(() => []));
ipcMain.handle('share:audioStart', (_e, target: AudioTarget) =>
shareAudio.start(target?.kind === 'app' ? { kind: 'app', name: String(target.name) } : { kind: 'all' }));
ipcMain.handle('share:audioStop', () => shareAudio.stop());
// The virtual microphone outlives the process otherwise
app.on('will-quit', () => { shareAudio.stop(); });
// Names first (fast and dependable), thumbnails in a second call: grabbing the pictures can be
// slow and, under X11, can come back with nothing.
ipcMain.handle('share:sources', async (_e, thumbnails: boolean) => {
const wayland = onWayland();
const size = thumbnails ? { width: 320, height: 180 } : { width: 0, height: 0 };
const list = () => desktopCapturer.getSources({ types: ['screen', 'window'], thumbnailSize: size });
let found = await list();
// Not on Wayland, where empty means the user cancelled the system's picker and asking again would reopen it
for (let i = 0; i < 3 && !found.length && !wayland; i++) {
await new Promise(r => setTimeout(r, 250));
found = await list();
}
if (found.length || !thumbnails) screenSources = found;
return {
chosen: wayland && found.length === 1,
sources: found.map(s => ({ id: s.id, name: s.name, thumbnail: s.thumbnail.isEmpty() ? '' : s.thumbnail.toDataURL() }))
};
});
ipcMain.on('share:pick', (_e, id: string) => { pickedSource = String(id); });
ipcMain.on('tray:update', (_e, state: tray.TrayState) => tray.update(state)); ipcMain.on('tray:update', (_e, state: tray.TrayState) => tray.update(state));
ipcMain.handle('platform:info', () => ({ os: process.platform, osVersion: os.release() })); ipcMain.handle('platform:info', () => ({ os: process.platform, osVersion: os.release() }));
@@ -152,7 +201,11 @@ function createWindow() {
}); });
// Links from chat open in the system browser, never inside the app // Links from chat open in the system browser, never inside the app
win.webContents.setWindowOpenHandler(({ url }) => { win.webContents.setWindowOpenHandler(({ url, frameName }) => {
// The stream window: an empty page the app fills with the video itself
if (frameName === 'mumh5-stream' && (url === 'about:blank' || url === '')) {
return { action: 'allow', overrideBrowserWindowOptions: { width: 960, height: 600, minWidth: 320, minHeight: 200, backgroundColor: '#000000', autoHideMenuBar: true, title: 'mumh5' } };
}
if (/^https?:\/\//i.test(url)) shell.openExternal(url); if (/^https?:\/\//i.test(url)) shell.openExternal(url);
return { action: 'deny' }; return { action: 'deny' };
}); });
@@ -195,6 +248,17 @@ app.whenReady().then(async () => {
cb({ requestHeaders: details.requestHeaders }); cb({ requestHeaders: details.requestHeaders });
}); });
session.defaultSession.setDisplayMediaRequestHandler((request, callback) => {
const source = screenSources.find(s => s.id === pickedSource);
pickedSource = null;
// Without a picked source the request is refused
try {
if (!source) callback({});
else if (request.audioRequested && process.platform === 'win32') callback({ video: source, audio: 'loopback' });
else callback({ video: source });
} catch { /* refused */ }
});
// Microphone, camera and screen capture for voice and video; nothing else // Microphone, camera and screen capture for voice and video; nothing else
session.defaultSession.setPermissionRequestHandler((_wc, permission, cb) => { session.defaultSession.setPermissionRequestHandler((_wc, permission, cb) => {
cb(['media', 'display-capture', 'clipboard-sanitized-write', 'notifications'].includes(permission)); cb(['media', 'display-capture', 'clipboard-sanitized-write', 'notifications'].includes(permission));
+158
View File
@@ -0,0 +1,158 @@
// Sound for screen sharing on Linux. Chromium cannot capture what other programs play, so a
// virtual microphone is created in PipeWire and the chosen programs' output is linked into it;
// the renderer then records that microphone like any other. Works through PipeWire's command
// line tools (pw-cli, pw-dump, pw-link, pw-mon), no native module.
import { execFile, spawn, type ChildProcess } from 'node:child_process';
export const VIRTUAL_MIC = 'mumh5-share-audio';
export const VIRTUAL_MIC_LABEL = 'mumh5 screen share audio';
// What to capture: everything except this app's own sound, or one program
export type AudioTarget = { kind: 'all' } | { kind: 'app'; name: string };
interface PwObject { id: number; type: string; info?: { direction?: string; props?: Record<string, any> } }
function run(cmd: string, args: string[], maxBuffer = 64 * 1024 * 1024): Promise<string> {
return new Promise((resolve, reject) => {
execFile(cmd, args, { maxBuffer, timeout: 5000 }, (err, stdout) => err ? reject(err) : resolve(stdout));
});
}
const dump = async (): Promise<PwObject[]> => JSON.parse(await run('pw-dump', []));
// A playing program: its name and process come from the stream itself or from the client that owns it
function describe(node: PwObject, byId: Map<number, PwObject>): { name: string; pid: number | null } {
const p = node.info?.props ?? {};
const client = byId.get(p['client.id'])?.info?.props ?? {};
return {
name: String(p['application.name'] ?? client['application.name'] ?? p['node.name'] ?? ''),
pid: Number(p['application.process.id'] ?? client['application.process.id']) || null
};
}
const outputs = (objects: PwObject[]) =>
objects.filter(o => o.type === 'PipeWire:Interface:Node' && o.info?.props?.['media.class'] === 'Stream/Output/Audio');
// Programs currently playing sound, without this app
export function audioApps(objects: PwObject[], ownPids: number[]): string[] {
const byId = new Map(objects.map(o => [o.id, o]));
const names = new Set<string>();
for (const node of outputs(objects)) {
const d = describe(node, byId);
if (d.name && !(d.pid && ownPids.includes(d.pid))) names.add(d.name);
}
return [...names].sort((a, b) => a.localeCompare(b));
}
// Which output ports to connect to which inputs of the virtual microphone, and which existing
// links to remove. This app's own playback is never linked: the people watching are in the same
// voice channel and would hear themselves back.
export function planLinks(objects: PwObject[], target: AudioTarget, ownPids: number[]): { add: [number, number][]; remove: number[] } {
const byId = new Map(objects.map(o => [o.id, o]));
const mic = objects.find(o => o.type === 'PipeWire:Interface:Node' && o.info?.props?.['node.name'] === VIRTUAL_MIC);
if (!mic) return { add: [], remove: [] };
const ports = objects.filter(o => o.type === 'PipeWire:Interface:Port');
const micIn = ports.filter(p => p.info?.props?.['node.id'] === mic.id && p.info?.direction === 'input');
const input = (channel: string) => micIn.find(p => p.info?.props?.['audio.channel'] === channel)?.id;
const left = input('FL'), right = input('FR');
if (left == null || right == null) return { add: [], remove: [] };
const wanted = new Set<string>();
for (const node of outputs(objects)) {
const d = describe(node, byId);
if (d.pid && ownPids.includes(d.pid)) continue;
if (target.kind === 'app' && d.name !== target.name) continue;
for (const port of ports) {
if (port.info?.props?.['node.id'] !== node.id || port.info?.direction !== 'output') continue;
const channel = port.info.props['audio.channel'];
// Mono programs go to both sides; surround beyond the front pair is left out
if (channel === 'FL' || channel === 'MONO') wanted.add(`${port.id}:${left}`);
if (channel === 'FR' || channel === 'MONO') wanted.add(`${port.id}:${right}`);
}
}
const remove: number[] = [];
for (const link of objects.filter(o => o.type === 'PipeWire:Interface:Link')) {
const info = link.info as any;
if (info?.['input-node-id'] !== mic.id) continue;
const key = `${info['output-port-id']}:${info['input-port-id']}`;
if (wanted.has(key)) wanted.delete(key);
else remove.push(link.id);
}
return { add: [...wanted].map(k => k.split(':').map(Number) as [number, number]), remove };
}
// Whether PipeWire and its tools are there
export async function pipewireAvailable(): Promise<boolean> {
if (process.platform !== 'linux') return false;
try { await dump(); await run('pw-link', ['--version']); await run('pw-cli', ['--version']); return true; } catch { return false; }
}
export class ShareAudio {
private target: AudioTarget | null = null;
private monitor: ChildProcess | null = null;
private debounce: ReturnType<typeof setTimeout> | undefined;
private busy: Promise<void> = Promise.resolve();
private ownPids: () => number[];
constructor(ownPids: () => number[]) { this.ownPids = ownPids; }
async apps(): Promise<string[]> {
return audioApps(await dump(), this.ownPids());
}
// Creates the microphone, links the target and keeps the links right while programs come and go
async start(target: AudioTarget): Promise<void> {
this.target = target;
if (!(await dump()).some(o => o.info?.props?.['node.name'] === VIRTUAL_MIC)) {
await run('pw-cli', ['create-node', 'adapter',
`{ factory.name=support.null-audio-sink node.name=${VIRTUAL_MIC} node.description="${VIRTUAL_MIC_LABEL}" media.class=Audio/Source/Virtual object.linger=1 audio.position=[FL,FR] audio.rate=48000 audio.channels=2 }`]);
// Give PipeWire a moment to publish the node and its ports
for (let i = 0; i < 20; i++) {
const objects = await dump();
const mic = objects.find(o => o.info?.props?.['node.name'] === VIRTUAL_MIC);
if (mic && objects.some(o => o.type === 'PipeWire:Interface:Port' && o.info?.props?.['node.id'] === mic.id)) break;
await new Promise(r => setTimeout(r, 100));
}
}
await this.relink();
if (!this.monitor) {
const proc = spawn('pw-mon', ['--color=never'], { stdio: ['ignore', 'pipe', 'ignore'] });
this.monitor = proc;
proc.stdout!.on('data', (data: Buffer) => {
const text = data.toString();
if (!text.includes('added') && !text.includes('removed')) return;
// Starting a program fires many events at once
clearTimeout(this.debounce);
this.debounce = setTimeout(() => this.relink().catch(() => {}), 400);
});
proc.on('error', () => { if (this.monitor === proc) this.monitor = null; });
proc.on('exit', () => { if (this.monitor === proc) this.monitor = null; });
}
}
// One at a time, so two graph changes cannot interleave their link commands
private relink(): Promise<void> {
const next = this.busy.then(async () => {
if (!this.target) return;
const { add, remove } = planLinks(await dump(), this.target, this.ownPids());
for (const id of remove) await run('pw-link', ['-d', String(id)]).catch(() => {});
// "File exists" when a link is already there is fine
for (const [out, input] of add) await run('pw-link', [String(out), String(input)]).catch(() => {});
});
this.busy = next.catch(() => {});
return next;
}
async stop(): Promise<void> {
this.target = null;
clearTimeout(this.debounce);
this.monitor?.kill();
this.monitor = null;
await this.busy;
try {
for (const o of await dump()) {
if (o.type === 'PipeWire:Interface:Node' && o.info?.props?.['node.name'] === VIRTUAL_MIC) await run('pw-cli', ['destroy', String(o.id)]).catch(() => {});
}
} catch { /* PipeWire gone */ }
}
}
+6
View File
@@ -21,6 +21,12 @@ contextBridge.exposeInMainWorld('mumh5Native', {
onContextMenu: (fn: (params: unknown) => void) => { ipcRenderer.on('context-menu', (_e, p) => fn(p)); }, onContextMenu: (fn: (params: unknown) => void) => { ipcRenderer.on('context-menu', (_e, p) => fn(p)); },
editAction: (action: string, arg?: unknown) => ipcRenderer.send('edit:action', action, arg), editAction: (action: string, arg?: unknown) => ipcRenderer.send('edit:action', action, arg),
onTrayAction: (fn: (action: string) => void) => { ipcRenderer.on('tray:action', (_e, a: string) => fn(a)); }, onTrayAction: (fn: (action: string) => void) => { ipcRenderer.on('tray:action', (_e, a: string) => fn(a)); },
screenSources: (thumbnails: boolean) => ipcRenderer.invoke('share:sources', !!thumbnails),
pickScreenSource: (id: string) => ipcRenderer.send('share:pick', id),
shareInfo: () => ipcRenderer.invoke('share:info'),
shareAudioApps: () => ipcRenderer.invoke('share:audioApps'),
shareAudioStart: (target: unknown) => ipcRenderer.invoke('share:audioStart', target),
shareAudioStop: () => ipcRenderer.invoke('share:audioStop'),
describeCerts: (ders: Uint8Array[]) => ipcRenderer.invoke('certs:describe', ders), describeCerts: (ders: Uint8Array[]) => ipcRenderer.invoke('certs:describe', ders),
identities: { identities: {
list: () => ipcRenderer.invoke('identities:list'), list: () => ipcRenderer.invoke('identities:list'),
+31 -6
View File
@@ -1,4 +1,5 @@
import tls from 'node:tls'; import tls from 'node:tls';
import net from 'node:net';
import { describeCert, type CertDetails } from './certs.ts'; import { describeCert, type CertDetails } from './certs.ts';
export interface TlsHandlers { export interface TlsHandlers {
@@ -9,13 +10,37 @@ export interface TlsHandlers {
// Opens a TLS connection to a Mumble server. Most servers use self-signed certificates, // Opens a TLS connection to a Mumble server. Most servers use self-signed certificates,
// so verification is left to the caller (trust on first use via the sha256 fingerprint). // so verification is left to the caller (trust on first use via the sha256 fingerprint).
export function openTls(host: string, port: number, cert: string, key: string, h: TlsHandlers) { export interface TlsOptions {
// Custom DNS resolution (the web proxy uses it to refuse private addresses)
lookup?: net.LookupFunction;
// Announce this client address with a PROXY protocol v1 line before TLS starts, for a
// receiver such as go-mmproxy that passes it on to a server without PROXY support
proxyClient?: string;
}
// PROXY protocol v1 line. Source and destination must be the same family; an IPv4 client is
// written as a mapped address towards an IPv6 destination, anything else is announced as unknown.
export function proxyLine(client: string, clientPort: number, dest: string, destPort: number): string {
const d = dest.replace(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i, '$1');
const c = client.replace(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i, '$1');
if (net.isIPv4(c) && net.isIPv4(d)) return `PROXY TCP4 ${c} ${d} ${clientPort} ${destPort}\r\n`;
if (net.isIPv6(d) && net.isIP(c)) return `PROXY TCP6 ${net.isIPv4(c) ? `::ffff:${c}` : c} ${d} ${clientPort} ${destPort}\r\n`;
return 'PROXY UNKNOWN\r\n';
}
export function openTls(host: string, port: number, cert: string, key: string, h: TlsHandlers, opts: TlsOptions = {}) {
let closeReason = 'Connection closed'; let closeReason = 'Connection closed';
const socket = tls.connect({ const servername = /^[\d.:]+$/.test(host) ? undefined : host;
host, port, cert, key, let socket: tls.TLSSocket;
rejectUnauthorized: false, if (opts.proxyClient) {
servername: /^[\d.:]+$/.test(host) ? undefined : host // The PROXY line goes first, in the clear; TLS then runs over the same connection
}); const raw = net.connect({ host, port, lookup: opts.lookup });
raw.once('connect', () => raw.write(proxyLine(opts.proxyClient!, raw.localPort ?? 0, raw.remoteAddress ?? '', raw.remotePort ?? port)));
raw.on('error', (e: Error) => { closeReason = e.message; socket.destroy(); });
socket = tls.connect({ socket: raw, cert, key, rejectUnauthorized: false, servername });
} else {
socket = tls.connect({ host, port, cert, key, lookup: opts.lookup, rejectUnauthorized: false, servername });
}
socket.setNoDelay(true); socket.setNoDelay(true);
socket.setKeepAlive(true, 30000); socket.setKeepAlive(true, 30000);
socket.setTimeout(20000, () => { socket.setTimeout(20000, () => {
+1 -1
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; media-src 'self' blob: https: http:; connect-src 'self' https: http: ws: wss:; object-src 'none'; base-uri 'none'; frame-src https://www.youtube-nocookie.com" /> <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; media-src 'self' blob: https: http:; connect-src 'self' https: http: ws: wss:; object-src 'none'; base-uri 'none'; frame-src https://www.youtube-nocookie.com" />
<title>mumh5</title> <title>mumh5</title>
</head> </head>
<body> <body>
+43 -1
View File
@@ -14,10 +14,12 @@
"protobufjs": "^8.8.0" "protobufjs": "^8.8.0"
}, },
"devDependencies": { "devDependencies": {
"@evan/opus": "^1.0.3",
"@sveltejs/vite-plugin-svelte": "^7.3.1", "@sveltejs/vite-plugin-svelte": "^7.3.1",
"@tsconfig/svelte": "^5.0.8", "@tsconfig/svelte": "^5.0.8",
"@types/node": "^26.6.3", "@types/node": "^26.6.3",
"@types/node-forge": "^1.3.14", "@types/node-forge": "^1.3.14",
"@types/ws": "^8.18.2",
"electron": "^44.5.1", "electron": "^44.5.1",
"electron-builder": "^26.15.3", "electron-builder": "^26.15.3",
"esbuild": "^0.28.2", "esbuild": "^0.28.2",
@@ -26,7 +28,8 @@
"svelte": "^5.57.1", "svelte": "^5.57.1",
"svelte-check": "^4.7.6", "svelte-check": "^4.7.6",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"vite": "^8.3.1" "vite": "^8.3.1",
"ws": "^8.22.0"
} }
}, },
"node_modules/@babel/helper-string-parser": { "node_modules/@babel/helper-string-parser": {
@@ -825,6 +828,13 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@evan/opus": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@evan/opus/-/opus-1.0.3.tgz",
"integrity": "sha512-ADfwIad83W1LuiZDNMjDMDNQRsPz8rj5xnDLExhVWTnA5wGJCLntOn12Ir5rxGBqdfo10QhnNVdd2+gXiZ6xCg==",
"dev": true,
"license": "MIT"
},
"node_modules/@isaacs/fs-minipass": { "node_modules/@isaacs/fs-minipass": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
@@ -1513,6 +1523,16 @@
"license": "MIT", "license": "MIT",
"optional": true "optional": true
}, },
"node_modules/@types/ws": {
"version": "8.18.2",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz",
"integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@xmldom/xmldom": { "node_modules/@xmldom/xmldom": {
"version": "0.8.15", "version": "0.8.15",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz",
@@ -5736,6 +5756,28 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/ws": {
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
"integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xmlbuilder": { "node_modules/xmlbuilder": {
"version": "15.1.1", "version": "15.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
+10 -2
View File
@@ -9,7 +9,9 @@
"scripts": { "scripts": {
"dev": "node scripts/dev.mjs", "dev": "node scripts/dev.mjs",
"build": "vite build && node scripts/build-electron.mjs", "build": "vite build && node scripts/build-electron.mjs",
"build:web": "vite build --mode web --outDir dist-web", "build:web": "vite build --mode web --outDir dist-web && node scripts/build-proxy.mjs",
"dev:web": "node scripts/dev-web.mjs",
"proxy": "node server/main.ts",
"check": "svelte-check --tsconfig ./tsconfig.json", "check": "svelte-check --tsconfig ./tsconfig.json",
"test": "node --test test/*.test.ts", "test": "node --test test/*.test.ts",
"dist": "npm run build && electron-builder", "dist": "npm run build && electron-builder",
@@ -17,6 +19,9 @@
"dist:win": "npm run build && electron-builder --win", "dist:win": "npm run build && electron-builder --win",
"dist:mac": "npm run build && electron-builder --mac", "dist:mac": "npm run build && electron-builder --mac",
"test:e2e": "node test/e2e/app.e2e.ts", "test:e2e": "node test/e2e/app.e2e.ts",
"test:e2e:web": "node test/e2e/web.e2e.ts",
"test:e2e:share": "node test/e2e/share.e2e.ts",
"themes": "node scripts/gen-themes.mjs",
"proto": "pbjs -t static-module -w es6 --keep-case --no-delimited --no-service --no-comments --force-number proto/Mumble.proto -o src/core/mumble-pb.js && pbjs -t static-module -w es6 --keep-case --no-delimited --no-service --no-comments --force-number proto/MumbleUDP.proto -o src/core/mumble-udp-pb.js", "proto": "pbjs -t static-module -w es6 --keep-case --no-delimited --no-service --no-comments --force-number proto/Mumble.proto -o src/core/mumble-pb.js && pbjs -t static-module -w es6 --keep-case --no-delimited --no-service --no-comments --force-number proto/MumbleUDP.proto -o src/core/mumble-udp-pb.js",
"screenshots": "node test/e2e/screenshots.ts" "screenshots": "node test/e2e/screenshots.ts"
}, },
@@ -86,10 +91,12 @@
} }
}, },
"devDependencies": { "devDependencies": {
"@evan/opus": "^1.0.3",
"@sveltejs/vite-plugin-svelte": "^7.3.1", "@sveltejs/vite-plugin-svelte": "^7.3.1",
"@tsconfig/svelte": "^5.0.8", "@tsconfig/svelte": "^5.0.8",
"@types/node": "^26.6.3", "@types/node": "^26.6.3",
"@types/node-forge": "^1.3.14", "@types/node-forge": "^1.3.14",
"@types/ws": "^8.18.2",
"electron": "^44.5.1", "electron": "^44.5.1",
"electron-builder": "^26.15.3", "electron-builder": "^26.15.3",
"esbuild": "^0.28.2", "esbuild": "^0.28.2",
@@ -98,7 +105,8 @@
"svelte": "^5.57.1", "svelte": "^5.57.1",
"svelte-check": "^4.7.6", "svelte-check": "^4.7.6",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"vite": "^8.3.1" "vite": "^8.3.1",
"ws": "^8.22.0"
}, },
"dependencies": { "dependencies": {
"dompurify": "^3.4.16", "dompurify": "^3.4.16",
+15
View File
@@ -0,0 +1,15 @@
import * as esbuild from 'esbuild';
// Bundles the web proxy to one file, dist-proxy/proxy.mjs, that runs with plain Node.
await esbuild.build({
entryPoints: { proxy: 'server/main.ts' },
outdir: 'dist-proxy',
outExtension: { '.js': '.mjs' },
bundle: true,
platform: 'node',
format: 'esm',
target: 'node22',
// Bundled CommonJS dependencies still call require()
banner: { js: "import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);" },
logLevel: 'info'
});
+19
View File
@@ -0,0 +1,19 @@
import { spawn } from 'node:child_process';
import { createServer } from 'vite';
// The browser build for development: the web proxy on 127.0.0.1:8080 and the Vite dev server,
// which forwards /api to it (vite.config.ts). Without MUMH5_SERVERS the proxy allows any
// server, private addresses included; it only listens on this machine.
const env = { MUMH5_STUN_BIND: '127.0.0.1', ...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);
+170
View File
@@ -0,0 +1,170 @@
// Writes the generated color schemes: src/themes-generated.css and src/lib/themes-generated.ts.
// Each scheme is a few numbers (surface hue and saturation, accent hue and saturation); the
// shades are derived from them and nudged until text, links and accent labels are readable.
// npm run themes
import { writeFileSync } from 'node:fs';
import path from 'node:path';
const root = path.resolve(import.meta.dirname, '..');
function hex(h, s, l) {
h = ((h % 360) + 360) % 360; s = Math.max(0, Math.min(100, s)) / 100; l = Math.max(0, Math.min(100, l)) / 100;
const k = n => (n + h / 30) % 12;
const a = s * Math.min(l, 1 - l);
const f = n => Math.round(255 * (l - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1))));
return '#' + [f(0), f(8), f(4)].map(v => v.toString(16).padStart(2, '0')).join('');
}
function luminance(c) {
const n = parseInt(c.slice(1), 16);
const [r, g, b] = [n >> 16, (n >> 8) & 255, n & 255].map(v => { const x = v / 255; return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4; });
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
const contrast = (a, b) => { const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x); return (hi + 0.05) / (lo + 0.05); };
// Moves the lightness away from the backgrounds until the color is readable on all of them
function readable(h, s, l, backgrounds, min, dir) {
let c = hex(h, s, l);
while (backgrounds.some(bg => contrast(c, bg) < min) && l > 0 && l < 100) { l += dir; c = hex(h, s, l); }
return c;
}
// [id, name, note, surface hue, surface saturation, accent hue, accent saturation, options]
const DARK = [
['slate', 'Slate', 'Blue-grey, calm', 215, 14, 215, 70],
['graphite', 'Graphite', 'Neutral grey, no color', 0, 0, 0, 0],
['charcoal', 'Charcoal', 'Warm grey, amber accents', 30, 5, 36, 85],
['obsidian', 'Obsidian', 'Near black, violet accents', 260, 10, 265, 85, { base: 3 }],
['navy', 'Navy', 'Dark blue', 222, 45, 215, 90],
['cobalt', 'Cobalt', 'Blue, yellow accents', 218, 60, 46, 95, { base: 8, link: 190 }],
['indigo', 'Indigo', 'Blue-violet', 240, 35, 245, 90],
['violet', 'Violet', 'Purple', 270, 35, 275, 85],
['orchid', 'Orchid', 'Soft purple-pink', 295, 28, 300, 70],
['magenta', 'Magenta', 'Dark pink', 320, 30, 325, 85],
['crimson', 'Crimson', 'Dark red', 350, 30, 352, 80],
['ruby', 'Ruby', 'Deep red, orange accents', 340, 40, 12, 85, { base: 5 }],
['rust', 'Rust', 'Red-brown', 15, 35, 18, 80],
['copper', 'Copper', 'Brown, muted orange', 25, 30, 28, 65],
['amber', 'Amber', 'Dark yellow-brown', 38, 30, 42, 95],
['olive', 'Olive', 'Yellow-green', 70, 22, 75, 60],
['moss', 'Moss', 'Muted green', 95, 22, 100, 50],
['emerald', 'Emerald', 'Rich green', 150, 35, 152, 70],
['jade', 'Jade', 'Blue-green', 165, 30, 168, 65],
['teal', 'Teal', 'Green-blue', 180, 35, 180, 70],
['lagoon', 'Lagoon', 'Bright turquoise accents', 192, 45, 190, 85],
['arctic', 'Arctic', 'Lighter cool grey-blue', 200, 25, 195, 75, { base: 9 }],
['storm', 'Storm', 'Lighter blue-grey', 225, 18, 200, 60, { base: 10 }],
['dusk', 'Dusk', 'Purple evening, orange accents', 255, 22, 20, 85, { base: 9 }],
['wine', 'Wine', 'Dark red-purple', 335, 35, 340, 60, { base: 5 }],
['cocoa', 'Cocoa', 'Brown', 22, 25, 30, 55, { base: 7 }],
['espresso', 'Espresso', 'Very dark brown', 28, 30, 35, 70, { base: 4 }],
['terminal', 'Terminal', 'Green on black', 120, 30, 120, 90, { base: 2, textHue: 120, textSat: 85, link: 150 }],
['phosphor', 'Phosphor', 'Amber on black', 40, 30, 40, 95, { base: 2, textHue: 40, textSat: 90, link: 25 }],
['neon', 'Neon', 'Black, pink and cyan', 280, 20, 320, 100, { base: 3, link: 185 }],
['synthwave', 'Synthwave', 'Purple, pink and cyan', 265, 45, 320, 90, { base: 8, link: 185 }],
['aurora', 'Aurora', 'Dark blue, green accents', 210, 30, 160, 75],
['abyss', 'Abyss', 'Blue-black, turquoise accents', 230, 50, 195, 90, { base: 3 }],
['carbon', 'Carbon', 'Black, red accents', 0, 0, 0, 85, { base: 3 }],
['ash', 'Ash', 'Lighter grey, blue accents', 0, 0, 210, 60, { base: 12 }],
['steel', 'Steel', 'Grey-blue, orange accents', 210, 10, 30, 80, { base: 11 }],
['ink', 'Ink', 'Blue-black, yellow accents', 235, 30, 50, 90, { base: 3 }],
['sapphire', 'Sapphire', 'Strong blue', 228, 55, 228, 95],
['azure', 'Azure', 'Bright blue accents', 208, 50, 205, 95],
['peacock', 'Peacock', 'Turquoise, purple accents', 188, 50, 300, 70],
['pine', 'Pine', 'Dark green, orange accents', 160, 40, 35, 85, { base: 4 }],
['fern', 'Fern', 'Soft green', 125, 25, 130, 55],
['lime', 'Lime', 'Yellow-green accents', 85, 30, 82, 85],
['citrus', 'Citrus', 'Dark yellow, orange accents', 60, 25, 28, 95],
['sunset', 'Sunset', 'Purple, orange accents', 280, 30, 25, 95],
['flamingo', 'Flamingo', 'Dark rose, bright pink', 330, 25, 345, 90],
['cherry', 'Cherry', 'Deep red', 355, 40, 355, 90, { base: 5 }],
['brick', 'Brick', 'Red-brown, gold accents', 8, 35, 40, 80],
['terracotta', 'Terracotta', 'Clay, teal accents', 18, 30, 175, 55, { base: 8 }],
['sandstone', 'Sandstone', 'Lighter warm grey', 35, 18, 20, 70, { base: 10 }],
['mocha', 'Mocha', 'Brown, lilac accents', 20, 18, 280, 50, { base: 9 }],
['walnut', 'Walnut', 'Dark brown, gold accents', 25, 35, 45, 80, { base: 5 }],
['bronze', 'Bronze', 'Yellow-brown', 40, 35, 38, 70],
['brass', 'Brass', 'Dark gold', 48, 30, 50, 85],
['mulberry', 'Mulberry', 'Dark purple-pink', 310, 35, 310, 65, { base: 5 }],
['grape', 'Grape', 'Strong purple', 280, 40, 280, 90],
['iris', 'Iris', 'Lighter blue-violet', 250, 30, 255, 90, { base: 9 }],
['twilight', 'Twilight', 'Night blue, pink accents', 245, 35, 330, 75, { base: 5 }],
['nebula', 'Nebula', 'Purple, turquoise accents', 270, 45, 190, 85, { base: 4 }],
['cathode', 'Cathode', 'Cyan on black', 185, 30, 185, 90, { base: 2, textHue: 185, textSat: 80, link: 160 }],
['bubblegum', 'Bubblegum', 'Purple-pink, cyan links', 300, 25, 330, 95, { base: 8, link: 185 }],
['volcano', 'Volcano', 'Black, lava orange', 10, 30, 20, 100, { base: 3 }],
['glacier', 'Glacier', 'The lightest dark, cool', 205, 30, 190, 70, { base: 12 }],
['seaweed', 'Seaweed', 'Dark teal, green accents', 170, 35, 90, 60, { base: 4 }]
];
const LIGHT = [
['snow', 'Snow', 'Cool white', 220, 10, 225, 75],
['linen', 'Linen', 'Warm white', 40, 30, 25, 60],
['sky', 'Sky', 'Light blue', 205, 60, 210, 85],
['mint', 'Mint', 'Light green', 150, 40, 160, 70],
['lavender', 'Lavender', 'Light purple', 265, 45, 265, 60],
['blush', 'Blush', 'Light pink', 345, 50, 345, 65],
['peach', 'Peach', 'Light orange', 25, 65, 18, 75],
['lemon', 'Lemon', 'Light yellow', 55, 60, 45, 90],
['sage', 'Sage', 'Grey-green', 100, 20, 110, 35],
['latte', 'Latte', 'Milky brown', 30, 35, 25, 45],
['ice', 'Ice', 'Pale turquoise', 190, 45, 195, 80],
['cloud', 'Cloud', 'Soft blue-grey', 210, 25, 210, 60],
['pearl', 'Pearl', 'Near white, rose accents', 300, 8, 320, 45],
['cream', 'Cream', 'Soft yellow', 48, 55, 30, 70],
['sand', 'Sand', 'Beige', 38, 40, 20, 55],
['rose', 'Rose', 'Pink-red', 355, 55, 350, 70],
['coral', 'Coral', 'Light red-orange', 12, 65, 8, 75],
['apricot', 'Apricot', 'Warm orange', 32, 70, 28, 85],
['pistachio', 'Pistachio', 'Yellow-green', 95, 35, 110, 50],
['seafoam', 'Seafoam', 'Green-turquoise', 165, 40, 172, 75],
['aqua', 'Aqua', 'Turquoise', 182, 50, 186, 85],
['periwinkle', 'Periwinkle', 'Blue-violet', 232, 55, 235, 70],
['lilac', 'Lilac', 'Soft purple', 285, 40, 285, 55],
['petal', 'Petal', 'Purple-pink', 320, 45, 320, 60],
['fog', 'Fog', 'Grey with a little blue', 200, 8, 200, 25]
];
function dark([id, name, note, h, s, a, as, o = {}]) {
const b = o.base ?? 6;
const bg = [b, b + 3, b + 5.5, b + 10].map(l => hex(h, s, l));
const th = o.textHue ?? h, ts = o.textSat ?? Math.min(s, 20);
const accent = hex(a, as, as ? 62 : 80);
const v = {
'--bg-0': bg[0], '--bg-1': bg[1], '--bg-2': bg[2], '--bg-3': bg[3],
'--bg-hover': hex(h, s, b + 13), '--bg-active': hex(h, s, b + 18), '--line': hex(h, s, b + 11),
'--text': readable(th, ts, o.textSat ? 70 : 91, bg, 7.5, 1),
'--text-dim': readable(th, o.textSat ? ts * 0.7 : Math.min(s, 15), o.textSat ? 50 : 66, bg, 4.6, 1),
'--text-faint': hex(th, o.textSat ? ts * 0.5 : Math.min(s, 12), 45),
'--accent': accent, '--accent-hover': hex(a, as, as ? 69 : 88),
'--accent-text': contrast('#ffffff', accent) >= contrast('#0b0b0b', accent) ? '#ffffff' : '#0b0b0b',
'--speaking': a > 90 && a < 170 ? '#f2c14e' : '#3bd16f',
'--danger': readable(4, 85, 62, bg, 4.6, 1), '--warn': '#e8b23c',
'--link': readable(o.link ?? (a > 190 && a < 250 ? 185 : 210), 85, 70, bg, 4.6, 1)
};
return { id, name, note, kind: 'dark', v };
}
function light([id, name, note, h, s, a, as]) {
const bg = [hex(h, s, 89), hex(h, s, 94.5), hex(h, s, 98)];
const accent = readable(a, as, 42, ['#ffffff'], 4.6, -1);
const v = {
'--bg-0': bg[0], '--bg-1': bg[1], '--bg-2': bg[2], '--bg-3': hex(h, s, 91),
'--bg-hover': hex(h, s, 87), '--bg-active': hex(h, s, 82), '--line': hex(h, Math.min(s, 30), 82),
'--text': readable(h, 20, 13, bg, 7.5, -1),
'--text-dim': readable(h, 12, 34, bg, 4.6, -1),
'--text-faint': hex(h, 10, 52),
'--accent': accent, '--accent-hover': readable(a, as, 34, ['#ffffff'], 6, -1), '--accent-text': '#ffffff',
'--speaking': readable(140, 65, 34, bg, 3.2, -1), '--danger': readable(4, 70, 44, bg, 4.6, -1), '--warn': readable(38, 85, 36, bg, 3.2, -1),
'--link': readable(215, 75, 40, bg, 4.6, -1)
};
return { id, name, note, kind: 'light', v };
}
const themes = [...DARK.map(dark), ...LIGHT.map(light)];
const css = '/* Generated by scripts/gen-themes.mjs (npm run themes). Do not edit by hand. */\n\n' + themes.map(t =>
`:root[data-theme="${t.id}"], [data-theme-preview="${t.id}"] {\n --rail: var(--bg-0);\n${Object.entries(t.v).map(([k, c]) => ` ${k}: ${c};`).join('\n')}\n color-scheme: ${t.kind};\n}\n`).join('\n');
writeFileSync(path.join(root, 'src/themes-generated.css'), css);
const ts = '// Generated by scripts/gen-themes.mjs (npm run themes). Do not edit by hand.\n' +
'export const GENERATED_THEMES = [\n' + themes.map(t => ` { id: '${t.id}', name: '${t.name}', note: '${t.note}', light: ${t.kind === 'light'} }`).join(',\n') + '\n] as const;\n';
writeFileSync(path.join(root, 'src/lib/themes-generated.ts'), ts);
console.log(`${themes.length} schemes written`);
+22
View File
@@ -0,0 +1,22 @@
// 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, stunPort } = 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(!stunPort ? 'STUN is off; screen sharing between browser users will only work on the same network'
: config.turn ? `Screen sharing: STUN and relay on port ${stunPort} (UDP and TCP), relayed streams on UDP ${config.turnMinPort}-${config.turnMaxPort}; all must be reachable from the internet`
: `Screen sharing: STUN on UDP port ${stunPort} (must be reachable from the internet), no relay`);
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);
}
+377
View File
@@ -0,0 +1,377 @@
// 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 { randomBytes } from 'node:crypto';
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';
import { startTurn, turnCredential, stunResponse, isPrivateAddress, CREDENTIAL_TTL } from './turn.ts';
export { stunResponse, isPrivateAddress };
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;
// UDP port of the built-in STUN responder for screen sharing between browser users; null turns it off
stunPort: number | null;
stunBind: string;
// Relay streams for browsers that cannot connect directly (TURN, same port over UDP and TCP)
turn: boolean;
// Public address announced for relayed traffic, when it cannot be found (behind a 1:1 NAT)
turnIp: string | null;
turnMinPort: number;
turnMaxPort: number;
// Relay routes in total and per client address. A browser opens one per stream and network
// interface, and a camera round has a stream per pair of people, so these run high.
turnMax: number;
turnPerAddress: number;
// Addresses to hand to clients instead of this host's port, e.g. a TLS front on port 443
turnUrls: string[];
// Log relay events
debug: boolean;
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, stunPort: null, stunBind: '::', turn: true, turnIp: null, turnMinPort: 49160, turnMaxPort: 49659, turnMax: 500, turnPerAddress: 64, turnUrls: [], debug: false, 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(source: NodeJS.ProcessEnv): ProxyConfig {
// A variable set to nothing counts as not set (docker-compose passes unset ones on that way)
const env = Object.fromEntries(Object.entries(source).filter(([, v]) => v !== '')) as NodeJS.ProcessEnv;
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,
stunPort: Number(env.MUMH5_STUN_PORT ?? 3478) || null,
stunBind: env.MUMH5_STUN_BIND ?? defaults.stunBind,
turn: env.MUMH5_TURN !== '0' && env.MUMH5_TURN !== 'false',
turnIp: env.MUMH5_TURN_IP ?? null,
turnMinPort: Number((env.MUMH5_TURN_PORTS ?? '').split('-')[0]) || defaults.turnMinPort,
turnMaxPort: Number((env.MUMH5_TURN_PORTS ?? '').split('-')[1]) || defaults.turnMaxPort,
turnMax: Number(env.MUMH5_TURN_MAX ?? defaults.turnMax),
turnPerAddress: Number(env.MUMH5_TURN_PER_ADDRESS ?? defaults.turnPerAddress),
turnUrls: (env.MUMH5_TURN_URLS ?? '').split(',').map(s => s.trim()).filter(Boolean),
debug: on(env.MUMH5_DEBUG),
maxConnections: Number(env.MUMH5_MAX_CONNECTIONS ?? defaults.maxConnections),
maxPerAddress: Number(env.MUMH5_MAX_PER_ADDRESS ?? defaults.maxPerAddress)
};
}
// DNS lookup that fails for private addresses, so a public name cannot point the proxy inward
const publicLookup: net.LookupFunction = (hostname, options, callback) => {
// With `all` the result is a list of addresses, otherwise one address string
(dns.lookup as Function)(hostname, options, (err: Error | null, result: string | dns.LookupAddress[], family?: number) => {
if (err) return (callback as Function)(err);
const list = Array.isArray(result) ? result : [{ address: result, family }];
if (list.some(r => isPrivateAddress(r.address))) return (callback as Function)(new Error('This proxy does not connect to private addresses'));
(callback as Function)(null, result, family);
});
};
const TYPES: Record<string, string> = {
'.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8',
'.json': 'application/json', '.png': 'image/png', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.jpg': 'image/jpeg',
'.webp': 'image/webp', '.woff2': 'font/woff2', '.woff': 'font/woff', '.ogg': 'audio/ogg', '.mp3': 'audio/mpeg',
'.wav': 'audio/wav', '.map': 'application/json', '.txt': 'text/plain; charset=utf-8', '.wasm': 'application/wasm'
};
const MAX_BODY = 256 * 1024;
// Mumble frames are at most 8 MiB; a WebSocket message carries a chunk of that stream
const MAX_WS_MESSAGE = 8 * 1024 * 1024 + 64;
const MAX_BUFFERED = 32 * 1024 * 1024;
class HttpError extends Error {
status: number;
constructor(status: number, message: string) { super(message); this.status = status; }
}
export async function startProxy(config: ProxyConfig): Promise<{ port: number; stunPort: number | null; close(): Promise<void> }> {
if (!config.allowAny && !config.servers.length) {
throw new Error('No servers allowed. Set MUMH5_SERVERS=host[:port][=Label],... or MUMH5_ALLOW_ANY=1.');
}
const staticDir = config.staticDir ? path.resolve(config.staticDir) : null;
const perAddress = new Map<string, number>();
let stunPort: number | null = null;
// Made up at every start: credentials are handed out by this process and checked by it
const turnSecret = randomBytes(24).toString('hex');
// Identity requests per address in the current minute; key generation is the costly part
const identityUse = new Map<string, number>();
const sweep = setInterval(() => identityUse.clear(), 60000);
sweep.unref();
const addressOf = (req: http.IncomingMessage): string => {
// The last entry is the one our own reverse proxy added; earlier ones come from the client and can be made up
const fwd = config.trustProxy ? String(req.headers['x-forwarded-for'] ?? '').split(',').at(-1)!.trim() : '';
return fwd || req.socket.remoteAddress || 'unknown';
};
const originOk = (req: http.IncomingMessage): boolean => {
if (config.origins.includes('*')) return true;
const origin = req.headers.origin;
if (!origin) return false;
if (config.origins.length) return config.origins.includes(origin);
try { return new URL(origin).host === req.headers.host; } catch { return false; }
};
const listed = (host: string, port: number): boolean => config.servers.some(s => s.host === host.toLowerCase() && s.port === port);
const allowed = (host: string, port: number): boolean => config.allowAny || listed(host, port);
async function readJson(req: http.IncomingMessage): Promise<any> {
const chunks: Buffer[] = [];
let size = 0;
for await (const chunk of req) {
size += (chunk as Buffer).length;
if (size > MAX_BODY) throw new HttpError(413, 'Request too large');
chunks.push(chunk as Buffer);
}
try { return JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { throw new HttpError(400, 'Invalid JSON'); }
}
const withCert = (id: { certPem: string; keyPem: string; fingerprint: string }) =>
({ ...id, cert: describeCert(id.certPem), commonName: certCommonName(id.certPem) });
async function api(req: http.IncomingMessage, route: string): Promise<unknown> {
if (route === 'config' && req.method === 'GET') {
return { servers: config.servers, any: config.allowAny, stun: stunPort, turn: stunPort != null && config.turn };
}
// What a client needs for screen sharing: the STUN port and, with the relay on, credentials
// for it. Open to any origin, because the desktop app asks from outside this site; checking
// the origin would not keep a script out anyway. The quotas are what limit use.
if (route === 'relay' && req.method === 'GET') {
if (stunPort == null) throw new HttpError(404, 'No STUN or relay here');
if (!config.turn) return { port: stunPort };
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');
// The username is the time it runs out; the relay recomputes the password from it
const username = String(Math.floor(Date.now() / 1000) + CREDENTIAL_TTL);
if (config.debug) console.log(new Date().toISOString(), `relay: credentials for ${addr}`);
return { port: stunPort, username, credential: turnCredential(turnSecret, username), ttl: CREDENTIAL_TTL, ...(config.turnUrls.length ? { urls: config.turnUrls } : {}) };
}
if (req.method !== 'POST') throw new HttpError(404, 'Not found');
if (!originOk(req)) throw new HttpError(403, 'Origin not allowed');
const body = await readJson(req);
if (route.startsWith('identity/')) {
const addr = addressOf(req);
const used = (identityUse.get(addr) ?? 0) + 1;
identityUse.set(addr, used);
if (used > 20) throw new HttpError(429, 'Too many requests, try again in a minute');
}
switch (route) {
// Nothing is stored here: the browser keeps its identities and sends one along when it connects
case 'identity/create':
return withCert(generateIdentity(String(body.name ?? '').slice(0, 128), String(body.email ?? '').slice(0, 128)));
case 'identity/import':
return withCert(identityFromP12(new Uint8Array(Buffer.from(String(body.p12 ?? ''), 'base64')), String(body.password ?? '')));
case 'identity/export': {
const id = { certPem: String(body.certPem), keyPem: String(body.keyPem), fingerprint: '' };
return { p12: Buffer.from(identityToP12(id, String(body.password ?? ''), String(body.name ?? 'Identity').slice(0, 128))).toString('base64') };
}
case 'certs/describe':
return (Array.isArray(body.ders) ? body.ders.slice(0, 8) : []).flatMap((d: unknown) => {
try { return [describeCert(Buffer.from(String(d), 'base64'))]; } catch { return []; }
});
}
throw new HttpError(404, 'Not found');
}
async function serveStatic(req: http.IncomingMessage, res: http.ServerResponse, pathname: string): Promise<void> {
if (!staticDir || (req.method !== 'GET' && req.method !== 'HEAD')) throw new HttpError(404, 'Not found');
let rel: string;
try { rel = decodeURIComponent(pathname); } catch { throw new HttpError(400, 'Bad path'); }
let file = path.join(staticDir, path.normalize('/' + rel));
if (file !== staticDir && !file.startsWith(staticDir + path.sep)) throw new HttpError(404, 'Not found');
let data: Buffer;
try {
if ((await fs.stat(file)).isDirectory()) file = path.join(file, 'index.html');
data = await fs.readFile(file);
} catch {
throw new HttpError(404, 'Not found');
}
const html = file.endsWith('.html');
res.writeHead(200, {
'Content-Type': TYPES[path.extname(file).toLowerCase()] ?? 'application/octet-stream',
'Content-Length': data.length,
// Built assets carry a content hash in their name; the page itself must stay fresh
'Cache-Control': html ? 'no-cache' : 'public, max-age=31536000, immutable',
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY'
});
res.end(req.method === 'HEAD' ? undefined : data);
}
const server = http.createServer((req, res) => {
const pathname = new URL(req.url ?? '/', 'http://x').pathname;
const work = pathname.startsWith('/api/')
? api(req, pathname.slice(5)).then(result => {
const data = Buffer.from(JSON.stringify(result));
res.writeHead(200, {
'Content-Type': 'application/json', 'Content-Length': data.length, 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff',
// The desktop app reads this one from another origin
...(pathname === '/api/relay' ? { 'Access-Control-Allow-Origin': '*' } : {})
});
res.end(data);
})
: serveStatic(req, res, pathname);
work.catch((e: Error) => {
const status = e instanceof HttpError ? e.status : 400;
if (res.headersSent) return res.destroy();
res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
res.end(JSON.stringify({ error: e.message }));
});
});
const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_WS_MESSAGE, perMessageDeflate: false });
server.on('upgrade', (req, socket, head) => {
const refuse = (status: string) => { socket.write(`HTTP/1.1 ${status}\r\nConnection: close\r\n\r\n`); socket.destroy(); };
if (new URL(req.url ?? '/', 'http://x').pathname !== '/api/connect') return refuse('404 Not Found');
if (!originOk(req)) return refuse('403 Forbidden');
const addr = addressOf(req);
if (wss.clients.size >= config.maxConnections || (perAddress.get(addr) ?? 0) >= config.maxPerAddress) return refuse('503 Service Unavailable');
wss.handleUpgrade(req, socket, head, ws => bridge(ws, addr));
});
// One WebSocket is one Mumble connection. The first message names the server and carries the
// client certificate; after that binary messages are the raw Mumble TCP stream in both directions.
function bridge(ws: WebSocket, addr: string): void {
perAddress.set(addr, (perAddress.get(addr) ?? 0) + 1);
const clientAddress = addr.replace(/^::ffff:/, '');
let conn: ReturnType<typeof openTls> | null = null;
let done = false;
const finish = (reason: string) => {
if (done) return;
done = true;
clearTimeout(hello);
conn?.close();
if (ws.readyState === ws.OPEN) {
ws.send(JSON.stringify({ type: 'close', reason }));
ws.close(1000);
}
};
const hello = setTimeout(() => finish('No connection request received'), 10000);
ws.on('message', (data, isBinary) => {
if (conn) {
if (isBinary) conn.send(data as Buffer);
return;
}
if (isBinary) return finish('Expected a connection request');
let req: any;
try { req = JSON.parse(data.toString()); } catch { return finish('Invalid connection request'); }
const host = String(req.host ?? '').trim();
const port = Number(req.port) || 64738;
if (!host || host.length > 255 || port < 1 || port > 65535 || !allowed(host, port)) {
return finish(`This proxy does not allow connecting to ${host}:${port}`);
}
// Servers the operator listed are trusted as they are, also on localhost or a private network
const guard = config.allowAny && !config.allowPrivate && !listed(host, port);
if (guard && net.isIP(host) && isPrivateAddress(host)) return finish('This proxy does not connect to private addresses');
clearTimeout(hello);
conn = openTls(host, port, String(req.certPem ?? ''), String(req.keyPem ?? ''), {
onSecure: info => {
if (ws.readyState !== ws.OPEN) return;
// The server's address stays here; the browser has no use for it without UDP
ws.send(JSON.stringify({ type: 'secure', info: {
fingerprint: info.fingerprint, authorized: info.authorized, authError: info.authError, chain: info.chain,
// Mumble only ever sees this proxy's address; tell the browser its own so the app can show it
clientAddress
} }));
},
onData: chunk => {
if (ws.readyState !== ws.OPEN) return;
// A browser that stopped reading must not grow our memory without bound
if (ws.bufferedAmount > MAX_BUFFERED) return finish('Connection too slow');
ws.send(chunk, { binary: true });
},
onClose: reason => finish(reason)
}, { lookup: guard ? publicLookup : undefined, proxyClient: config.sendProxy ? clientAddress : undefined });
});
ws.on('close', () => {
done = true;
clearTimeout(hello);
conn?.close();
const left = (perAddress.get(addr) ?? 1) - 1;
if (left > 0) perAddress.set(addr, left); else perAddress.delete(addr);
});
ws.on('error', () => ws.terminate());
}
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(config.port, config.bind, resolve);
});
// STUN and the relay. Failing to open the port only costs screen sharing its helper.
let turn: { port: number; close(): void } | null = null;
if (config.stunPort != null) {
try {
turn = await startTurn({
port: config.stunPort, bind: config.stunBind, relay: config.turn, publicIp: config.turnIp,
minPort: config.turnMinPort, maxPort: config.turnMaxPort, maxAllocations: config.turnMax, maxPerAddress: config.turnPerAddress,
peerAllowed: ip => config.allowPrivate || !isPrivateAddress(ip),
log: config.debug ? line => console.log(new Date().toISOString(), line) : undefined
}, turnSecret);
stunPort = turn.port;
} catch { /* port taken or not permitted */ }
}
return {
port: (server.address() as net.AddressInfo).port,
stunPort,
close: () => new Promise<void>(resolve => {
clearInterval(sweep);
turn?.close();
for (const ws of wss.clients) ws.terminate();
wss.close();
server.close(() => resolve());
server.closeAllConnections();
})
};
}
+406
View File
@@ -0,0 +1,406 @@
// STUN and TURN for screen sharing between browser users (RFC 5389, RFC 5766).
// STUN tells a browser its public address so two of them can connect directly. Where that is
// not possible (mobile carriers, company networks, browsers that forbid direct UDP such as
// Vanadium) the stream goes through this relay instead: the browser reaches it over UDP or
// TCP, and the relay passes the packets on over UDP. Credentials are short-lived and come
// from the proxy's API, so only people using this site can relay through it.
import dgram from 'node:dgram';
import net from 'node:net';
import os from 'node:os';
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
const COOKIE = 0x2112a442;
const REALM = 'mumh5';
// Message types: request, with success (| 0x100) and error (| 0x110) answers
const BINDING = 0x0001, ALLOCATE = 0x0003, REFRESH = 0x0004, SEND = 0x0016, DATA = 0x0017, CREATE_PERMISSION = 0x0008, CHANNEL_BIND = 0x0009;
const A = {
USERNAME: 0x0006, MESSAGE_INTEGRITY: 0x0008, ERROR_CODE: 0x0009, CHANNEL_NUMBER: 0x000c, LIFETIME: 0x000d, XOR_PEER_ADDRESS: 0x0012,
DATA: 0x0013, REALM: 0x0014, NONCE: 0x0015, XOR_RELAYED_ADDRESS: 0x0016, REQUESTED_ADDRESS_FAMILY: 0x0017, REQUESTED_TRANSPORT: 0x0019,
XOR_MAPPED_ADDRESS: 0x0020
};
const LIFETIME = 600; // an allocation, unless refreshed
const PERMISSION = 300; // a peer address may send for this long after CreatePermission
const CHANNEL = 600;
const NONCE_AGE = 3600;
export const CREDENTIAL_TTL = 12 * 3600;
export interface TurnOptions {
port: number;
bind: string;
// false: answer STUN binding requests only
relay: boolean;
// Address announced for relayed traffic; found from the connection or the network interfaces when unset
publicIp: string | null;
// UDP ports used for relaying, so a firewall can open exactly these
minPort: number;
maxPort: number;
maxAllocations: number;
maxPerAddress: number;
// Peers the relay may talk to; keeps it from being used to reach private networks
peerAllowed: (ip: string) => boolean;
// One line per relay event, for finding out why someone cannot connect
log?: (line: string) => void;
}
// 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);
}
const v4 = (address: string) => address.replace(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i, '$1');
function ipBytes(address: string): number[] | null {
const a = v4(address);
if (net.isIPv4(a)) return a.split('.').map(Number);
if (!net.isIPv6(a)) return null;
const [head, tail = ''] = a.split('%')[0].split('::');
const h = head ? head.split(':') : [], t = tail ? tail.split(':') : [];
const groups = a.includes('::') ? [...h, ...new Array(8 - h.length - t.length).fill('0'), ...t] : h;
return groups.flatMap(g => { const n = parseInt(g, 16); return [n >> 8, n & 255]; });
}
// Address attribute value, masked with the cookie and (for IPv6) the transaction id
export function xorAddress(address: string, port: number, header: Uint8Array): Uint8Array | null {
const bytes = ipBytes(address);
if (!bytes) return null;
const out = new Uint8Array(4 + bytes.length);
out[1] = bytes.length === 4 ? 1 : 2;
out[2] = (port >> 8) ^ 0x21;
out[3] = (port & 255) ^ 0x12;
for (let i = 0; i < bytes.length; i++) out[4 + i] = bytes[i] ^ header[4 + i];
return out;
}
export function unxorAddress(value: Uint8Array, header: Uint8Array): { ip: string; port: number } | null {
if (value.length !== 8 && value.length !== 20) return null;
const port = ((value[2] ^ 0x21) << 8) | (value[3] ^ 0x12);
const raw = [...value.subarray(4)].map((b, i) => b ^ header[4 + i]);
if (raw.length === 4) return { ip: raw.join('.'), port };
const groups: string[] = [];
for (let i = 0; i < 16; i += 2) groups.push(((raw[i] << 8) | raw[i + 1]).toString(16));
return { ip: groups.join(':'), port };
}
interface Stun { type: number; header: Uint8Array; attrs: { type: number; value: Uint8Array; offset: number }[]; raw: Uint8Array }
export function parse(msg: Uint8Array): Stun | null {
if (msg.length < 20) return null;
const view = new DataView(msg.buffer, msg.byteOffset, msg.byteLength);
if (view.getUint32(4) !== COOKIE || view.getUint16(2) !== msg.length - 20) return null;
const attrs: Stun['attrs'] = [];
for (let at = 20; at + 4 <= msg.length;) {
const type = view.getUint16(at), length = view.getUint16(at + 2);
if (at + 4 + length > msg.length) return null;
attrs.push({ type, value: msg.subarray(at + 4, at + 4 + length), offset: at });
at += 4 + ((length + 3) & ~3);
}
return { type: view.getUint16(0), header: msg.subarray(0, 20), attrs, raw: msg };
}
// Builds a message; with a key it is signed (MESSAGE-INTEGRITY), which browsers check on answers
export function build(type: number, header: Uint8Array, attrs: [number, Uint8Array][], key?: Buffer): Uint8Array {
const body = attrs.reduce((n, [, v]) => n + 4 + ((v.length + 3) & ~3), 0);
const out = new Uint8Array(20 + body + (key ? 24 : 0));
const view = new DataView(out.buffer);
view.setUint16(0, type);
out.set(header.subarray(4, 20), 4);
let at = 20;
for (const [t, v] of attrs) {
view.setUint16(at, t);
view.setUint16(at + 2, v.length);
out.set(v, at + 4);
at += 4 + ((v.length + 3) & ~3);
}
// The length field counts the integrity attribute while it is being computed
view.setUint16(2, out.length - 20);
if (key) {
view.setUint16(at, A.MESSAGE_INTEGRITY);
view.setUint16(at + 2, 20);
out.set(createHmac('sha1', key).update(out.subarray(0, at)).digest(), at + 4);
}
return out;
}
const text = (s: string) => new TextEncoder().encode(s);
const u32 = (n: number) => { const b = new Uint8Array(4); new DataView(b.buffer).setUint32(0, n); return b; };
const errorCode = (code: number, reason: string) => Uint8Array.from([0, 0, Math.floor(code / 100), code % 100, ...text(reason)]);
// Binding answer on its own, for the STUN-only case and the tests
export function stunResponse(msg: Uint8Array, address: string, port: number): Uint8Array | null {
const m = parse(msg);
if (!m || m.type !== BINDING) return null;
const mapped = xorAddress(address, port, m.header);
return mapped && build(BINDING | 0x100, m.header, [[A.XOR_MAPPED_ADDRESS, mapped]]);
}
// The password that goes with a username, as handed out by the API and checked here
export function turnCredential(secret: string, username: string): string {
return createHmac('sha1', secret).update(username).digest('base64');
}
interface Client { key: string; ip: string; port: number; send(bytes: Uint8Array): void; local: string | null }
interface Allocation {
client: Client;
authKey: Buffer;
relay: dgram.Socket;
relayIp: string;
permissions: Map<string, number>;
channels: Map<number, { ip: string; port: number; expires: number }>;
byPeer: Map<string, number>;
expires: number;
}
export async function startTurn(opts: TurnOptions, secret: string): Promise<{ port: number; close(): void }> {
const allocations = new Map<string, Allocation>();
const now = () => Math.floor(Date.now() / 1000);
const nonce = () => { const t = String(now()); return `${t}-${createHmac('sha1', secret).update(`nonce:${t}`).digest('hex').slice(0, 24)}`; };
const nonceValid = (n: string) => {
const [t, mac] = n.split('-');
return !!mac && now() - Number(t) < NONCE_AGE && mac === createHmac('sha1', secret).update(`nonce:${t}`).digest('hex').slice(0, 24);
};
function free(a: Allocation): void {
if (allocations.get(a.client.key) === a) allocations.delete(a.client.key);
try { a.relay.close(); } catch { /* already closed */ }
}
const sweep = setInterval(() => { for (const a of allocations.values()) if (a.expires < now()) free(a); }, 15000);
sweep.unref();
// The address peers must send to. Behind a 1:1 NAT (many cloud hosts) it has to be configured.
function relayAddress(family: 4 | 6, local: string | null): string | null {
if (opts.publicIp && (net.isIPv4(opts.publicIp) ? 4 : 6) === family) return opts.publicIp;
if (local && (net.isIPv4(local) ? 4 : 6) === family && !/^(0\.0\.0\.0|::)$/.test(local)) return local;
const want = family === 4 ? 'IPv4' : 'IPv6';
const found = Object.values(os.networkInterfaces()).flat().filter(i => i && i.family === want && !i.address.startsWith('fe80'));
// A public address first; Docker bridges and the like come before loopback only
return (found.find(i => !isPrivateAddress(i!.address)) ?? found.find(i => !i!.internal) ?? found[0])?.address ?? null;
}
async function bindRelay(family: 4 | 6): Promise<dgram.Socket | null> {
const span = opts.maxPort - opts.minPort + 1;
const start = Math.floor(Math.random() * span);
for (let i = 0; i < Math.min(span, 64); i++) {
const socket = dgram.createSocket({ type: family === 4 ? 'udp4' : 'udp6', ipv6Only: family === 6 });
const ok = await new Promise<boolean>(resolve => {
socket.once('error', () => resolve(false));
socket.bind(opts.minPort + ((start + i) % span), () => resolve(true));
});
if (ok) { socket.removeAllListeners('error'); socket.on('error', () => {}); return socket; }
try { socket.close(); } catch { /* never opened */ }
}
return null;
}
// Checks the credentials of a request; answers with the right error and returns null otherwise
function authenticate(m: Stun, client: Client): Buffer | null {
const get = (t: number) => m.attrs.find(a => a.type === t);
const fail = (code: number, reason: string) => {
client.send(build(m.type | 0x110, m.header, [[A.ERROR_CODE, errorCode(code, reason)], [A.REALM, text(REALM)], [A.NONCE, text(nonce())]]));
return null;
};
const integrity = get(A.MESSAGE_INTEGRITY);
// The first request always comes without credentials; that is how a client learns the realm
if (!integrity) return fail(401, 'Unauthorized');
opts.log?.(`relay: ${client.key} sent credentials`);
const username = get(A.USERNAME), n = get(A.NONCE);
if (!username || !n || integrity.value.length !== 20) return fail(400, 'Bad Request');
if (!nonceValid(new TextDecoder().decode(n.value))) return fail(438, 'Stale Nonce');
const user = new TextDecoder().decode(username.value);
// The username is the time the credential runs out
if (!(Number(user.split(':')[0]) > now())) return fail(401, 'Unauthorized');
const key = createHash('md5').update(`${user}:${REALM}:${turnCredential(secret, user)}`).digest();
const signed = Uint8Array.from(m.raw.subarray(0, integrity.offset));
new DataView(signed.buffer).setUint16(2, integrity.offset + 24 - 20);
const expected = createHmac('sha1', key).update(signed).digest();
if (!timingSafeEqual(expected, integrity.value)) return fail(401, 'Unauthorized');
return key;
}
// Two clients of this relay reach each other at its announced address. Behind a 1:1 NAT that
// address is not on this machine and the router will not turn the packet around, so such
// traffic stays on loopback and is presented as coming from the announced address.
const loopback = (ip: string) => ip === '127.0.0.1' || ip === '::1';
function toPeer(a: Allocation, data: Uint8Array, ip: string, port: number): void {
a.relay.send(data, port, ip === a.relayIp ? (net.isIPv4(ip) ? '127.0.0.1' : '::1') : ip);
}
function fromPeer(a: Allocation, data: Buffer, peerIp: string, peerPort: number): void {
const seen = v4(peerIp);
const ip = loopback(seen) && !loopback(a.relayIp) ? a.relayIp : seen;
if ((a.permissions.get(ip) ?? 0) < now()) return;
const channel = a.byPeer.get(`${ip}:${peerPort}`);
if (channel != null && (a.channels.get(channel)?.expires ?? 0) >= now()) {
const out = new Uint8Array(4 + data.length);
new DataView(out.buffer).setUint16(0, channel);
new DataView(out.buffer).setUint16(2, data.length);
out.set(data, 4);
return a.client.send(out);
}
const header = new Uint8Array(20);
new DataView(header.buffer).setUint32(4, COOKIE);
header.set(randomBytes(12), 8);
const peer = xorAddress(ip, peerPort, header);
if (peer) a.client.send(build(DATA, header, [[A.XOR_PEER_ADDRESS, peer], [A.DATA, data]]));
}
async function handle(msg: Uint8Array, client: Client): Promise<void> {
// ChannelData: channel number, length, payload
if (msg.length >= 4 && (msg[0] & 0xc0) === 0x40) {
const a = allocations.get(client.key);
const view = new DataView(msg.buffer, msg.byteOffset, msg.byteLength);
const bound = a?.channels.get(view.getUint16(0));
const length = view.getUint16(2);
if (a && bound && bound.expires >= now() && 4 + length <= msg.length) toPeer(a, msg.subarray(4, 4 + length), bound.ip, bound.port);
return;
}
const m = parse(msg);
if (!m) return;
if (m.type === BINDING) {
const mapped = xorAddress(client.ip, client.port, m.header);
if (mapped) client.send(build(BINDING | 0x100, m.header, [[A.XOR_MAPPED_ADDRESS, mapped]]));
return;
}
if (!opts.relay) return;
const get = (t: number) => m.attrs.find(a => a.type === t);
const existing = allocations.get(client.key);
if (m.type === SEND) {
const peer = get(A.XOR_PEER_ADDRESS), data = get(A.DATA);
const to = peer && unxorAddress(peer.value, m.header);
if (existing && to && data && (existing.permissions.get(to.ip) ?? 0) >= now()) toPeer(existing, data.value, to.ip, to.port);
return;
}
if (![ALLOCATE, REFRESH, CREATE_PERMISSION, CHANNEL_BIND].includes(m.type)) return;
const key = authenticate(m, client);
if (!key) return;
const ok = (attrs: [number, Uint8Array][] = []) => client.send(build(m.type | 0x100, m.header, attrs, key));
const error = (code: number, reason: string) => {
opts.log?.(`relay: ${client.key} refused, ${code} ${reason}`);
client.send(build(m.type | 0x110, m.header, [[A.ERROR_CODE, errorCode(code, reason)]], key));
};
if (m.type === ALLOCATE) {
if (existing) return error(437, 'Allocation Mismatch');
if (get(A.REQUESTED_TRANSPORT)?.value[0] !== 17) return error(442, 'Unsupported Transport Protocol');
const family: 4 | 6 = get(A.REQUESTED_ADDRESS_FAMILY)?.value[0] === 2 ? 6 : 4;
const mine = [...allocations.values()].filter(a => a.client.ip === client.ip).length;
if (allocations.size >= opts.maxAllocations || mine >= opts.maxPerAddress) return error(486, 'Allocation Quota Reached');
const relayIp = relayAddress(family, client.local);
const relay = relayIp ? await bindRelay(family) : null;
if (!relay || !relayIp) return error(508, 'Insufficient Capacity');
// Another request for the same client may have won while the port was being opened
if (allocations.has(client.key)) { relay.close(); return error(437, 'Allocation Mismatch'); }
const a: Allocation = { client, authKey: key, relay, relayIp, permissions: new Map(), channels: new Map(), byPeer: new Map(), expires: now() + LIFETIME };
allocations.set(client.key, a);
relay.on('message', (data, from) => fromPeer(a, data, from.address, from.port));
opts.log?.(`relay: ${client.key} got ${relayIp}:${relay.address().port} (${allocations.size} in use)`);
return ok([
[A.XOR_RELAYED_ADDRESS, xorAddress(relayIp, relay.address().port, m.header)!],
[A.LIFETIME, u32(LIFETIME)],
[A.XOR_MAPPED_ADDRESS, xorAddress(client.ip, client.port, m.header)!]
]);
}
if (!existing) return error(437, 'Allocation Mismatch');
if (m.type === REFRESH) {
const wanted = get(A.LIFETIME);
const seconds = wanted ? Math.min(new DataView(wanted.value.buffer, wanted.value.byteOffset).getUint32(0), LIFETIME) : LIFETIME;
if (seconds === 0) free(existing);
else existing.expires = now() + seconds;
return ok([[A.LIFETIME, u32(seconds)]]);
}
const peers = m.attrs.filter(a => a.type === A.XOR_PEER_ADDRESS).map(a => unxorAddress(a.value, m.header));
if (!peers.length || peers.some(p => !p)) return error(400, 'Bad Request');
// Other clients of this relay are reached at its own address
if (peers.some(p => p!.ip !== existing.relayIp && !opts.peerAllowed(p!.ip))) return error(403, 'Forbidden');
if (m.type === CREATE_PERMISSION) {
for (const p of peers) existing.permissions.set(p!.ip, now() + PERMISSION);
return ok();
}
// ChannelBind
const number = get(A.CHANNEL_NUMBER);
const channel = number ? new DataView(number.value.buffer, number.value.byteOffset).getUint16(0) : 0;
const peer = peers[0]!;
const peerKey = `${peer.ip}:${peer.port}`;
if (channel < 0x4000 || channel > 0x7ffe) return error(400, 'Bad Request');
const boundTo = existing.channels.get(channel), boundAs = existing.byPeer.get(peerKey);
if ((boundTo && `${boundTo.ip}:${boundTo.port}` !== peerKey) || (boundAs != null && boundAs !== channel)) return error(400, 'Bad Request');
existing.channels.set(channel, { ...peer, expires: now() + CHANNEL });
existing.byPeer.set(peerKey, channel);
existing.permissions.set(peer.ip, now() + PERMISSION);
return ok();
}
// UDP: one message per packet
const udp = dgram.createSocket(net.isIPv4(opts.bind) ? 'udp4' : 'udp6');
udp.on('message', (msg, from) => {
const ip = v4(from.address);
handle(msg, { key: `udp:${ip}:${from.port}`, ip, port: from.port, local: null, send: bytes => udp.send(bytes, from.port, from.address) }).catch(() => {});
});
const port = await new Promise<number>((resolve, reject) => {
udp.once('error', reject);
udp.bind(opts.port, opts.bind, () => { udp.removeAllListeners('error'); udp.on('error', () => {}); resolve(udp.address().port); });
});
// TCP, for browsers and networks that allow no UDP: the same messages, one after another, padded to 4 bytes
const tcp = net.createServer(socket => {
const ip = v4(socket.remoteAddress ?? ''), remotePort = socket.remotePort ?? 0;
const client: Client = {
key: `tcp:${ip}:${remotePort}`, ip, port: remotePort, local: socket.localAddress ? v4(socket.localAddress) : null,
send: bytes => {
if (socket.destroyed) return;
// A slow reader must not grow our memory; it loses packets instead, like on UDP
if (socket.writableLength > 4 * 1024 * 1024) return;
const padded = (bytes.length + 3) & ~3;
socket.write(padded === bytes.length ? bytes : Buffer.concat([bytes, Buffer.alloc(padded - bytes.length)]));
}
};
let pending: Buffer = Buffer.alloc(0);
socket.setNoDelay(true);
socket.setTimeout(LIFETIME * 1000, () => socket.destroy());
socket.on('data', (chunk: Buffer) => {
pending = pending.length ? Buffer.concat([pending, chunk]) : chunk;
while (pending.length >= 4) {
const kind = pending[0] & 0xc0;
if (kind !== 0x00 && kind !== 0x40) return socket.destroy();
const length = (kind === 0x00 ? 20 : 4) + pending.readUInt16BE(2);
const framed = (length + 3) & ~3;
if (length > 65536) return socket.destroy();
if (pending.length < framed) break;
handle(Uint8Array.from(pending.subarray(0, length)), client).catch(() => {});
pending = pending.subarray(framed);
}
});
socket.on('error', () => {});
socket.on('close', () => { const a = allocations.get(client.key); if (a) free(a); });
});
if (opts.relay) {
await new Promise<void>(resolve => {
// Relaying over UDP still works without the TCP port
tcp.once('error', () => resolve());
tcp.listen({ port, host: opts.bind }, () => { tcp.removeAllListeners('error'); tcp.on('error', () => {}); resolve(); });
});
}
return {
port,
close() {
clearInterval(sweep);
for (const a of [...allocations.values()]) free(a);
try { udp.close(); } catch { /* already closed */ }
tcp.close();
}
};
}
+29 -5
View File
@@ -14,7 +14,12 @@
import UserInfoDialog from './ui/UserInfoDialog.svelte'; import UserInfoDialog from './ui/UserInfoDialog.svelte';
import ChannelDialog from './ui/ChannelDialog.svelte'; import ChannelDialog from './ui/ChannelDialog.svelte';
import PublicServers from './ui/PublicServers.svelte'; import PublicServers from './ui/PublicServers.svelte';
import ServerInfoDialog from './ui/ServerInfoDialog.svelte';
import ImageViewer from './ui/ImageViewer.svelte';
import ShareDialog from './ui/ShareDialog.svelte';
import { share } from './lib/share.svelte.ts';
import ResizeHandle from './ui/ResizeHandle.svelte'; import ResizeHandle from './ui/ResizeHandle.svelte';
import Shell from './ui2/Shell.svelte';
import { identities } from './lib/identities.svelte.ts'; import { identities } from './lib/identities.svelte.ts';
import { session } from './lib/session.svelte.ts'; import { session } from './lib/session.svelte.ts';
import { ui } from './lib/ui.svelte.ts'; import { ui } from './lib/ui.svelte.ts';
@@ -25,8 +30,8 @@
const layout = $derived(width >= 1100 ? 'wide' : width >= 760 ? 'medium' : 'narrow'); const layout = $derived(width >= 1100 ? 'wide' : width >= 760 ? 'medium' : 'narrow');
// Arrangement (Appearance setting): side is the default, classic puts the channel list right // Arrangement (Appearance setting): side is the default, classic puts the channel list right
// of the chat, stacked above it. Phones always use drawers. // of the chat, stacked above it. Narrow windows use drawers, except stacked, which works at any width.
const arrangement = $derived(layout === 'narrow' ? 'side' : store.settings.arrangement ?? 'side'); const arrangement = $derived(store.settings.arrangement === 'stacked' ? 'stacked' : layout === 'narrow' ? 'side' : store.settings.arrangement ?? 'side');
// The member panel sits inline only in the side arrangement on wide windows, otherwise it overlays // The member panel sits inline only in the side arrangement on wide windows, otherwise it overlays
const panelInline = $derived(layout === 'wide' && arrangement === 'side'); const panelInline = $derived(layout === 'wide' && arrangement === 'side');
let treeHeight = $state(Math.max(120, Number((() => { try { return localStorage.getItem('mumh5.treeHeight'); } catch { return null; } })()) || 280)); let treeHeight = $state(Math.max(120, Number((() => { try { return localStorage.getItem('mumh5.treeHeight'); } catch { return null; } })()) || 280));
@@ -60,22 +65,37 @@
else ui.panelOpen = false; else ui.panelOpen = false;
} }
// Images in rendered text (messages, descriptions, the quick preview) and image embeds open
// large on click. Not in editors, link preview cards or the YouTube card.
function imageClick(e: MouseEvent) {
if (e.button !== 0 || !(e.target instanceof HTMLImageElement)) return;
const img = e.target;
if (!img.closest('.html, .embed') || img.closest('[contenteditable], .rich, .card, .yt')) return;
e.preventDefault();
e.stopPropagation();
ui.image = img.currentSrc || img.src;
}
const closeOverlays = () => { const closeOverlays = () => {
navOpen = false; navOpen = false;
if (!panelInline) ui.panelOpen = false; if (!panelInline) ui.panelOpen = false;
}; };
</script> </script>
<svelte:window bind:innerWidth={width} /> <svelte:window bind:innerWidth={width} onclickcapture={imageClick} />
{#snippet rail()} {#snippet rail()}
<ServerRail onadd={() => (editing = { server: null })} onedit={s => (editing = { server: s })} onbrowse={() => (ui.publicServers = true)} <ServerRail onadd={() => (editing = { server: null })} onedit={s => (editing = { server: s })} onbrowse={() => (ui.publicServers = true)}
onsettings={() => (ui.settingsOpen = true)} /> onsettings={() => (ui.settingsOpen = true)} />
{/snippet} {/snippet}
{#snippet chat()} {#snippet chat()}
<Chat showMenu={layout === 'narrow'} showStage={!panelInline && arrangement === 'side'} onmenu={() => (navOpen = true)} onpanel={togglePanel} /> <Chat showMenu={layout === 'narrow' && arrangement === 'side'} showStage={!panelInline && arrangement === 'side'} videoOnly={arrangement === 'stacked'}
onmenu={() => (navOpen = true)} onpanel={togglePanel} />
{/snippet} {/snippet}
{#if store.settings.interface === 'rooms'}
<Shell onadd={() => (editing = { server: null })} onedit={s => (editing = { server: s })} onbrowse={() => (ui.publicServers = true)} />
{:else}
<div class="app {layout} {arrangement}"> <div class="app {layout} {arrangement}">
{#if arrangement === 'classic'} {#if arrangement === 'classic'}
{@render rail()} {@render rail()}
@@ -111,10 +131,12 @@
{/if} {/if}
{/if} {/if}
</div> </div>
{/if}
{#if editing}<ConnectDialog server={editing.server} onclose={() => (editing = null)} />{/if} {#if editing}<ConnectDialog server={editing.server} onclose={() => (editing = null)} />{/if}
{#if ui.addServer}<ConnectDialog server={null} prefill={ui.addServer} onclose={() => (ui.addServer = null)} />{/if} {#if ui.addServer}<ConnectDialog server={null} prefill={ui.addServer} onclose={() => (ui.addServer = null)} />{/if}
{#if ui.publicServers}<PublicServers onclose={() => (ui.publicServers = false)} />{/if} {#if ui.publicServers}<PublicServers onclose={() => (ui.publicServers = false)} />{/if}
{#if ui.serverInfo && session.status === 'connected'}<ServerInfoDialog onclose={() => (ui.serverInfo = false)} />{/if}
{#if ui.settingsOpen}<SettingsDialog tab={ui.settingsTab} onclose={() => { ui.settingsOpen = false; ui.settingsTab = 'voice'; }} />{/if} {#if ui.settingsOpen}<SettingsDialog tab={ui.settingsTab} onclose={() => { ui.settingsOpen = false; ui.settingsTab = 'voice'; }} />{/if}
{#if ui.quickAudio}<QuickAudio />{/if} {#if ui.quickAudio}<QuickAudio />{/if}
{#if ui.userInfo != null && session.status === 'connected'} {#if ui.userInfo != null && session.status === 'connected'}
@@ -127,7 +149,9 @@
{#if ui.channelDialog && session.status === 'connected'} {#if ui.channelDialog && session.status === 'connected'}
{#key ui.channelDialog}<ChannelDialog {...ui.channelDialog} onclose={() => (ui.channelDialog = null)} />{/key} {#key ui.channelDialog}<ChannelDialog {...ui.channelDialog} onclose={() => (ui.channelDialog = null)} />{/key}
{/if} {/if}
{#if ui.prompt}<PromptDialog {...ui.prompt} onclose={() => (ui.prompt = null)} />{/if} {#if share.dialog}<ShareDialog onclose={() => (share.dialog = null)} />{/if}
{#if ui.prompt}<PromptDialog {...ui.prompt} onclose={() => { const p = ui.prompt; ui.prompt = null; p?.oncancel?.(); }} />{/if}
<ImageViewer />
<ContextMenu /> <ContextMenu />
<style> <style>
+4
View File
@@ -73,6 +73,10 @@ label { display: block; font-size: 12px; font-weight: 600; text-transform: upper
.icon-btn:hover { background: var(--bg-hover); color: var(--text); } .icon-btn:hover { background: var(--bg-hover); color: var(--text); }
.icon-btn.on { color: var(--danger); } .icon-btn.on { color: var(--danger); }
/* Images in rendered text open large on click (App.svelte) */
.html img, .embed img { cursor: zoom-in; }
.card img { cursor: inherit; }
::-webkit-scrollbar { width: 10px; height: 10px; } ::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-thumb { background: var(--bg-3); border: 2px solid transparent; background-clip: padding-box; } ::-webkit-scrollbar-thumb { background: var(--bg-3); border: 2px solid transparent; background-clip: padding-box; }
::-webkit-scrollbar-thumb:hover { background-color: var(--bg-active); } ::-webkit-scrollbar-thumb:hover { background-color: var(--bg-active); }
+25
View File
@@ -0,0 +1,25 @@
// Where a channel sits among its siblings. Mumble sorts by position, then by name, so a
// channel dropped between two others needs a position that puts it exactly there.
export interface Ordered { id: number; position: number; name: string }
export const byOrder = (a: Ordered, b: Ordered) => a.position - b.position || a.name.localeCompare(b.name);
// Position changes that put `moved` at `index` among `siblings` (which need not contain it).
// One change when there is room between the neighbours; otherwise the row is numbered again.
export function planMove(siblings: Ordered[], moved: Ordered, index: number): { id: number; position: number }[] {
const rest = siblings.filter(c => c.id !== moved.id).sort(byOrder);
const at = Math.max(0, Math.min(index, rest.length));
const prev = rest[at - 1];
const next = rest[at];
const fits = (position: number) => {
const m = { ...moved, position };
return (!prev || byOrder(prev, m) < 0) && (!next || byOrder(m, next) < 0);
};
const tries = [moved.position, prev?.position, next?.position, prev && prev.position + 1, next && next.position - 1];
for (const p of tries) {
if (p == null || !fits(p)) continue;
return p === moved.position ? [] : [{ id: moved.id, position: p }];
}
const order = [...rest.slice(0, at), moved, ...rest.slice(at)];
return order.map((c, i) => ({ id: c.id, position: i })).filter((u, i) => order[i].position !== u.position);
}
+111
View File
@@ -0,0 +1,111 @@
// Screen-share signalling between mumh5 clients, carried as Mumble plugin data so it works on
// any server and other clients ignore it. Murmur drops plugin data over 1000 bytes, so session
// descriptions are compressed and split into chunks.
export const SHARE_DATA_ID = 'mumh5.share';
export const SHARE = {
on: 1, // I am sharing my screen
off: 2, // I stopped
who: 3, // I just got here: who is sharing?
watch: 4, // let me watch
leave: 5, // I stopped watching
offer: 6, // session description from the sharer
answer: 7 // session description from the viewer
} as const;
export type ShareType = typeof SHARE[keyof typeof SHARE];
const CHUNK = 900;
const MAX_CHUNKS = 32;
const MAX_TEXT = 256 * 1024;
let nextId = 0;
async function deflate(bytes: Uint8Array): Promise<Uint8Array> {
const out = new Blob([bytes as Uint8Array<ArrayBuffer>]).stream().pipeThrough(new CompressionStream('deflate-raw'));
return new Uint8Array(await new Response(out).arrayBuffer());
}
// Stops reading once the output passes the cap, so a tiny message cannot expand without bound
async function inflate(bytes: Uint8Array): Promise<Uint8Array | null> {
const reader = new Blob([bytes as Uint8Array<ArrayBuffer>]).stream().pipeThrough(new DecompressionStream('deflate-raw')).getReader();
const parts: Uint8Array[] = [];
let size = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
size += value.length;
if (size > MAX_TEXT) { await reader.cancel(); return null; }
parts.push(value);
}
} catch {
return null;
}
const all = new Uint8Array(size);
let at = 0;
for (const p of parts) { all.set(p, at); at += p.length; }
return all;
}
// One packet for a bare signal, several for a signal with text: [type, id, index, total, ...data]
export async function encodeSignal(type: number, text?: string): Promise<Uint8Array[]> {
if (text == null) return [new Uint8Array([type])];
const data = await deflate(new TextEncoder().encode(text));
const total = Math.max(1, Math.ceil(data.length / CHUNK));
if (total > MAX_CHUNKS) throw new Error('Signal too large');
const id = nextId = (nextId + 1) & 255;
return Array.from({ length: total }, (_, i) => {
const part = data.subarray(i * CHUNK, (i + 1) * CHUNK);
const packet = new Uint8Array(4 + part.length);
packet.set([type, id, i, total]);
packet.set(part, 4);
return packet;
});
}
export const encodeShare = (type: ShareType, text?: string) => encodeSignal(type, text);
export interface Signal<T extends number> { type: T; text?: string }
export type ShareSignal = Signal<ShareType>;
// Puts chunked signals back together, per sender
export class SignalAssembler<T extends number> {
private pending = new Map<string, { parts: (Uint8Array | undefined)[]; got: number; at: number }>();
private types: readonly T[];
constructor(types: readonly T[]) {
this.types = types;
}
async push(sender: number, packet: Uint8Array, now = Date.now()): Promise<Signal<T> | null> {
const type = packet[0] as T;
if (!this.types.includes(type)) return null;
if (packet.length === 1) return { type };
if (packet.length < 4) return null;
const [, id, index, total] = packet;
if (!total || total > MAX_CHUNKS || index >= total) return null;
// Abandoned transfers must not pile up
for (const [k, v] of this.pending) if (now - v.at > 15000) this.pending.delete(k);
if (this.pending.size > 64) this.pending.clear();
const key = `${sender}:${type}:${id}`;
let entry = this.pending.get(key);
if (!entry || entry.parts.length !== total) {
entry = { parts: new Array(total), got: 0, at: now };
this.pending.set(key, entry);
}
if (!entry.parts[index]) entry.got++;
entry.parts[index] = packet.slice(4);
if (entry.got < total) return null;
this.pending.delete(key);
const joined = new Uint8Array(entry.parts.reduce((n, p) => n + p!.length, 0));
let at = 0;
for (const p of entry.parts) { joined.set(p!, at); at += p!.length; }
const plain = await inflate(joined);
return plain ? { type, text: new TextDecoder().decode(plain) } : null;
}
}
export class ShareAssembler extends SignalAssembler<ShareType> {
constructor() {
super(Object.values(SHARE));
}
}
+102
View File
@@ -0,0 +1,102 @@
// Text channels between mumh5 clients. Mumble only delivers a channel message to the people
// sitting in that channel, so a channel you read while you are in voice elsewhere cannot be
// a plain Mumble chat. A text channel is a normal Mumble channel with a marker in its
// description; its messages travel as plugin data to everyone on the server, which other
// clients ignore. The server stores nothing: clients keep the messages and hand the recent
// ones to whoever connects later.
import { encodeSignal, SignalAssembler } from './share-signal.ts';
export const TEXT_DATA_ID = 'mumh5.text';
export const TEXT = {
hello: 1, // I just connected: who else runs mumh5?
here: 2, // answer to hello
msg: 3, // a message: { i, c, h }
want: 4, // send me the recent messages
history: 5 // answer to want, one per channel: { c, m: [...] }
} as const;
export type TextType = typeof TEXT[keyof typeof TEXT];
// An HTML comment: invisible in every client, and Murmur keeps it
export const TEXT_MARK = '<!--mumh5:text-->';
export const isTextChannel = (description: string) => description.includes(TEXT_MARK);
export const stripMark = (description: string) => description.split(TEXT_MARK).join('');
export const markText = (description: string, on: boolean) => (on ? TEXT_MARK : '') + stripMark(description);
// Kept per channel, and handed to a newcomer
export const KEEP = 200;
export const HAND_OVER = 50;
// Compressed, this stays well inside the 32 packets a signal may take
export const MAX_HTML = 24000;
// i: id, c: channel, t: time, n: author name, k: author certificate hash, h: html
export interface TextMsg { i: string; c: number; t: number; n: string; k: string; h: string }
export const encodeText = (type: TextType, body?: unknown) => encodeSignal(type, body === undefined ? undefined : JSON.stringify(body));
export class TextAssembler extends SignalAssembler<TextType> {
constructor() {
super(Object.values(TEXT));
}
}
export function newId(): string {
return Date.now().toString(36) + Array.from(crypto.getRandomValues(new Uint8Array(6)), b => b.toString(36)).join('');
}
function parse(text: string | undefined): Record<string, unknown> | null {
try {
const v = JSON.parse(text ?? '');
return v && typeof v === 'object' && !Array.isArray(v) ? v : null;
} catch {
return null;
}
}
const okId = (v: unknown): v is string => typeof v === 'string' && v.length > 0 && v.length <= 40;
const okChannel = (v: unknown): v is number => Number.isInteger(v) && (v as number) >= 0;
const okHtml = (v: unknown): v is string => typeof v === 'string' && v.length > 0 && v.length <= MAX_HTML;
// A live message; who sent it and when is filled in by the receiver
export function parseMsg(text: string | undefined): { i: string; c: number; h: string } | null {
const v = parse(text);
return v && okId(v.i) && okChannel(v.c) && okHtml(v.h) ? { i: v.i, c: v.c, h: v.h } : null;
}
// Messages another client kept. Times from the future are pulled back to now.
export function parseHistory(text: string | undefined, now = Date.now()): TextMsg[] {
const v = parse(text);
if (!v || !okChannel(v.c) || !Array.isArray(v.m)) return [];
const c = v.c;
const out: TextMsg[] = [];
for (const m of v.m.slice(-HAND_OVER) as Record<string, unknown>[]) {
if (!m || !okId(m.i) || !okHtml(m.h) || typeof m.t !== 'number' || !Number.isFinite(m.t)) continue;
if (typeof m.n !== 'string' || m.n.length > 200 || typeof m.k !== 'string' || m.k.length > 200) continue;
out.push({ i: m.i, c, t: Math.min(m.t, now), n: m.n, k: m.k, h: m.h });
}
return out;
}
// Adds what is not there yet, in order of time. Returns the new list and what was added.
export function mergeMsgs(have: TextMsg[], incoming: TextMsg[], keep = KEEP): { all: TextMsg[]; added: TextMsg[] } {
const ids = new Set(have.map(m => m.i));
const added: TextMsg[] = [];
for (const m of incoming) {
if (ids.has(m.i)) continue;
ids.add(m.i);
added.push(m);
}
if (!added.length) return { all: have, added };
const all = [...have, ...added].sort((a, b) => a.t - b.t).slice(-keep);
return { all, added: added.filter(m => all.includes(m)) };
}
// The newest messages of a channel that still fit one signal
export async function encodeHistory(c: number, msgs: TextMsg[]): Promise<Uint8Array[]> {
for (let n = Math.min(HAND_OVER, msgs.length); n > 0; n = Math.floor(n / 2)) {
try {
return await encodeText(TEXT.history, { c, m: msgs.slice(-n).map(({ i, t, n: name, k, h }) => ({ i, t, n: name, k, h })) });
} catch { /* too large: hand over fewer */ }
}
return [];
}
+3
View File
@@ -51,4 +51,7 @@ export interface ServerCertInfo {
authorized: boolean; // true if it chains to a trusted CA authorized: boolean; // true if it chains to a trusted CA
authError: string | null; // why it is not trusted, e.g. DEPTH_ZERO_SELF_SIGNED_CERT authError: string | null; // why it is not trusted, e.g. DEPTH_ZERO_SELF_SIGNED_CERT
chain: CertDetails[]; // leaf first chain: CertDetails[]; // leaf first
// Browser build only: this client's address as the web proxy saw it. The Mumble server
// sees the proxy's address instead and cannot be told the original one.
clientAddress?: string;
} }
+50
View File
@@ -0,0 +1,50 @@
import type { Transport, ServerCertInfo } from './transport.ts';
// Transport through the mumh5 web proxy: one WebSocket per Mumble connection. The proxy opens
// the TLS socket and presents the client certificate, since a browser can do neither.
// `secure` resolves once the proxy has finished the TLS handshake, before any Mumble data is
// sent, so the caller can check the server certificate before sending a password.
// There is no UDP here; voice goes through the TCP tunnel.
export class WebSocketTransport implements Transport {
onData: ((chunk: Uint8Array) => void) | null = null;
onClose: ((reason: string) => void) | null = null;
readonly secure: Promise<ServerCertInfo>;
private ws: WebSocket;
constructor(url: string, target: { host: string; port: number }, identity: { certPem: string; keyPem: string }) {
let resolveSecure!: (i: ServerCertInfo) => void;
let rejectSecure!: (e: Error) => void;
this.secure = new Promise((res, rej) => { resolveSecure = res; rejectSecure = rej; });
// Callers that never await `secure` still get onClose
this.secure.catch(() => {});
let reason = '';
let opened = false;
const ws = new WebSocket(url);
this.ws = ws;
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
opened = true;
ws.send(JSON.stringify({ host: target.host, port: target.port, certPem: identity.certPem, keyPem: identity.keyPem }));
};
ws.onmessage = e => {
if (typeof e.data !== 'string') return this.onData?.(new Uint8Array(e.data as ArrayBuffer));
let msg: any;
try { msg = JSON.parse(e.data); } catch { return; }
if (msg.type === 'secure') resolveSecure(msg.info);
else if (msg.type === 'close') reason = String(msg.reason ?? '');
};
ws.onclose = () => {
reason ||= opened ? 'Connection closed' : 'Could not reach the proxy';
rejectSecure(new Error(reason));
this.onClose?.(reason);
};
}
send(bytes: Uint8Array): void {
if (this.ws.readyState === WebSocket.OPEN) this.ws.send(bytes as Uint8Array<ArrayBuffer>);
}
close(): void {
this.ws.close();
}
}
+17 -3
View File
@@ -1,4 +1,5 @@
import { session } from './session.svelte.ts'; import { session, sessions } from './session.svelte.ts';
import { share } from './share.svelte.ts';
import { ui } from './ui.svelte.ts'; import { ui } from './ui.svelte.ts';
import { store } from './settings.svelte.ts'; import { store } from './settings.svelte.ts';
import { voice, userKey } from './audio/voice.svelte.ts'; import { voice, userKey } from './audio/voice.svelte.ts';
@@ -35,10 +36,17 @@ export function channelMenu(c: Channel): MenuItem[] {
}) })
}); });
} }
// A text channel is opened, not joined
const open: MenuItem[] = session.isText(c.id)
? [{ label: 'Open text channel', action: () => session.selectView(`ch:${c.id}`) }]
: [
{ label: here ? 'You are here' : 'Join channel', action: () => session.joinChannel(c.id), disabled: here || !c.canEnter },
// Opens the channel's chat in the side panel, ready to type, without joining it
{ label: 'Send message to channel', action: () => { session.showChannel(c.id, 'chat'); ui.panelOpen = true; } }
];
return [ return [
{ header: true, label: c.name || 'Root' }, { header: true, label: c.name || 'Root' },
{ label: here ? 'You are here' : 'Join channel', action: () => session.joinChannel(c.id), disabled: here || !c.canEnter }, ...open,
{ label: 'Open chat', action: () => { session.showChannel(c.id, 'chat'); ui.panelOpen = true; } },
{ label: 'Show description', action: () => { session.showChannel(c.id, 'info'); ui.panelOpen = true; } }, { label: 'Show description', action: () => { session.showChannel(c.id, 'info'); ui.panelOpen = true; } },
{ separator: true, label: '' }, { separator: true, label: '' },
{ label: session.isExpanded(c.id) ? 'Collapse' : 'Expand', action: () => session.toggleExpanded(c.id), disabled: c.id === 0 }, { label: session.isExpanded(c.id) ? 'Collapse' : 'Expand', action: () => session.toggleExpanded(c.id), disabled: c.id === 0 },
@@ -67,6 +75,12 @@ export function userMenu(u: User): MenuItem[] {
); );
if (u.userId == null) items.push({ label: 'Register on this server', action: () => session.register(u.session) }); if (u.userId == null) items.push({ label: 'Register on this server', action: () => session.register(u.session) });
} else { } else {
const sharing = session.sharing[u.session];
if (sharing === 'camera') {
// Cameras show by themselves; this is the per-person switch
const hidden = share.isHidden(sessions.active, u.session) || !share.watchOf(u.session);
items.push({ label: hidden ? 'Show camera' : 'Hide camera', action: () => share.hideCamera(sessions.active, u.session, !hidden) });
} else if (sharing) items.push({ label: `Watch ${sharing}`, action: () => share.watch(sessions.active, u.session) });
items.push( items.push(
{ label: 'Send message', action: () => session.selectView(`dm:${u.session}`) }, { label: 'Send message', action: () => session.selectView(`dm:${u.session}`) },
{ label: 'Mute for me', checked: !!voice.settings.localMutes[userKey(u)], action: () => voice.setLocalMute(u, !voice.settings.localMutes[userKey(u)]) }, { label: 'Mute for me', checked: !!voice.settings.localMutes[userKey(u)], action: () => voice.setLocalMute(u, !voice.settings.localMutes[userKey(u)]) },
+8
View File
@@ -0,0 +1,8 @@
import wasmUrl from '@evan/opus/wasm/opus.wasm?url';
import { instantiateOpus, type Opus } from './opus-wasm.ts';
// Fetched once, and only in browsers that need it
let loading: Promise<Opus> | null = null;
export function loadOpus(): Promise<Opus> {
return loading ??= fetch(wasmUrl).then(r => r.arrayBuffer()).then(instantiateOpus).catch(e => { loading = null; throw e; });
}
+126
View File
@@ -0,0 +1,126 @@
// Opus in WebAssembly (libopus, from the @evan/opus package), for browsers without WebCodecs
// audio, such as Firefox on Android. Only loaded there; everywhere else the browser's own
// codec is used. The two classes offer the small part of the WebCodecs interface the voice
// engine needs. No DOM or bundler imports here, so the tests can run it in Node.
interface Exports {
memory: WebAssembly.Memory;
malloc(size: number): number;
free(ptr: number): void;
opus_encoder_get_size(channels: number): number;
opus_encoder_init(ptr: number, rate: number, channels: number, application: number): number;
opus_encoder_ctl_set(ptr: number, request: number, value: number): number;
opus_encode(ptr: number, pcm: number, frameSize: number, out: number, max: number): number;
opus_decoder_get_size(channels: number): number;
opus_decoder_init(ptr: number, rate: number, channels: number): number;
opus_decode(ptr: number, data: number, length: number, pcm: number, frameSize: number, fec: number): number;
}
export interface Opus { wasm: Exports }
const RATE = 48000;
const MAX_PACKET = 4000;
// The longest Opus frame is 120 ms
const MAX_SAMPLES = 5760;
const APPLICATION_VOIP = 2048, SET_BITRATE = 4002, SET_COMPLEXITY = 4010, SET_INBAND_FEC = 4012, SET_PACKET_LOSS = 4014, SET_SIGNAL = 4024, SIGNAL_VOICE = 3001;
// bytes: the contents of opus.wasm (fetched by opus-load.ts in the app, read from disk in tests)
export async function instantiateOpus(bytes: BufferSource): Promise<Opus> {
const imports = {
wasi_snapshot_preview1: { fd_seek() { return 0; }, fd_write() { return 0; }, fd_close() { return 0; }, proc_exit() {} },
// Memory is read through fresh views every call, so growth needs no bookkeeping
env: { emscripten_notify_memory_growth() {} }
};
const { instance } = await WebAssembly.instantiate(bytes, imports);
return { wasm: instance.exports as unknown as Exports };
}
// Takes the engine's 10 ms frames and gives out one packet per frame duration
export class WasmEncoder {
state: 'configured' | 'closed' = 'configured';
private wasm: Exports;
private ptr: number;
private pcm: number;
private out: number;
private samples: number;
private filled = 0;
private output: (packet: Uint8Array) => void;
constructor(opus: Opus, bitrate: number, frameMs: number, output: (packet: Uint8Array) => void) {
const w = this.wasm = opus.wasm;
this.output = output;
this.samples = RATE * frameMs / 1000;
this.ptr = w.malloc(w.opus_encoder_get_size(1));
this.pcm = w.malloc(this.samples * 2);
this.out = w.malloc(MAX_PACKET);
if (w.opus_encoder_init(this.ptr, RATE, 1, APPLICATION_VOIP) < 0) { this.close(); throw new Error('Could not start the Opus encoder'); }
w.opus_encoder_ctl_set(this.ptr, SET_BITRATE, Math.round(bitrate));
// A notch below the maximum: phones are the ones without a codec of their own
w.opus_encoder_ctl_set(this.ptr, SET_COMPLEXITY, 8);
w.opus_encoder_ctl_set(this.ptr, SET_INBAND_FEC, 1);
w.opus_encoder_ctl_set(this.ptr, SET_PACKET_LOSS, 10);
w.opus_encoder_ctl_set(this.ptr, SET_SIGNAL, SIGNAL_VOICE);
}
push(frame: Float32Array): void {
if (this.state !== 'configured') return;
let view = new Int16Array(this.wasm.memory.buffer, this.pcm, this.samples);
for (let i = 0; i < frame.length; i++) {
const v = Math.max(-1, Math.min(1, frame[i]));
view[this.filled++] = v < 0 ? v * 0x8000 : v * 0x7fff;
if (this.filled < this.samples) continue;
this.filled = 0;
const length = this.wasm.opus_encode(this.ptr, this.pcm, this.samples, this.out, MAX_PACKET);
if (length > 0) this.output(new Uint8Array(this.wasm.memory.buffer, this.out, length).slice());
// Encoding may have grown the memory, which detaches the view
view = new Int16Array(this.wasm.memory.buffer, this.pcm, this.samples);
}
}
// An unfinished frame at the end of a transmission is dropped
flush(): Promise<void> {
this.filled = 0;
return Promise.resolve();
}
close(): void {
if (this.state === 'closed') return;
this.state = 'closed';
for (const p of [this.ptr, this.pcm, this.out]) if (p) this.wasm.free(p);
}
}
export class WasmDecoder {
private wasm: Exports;
private ptr: number;
private data: number;
private pcm: number;
private closed = false;
constructor(opus: Opus) {
const w = this.wasm = opus.wasm;
this.ptr = w.malloc(w.opus_decoder_get_size(1));
this.data = w.malloc(MAX_PACKET);
this.pcm = w.malloc(MAX_SAMPLES * 2);
if (w.opus_decoder_init(this.ptr, RATE, 1) < 0) { this.close(); throw new Error('Could not start the Opus decoder'); }
}
// Mono samples for the playback worklet, or null for a packet that does not decode
decode(packet: Uint8Array): Float32Array<ArrayBuffer> | null {
// An empty packet would make libopus invent audio (loss concealment)
if (this.closed || !packet.length || packet.length > MAX_PACKET) return null;
new Uint8Array(this.wasm.memory.buffer, this.data, packet.length).set(packet);
const n = this.wasm.opus_decode(this.ptr, this.data, packet.length, this.pcm, MAX_SAMPLES, 0);
if (n <= 0) return null;
const samples = new Int16Array(this.wasm.memory.buffer, this.pcm, n);
const out = new Float32Array(n);
for (let i = 0; i < n; i++) out[i] = samples[i] / 0x8000;
return out;
}
close(): void {
if (this.closed) return;
this.closed = true;
for (const p of [this.ptr, this.data, this.pcm]) if (p) this.wasm.free(p);
}
}
+8
View File
@@ -27,6 +27,10 @@ export const SOUND_EVENTS = [
{ id: 'userBanned', group: 'Your channel', name: 'Someone in your channel was banned', ts: 'CLIENT_WAS_BANNED_CURRENT_CHANNEL', keys: ['banned_server_currentchannel'] }, { id: 'userBanned', group: 'Your channel', name: 'Someone in your channel was banned', ts: 'CLIENT_WAS_BANNED_CURRENT_CHANNEL', keys: ['banned_server_currentchannel'] },
{ id: 'recordingStarted', group: 'Your channel', name: 'Someone started recording', ts: 'CLIENT_RECORDING_START', keys: ['recording_started'] }, { id: 'recordingStarted', group: 'Your channel', name: 'Someone started recording', ts: 'CLIENT_RECORDING_START', keys: ['recording_started'] },
{ id: 'recordingStopped', group: 'Your channel', name: 'Someone stopped recording', ts: 'CLIENT_RECORDING_STOP', keys: ['recording_stopped'] }, { id: 'recordingStopped', group: 'Your channel', name: 'Someone stopped recording', ts: 'CLIENT_RECORDING_STOP', keys: ['recording_stopped'] },
{ id: 'shareStarted', group: 'Screen sharing', name: 'A screen share started (yours or in your channel)', ts: 'MUMH5_SHARE_STARTED', keys: ['share_started', 'stream_started'] },
{ id: 'shareStopped', group: 'Screen sharing', name: 'A screen share stopped', ts: 'MUMH5_SHARE_STOPPED', keys: ['share_stopped', 'stream_stopped'] },
{ id: 'viewerJoined', group: 'Screen sharing', name: 'Someone started watching your screen', ts: 'MUMH5_VIEWER_JOINED', keys: ['viewer_joined'] },
{ id: 'viewerLeft', group: 'Screen sharing', name: 'Someone stopped watching your screen', ts: 'MUMH5_VIEWER_LEFT', keys: ['viewer_left'] },
{ id: 'message', group: 'Chat', name: 'Channel message', ts: 'CHAT_RECEIVED_MESSAGE_CHANNEL', keys: ['chat_message_inbound', 'message'] }, { id: 'message', group: 'Chat', name: 'Channel message', ts: 'CHAT_RECEIVED_MESSAGE_CHANNEL', keys: ['chat_message_inbound', 'message'] },
{ id: 'privateMessage', group: 'Chat', name: 'Direct message', ts: 'CHAT_RECEIVED_MESSAGE_CLIENT', keys: ['private', 'direct', 'pm'] }, { id: 'privateMessage', group: 'Chat', name: 'Direct message', ts: 'CHAT_RECEIVED_MESSAGE_CLIENT', keys: ['private', 'direct', 'pm'] },
{ id: 'messageSent', group: 'Chat', name: 'You sent a message', ts: 'CHAT_SENT_MESSAGE_CHANNEL', keys: ['chat_message_outbound', 'sent'], off: true }, { id: 'messageSent', group: 'Chat', name: 'You sent a message', ts: 'CHAT_SENT_MESSAGE_CHANNEL', keys: ['chat_message_outbound', 'sent'], off: true },
@@ -79,6 +83,10 @@ const TONES: Record<SoundEvent, [number, number, number][]> = {
userBanned: [[294, 0, 0.12], [220, 0.13, 0.16]], userBanned: [[294, 0, 0.12], [220, 0.13, 0.16]],
recordingStarted: [[1175, 0, 0.08], [1175, 0.12, 0.08]], recordingStarted: [[1175, 0, 0.08], [1175, 0.12, 0.08]],
recordingStopped: [[880, 0, 0.12]], recordingStopped: [[880, 0, 0.12]],
shareStarted: [[523, 0, 0.07], [784, 0.08, 0.07], [1047, 0.16, 0.12]],
shareStopped: [[1047, 0, 0.07], [784, 0.08, 0.07], [523, 0.16, 0.12]],
viewerJoined: [[880, 0, 0.05], [1109, 0.06, 0.08]],
viewerLeft: [[1109, 0, 0.05], [880, 0.06, 0.08]],
message: [[1047, 0, 0.06]], message: [[1047, 0, 0.06]],
privateMessage: [[1047, 0, 0.05], [1319, 0.07, 0.07]], privateMessage: [[1047, 0, 0.05], [1319, 0.07, 0.07]],
messageSent: [[1319, 0, 0.04]], messageSent: [[1319, 0, 0.04]],
+94 -17
View File
@@ -1,3 +1,5 @@
import { WasmEncoder, WasmDecoder, type Opus } from './opus-wasm.ts';
import { loadOpus } from './opus-load.ts';
import captureUrl from './capture.worklet.ts?worker&url'; import captureUrl from './capture.worklet.ts?worker&url';
import playbackUrl from './playback.worklet.ts?worker&url'; import playbackUrl from './playback.worklet.ts?worker&url';
import { encodeVoice, decodeVoice } from '../../core/voice-packet.ts'; import { encodeVoice, decodeVoice } from '../../core/voice-packet.ts';
@@ -22,11 +24,17 @@ export interface VoiceSettings {
frameMs: 10 | 20 | 40 | 60; frameMs: 10 | 20 | 40 | 60;
jitterMs: number; jitterMs: number;
forceTcp: boolean; // send voice through the TCP connection even when UDP works forceTcp: boolean; // send voice through the TCP connection even when UDP works
// Give the microphone back while muted. An open microphone puts a Bluetooth headset into its
// telephone mode, which also makes what you hear sound like a phone call.
releaseMicMuted: boolean;
// Per-user local volume and mute, keyed by certificate hash (or name without one) // Per-user local volume and mute, keyed by certificate hash (or name without one)
userVolumes: Record<string, number>; userVolumes: Record<string, number>;
localMutes: Record<string, boolean>; localMutes: Record<string, boolean>;
} }
// Phones and tablets, where Bluetooth headsets are the usual case
const handheld = typeof navigator !== 'undefined' && /Android|iPhone|iPad|iPod/.test(navigator.userAgent);
const defaults: VoiceSettings = { const defaults: VoiceSettings = {
inputDevice: 'default', inputDevice: 'default',
outputDevice: 'default', outputDevice: 'default',
@@ -43,6 +51,7 @@ const defaults: VoiceSettings = {
frameMs: 20, frameMs: 20,
jitterMs: 60, jitterMs: 60,
forceTcp: false, forceTcp: false,
releaseMicMuted: handheld,
userVolumes: {}, userVolumes: {},
localMutes: {} localMutes: {}
}; };
@@ -61,6 +70,7 @@ class VoiceEngine {
inputDevices = $state<DeviceInfo[]>([]); inputDevices = $state<DeviceInfo[]>([]);
outputDevices = $state<DeviceInfo[]>([]); outputDevices = $state<DeviceInfo[]>([]);
running = $state(false); running = $state(false);
micOpen = $state(false); // the microphone is held right now
error = $state(''); error = $state('');
level = $state(-100); // current input level, dBFS level = $state(-100); // current input level, dBFS
transmitting = $state(false); transmitting = $state(false);
@@ -77,8 +87,10 @@ class VoiceEngine {
private outputGainNode: GainNode | null = null; private outputGainNode: GainNode | null = null;
private capture: AudioWorkletNode | null = null; private capture: AudioWorkletNode | null = null;
private playback: AudioWorkletNode | null = null; private playback: AudioWorkletNode | null = null;
private encoder: AudioEncoder | null = null; // The browser's Opus codec (WebCodecs), or our own in WebAssembly where the browser has none
private decoders = new Map<number, { decoder: AudioDecoder; lastFrame: number; timer: ReturnType<typeof setTimeout> | null }>(); private opus: Opus | null = null;
private encoder: AudioEncoder | WasmEncoder | null = null;
private decoders = new Map<number, { decoder: AudioDecoder | WasmDecoder; lastFrame: number; timer: ReturnType<typeof setTimeout> | null }>();
private client: MumbleClient | null = null; private client: MumbleClient | null = null;
private sessionGains = new Map<number, number>(); private sessionGains = new Map<number, number>();
private unsubscribe: (() => void)[] = []; private unsubscribe: (() => void)[] = [];
@@ -118,7 +130,8 @@ class VoiceEngine {
if ('jitterMs' in patch) this.playback?.port.postMessage({ jitterMs: this.settings.jitterMs }); if ('jitterMs' in patch) this.playback?.port.postMessage({ jitterMs: this.settings.jitterMs });
if ('outputDevice' in patch) this.applyOutputDevice(); if ('outputDevice' in patch) this.applyOutputDevice();
if ('bitrate' in patch || 'frameMs' in patch) this.configureEncoder(); if ('bitrate' in patch || 'frameMs' in patch) this.configureEncoder();
if (['inputDevice', 'echoCancellation', 'noiseSuppression', 'autoGainControl'].some(k => k in patch)) this.restartInput(); if (['inputDevice', 'echoCancellation', 'noiseSuppression', 'autoGainControl'].some(k => k in patch)) this.syncInput(true);
else if ('releaseMicMuted' in patch) this.syncInput();
} }
// ─── Devices ──────────────────────────────────────────────────────────────── // ─── Devices ────────────────────────────────────────────────────────────────
@@ -139,8 +152,18 @@ class VoiceEngine {
if (this.ctx) return; if (this.ctx) return;
this.error = ''; this.error = '';
try { try {
if (typeof AudioEncoder === 'undefined' || !(await AudioEncoder.isConfigSupported(this.encoderConfig())).supported) { const builtIn = typeof AudioEncoder !== 'undefined' && typeof AudioDecoder !== 'undefined' &&
throw new Error('This system cannot encode Opus audio'); (await AudioEncoder.isConfigSupported(this.encoderConfig()).catch(() => ({ supported: false }))).supported;
// mumh5.forceWasmOpus in localStorage: use the built-in codec regardless, for testing it
let forced = false;
try { forced = localStorage.getItem('mumh5.forceWasmOpus') === '1'; } catch { /* storage unavailable */ }
if (!builtIn || forced) {
// Firefox on Android, for one, has no audio codec for pages; bring our own
try {
this.opus = await loadOpus();
} catch (e) {
throw new Error(`This browser has no Opus audio codec, and the built-in one could not be loaded (${(e as Error).message})`);
}
} }
const ctx = new AudioContext({ sampleRate: SAMPLE_RATE, latencyHint: 'interactive' }); const ctx = new AudioContext({ sampleRate: SAMPLE_RATE, latencyHint: 'interactive' });
this.ctx = ctx; this.ctx = ctx;
@@ -160,7 +183,7 @@ class VoiceEngine {
this.inputGainNode.connect(this.capture); this.inputGainNode.connect(this.capture);
await this.applyOutputDevice(); await this.applyOutputDevice();
await this.restartInput(); await this.syncInput();
this.configureEncoder(); this.configureEncoder();
await ctx.resume(); await ctx.resume();
this.levelTimer = setInterval(() => { this.level = this.levelRaw; }, 50); this.levelTimer = setInterval(() => { this.level = this.levelRaw; }, 50);
@@ -178,6 +201,7 @@ class VoiceEngine {
this.levelTimer = null; this.levelTimer = null;
this.stream?.getTracks().forEach(t => t.stop()); this.stream?.getTracks().forEach(t => t.stop());
this.stream = null; this.stream = null;
this.micOpen = false;
for (const d of this.decoders.values()) { try { d.decoder.close(); } catch { /* closed */ } } for (const d of this.decoders.values()) { try { d.decoder.close(); } catch { /* closed */ } }
this.decoders.clear(); this.decoders.clear();
try { this.encoder?.close(); } catch { /* closed */ } try { this.encoder?.close(); } catch { /* closed */ }
@@ -191,13 +215,39 @@ class VoiceEngine {
this.level = -100; this.level = -100;
} }
// The microphone is not needed while we cannot talk anyway (muted or deafened, by us or the server)
private micWanted(): boolean {
if (!this.settings.releaseMicMuted || this.testing) return true;
const self = this.client?.self;
return !self || !(self.selfMute || self.mute || self.suppress || self.selfDeaf || self.deaf);
}
// Opens or gives back the microphone as the mute state asks. restart: open it anew (settings changed).
private async syncInput(restart = false): Promise<void> {
if (!this.ctx) return;
const wanted = this.micWanted();
if (wanted && (restart || !this.stream)) return this.restartInput();
if (!wanted && this.stream) {
this.endTransmission();
this.source?.disconnect();
this.source = null;
this.stream.getTracks().forEach(t => t.stop());
this.stream = null;
this.micOpen = false;
this.levelRaw = -100;
}
}
private async restartInput(): Promise<void> { private async restartInput(): Promise<void> {
if (!this.ctx || !this.inputGainNode) return; if (!this.ctx || !this.inputGainNode) return;
this.source?.disconnect(); this.source?.disconnect();
this.stream?.getTracks().forEach(t => t.stop()); this.stream?.getTracks().forEach(t => t.stop());
this.stream = null;
this.micOpen = false;
const s = this.settings; const s = this.settings;
let stream: MediaStream;
try { try {
this.stream = await navigator.mediaDevices.getUserMedia({ stream = await navigator.mediaDevices.getUserMedia({
audio: { audio: {
deviceId: s.inputDevice && s.inputDevice !== 'default' ? { exact: s.inputDevice } : undefined, deviceId: s.inputDevice && s.inputDevice !== 'default' ? { exact: s.inputDevice } : undefined,
echoCancellation: s.echoCancellation, echoCancellation: s.echoCancellation,
@@ -211,6 +261,10 @@ class VoiceEngine {
this.error = `Microphone unavailable: ${(e as Error).message}`; this.error = `Microphone unavailable: ${(e as Error).message}`;
return; return;
} }
// Muted again, or opened a second time, while the browser was getting the microphone
if (!this.ctx || this.stream || !this.micWanted()) return stream.getTracks().forEach(t => t.stop());
this.stream = stream;
this.micOpen = true;
this.error = ''; this.error = '';
this.source = this.ctx.createMediaStreamSource(this.stream); this.source = this.ctx.createMediaStreamSource(this.stream);
this.source.connect(this.inputGainNode); this.source.connect(this.inputGainNode);
@@ -235,7 +289,8 @@ class VoiceEngine {
this.stats = { sent: 0, received: 0, decoded: 0, lost: 0 }; this.stats = { sent: 0, received: 0, decoded: 0, lost: 0 };
this.unsubscribe = [ this.unsubscribe = [
client.on('voice', raw => { if (this.client === client) this.receive(raw); }), client.on('voice', raw => { if (this.client === client) this.receive(raw); }),
client.on('userRemove', u => this.dropSession(u.session)) client.on('userRemove', u => this.dropSession(u.session)),
client.on('user', u => { if (this.client === client && u.session === client.session) this.syncInput(); })
]; ];
this.limitBitrate(); this.limitBitrate();
this.start(); this.start();
@@ -281,11 +336,16 @@ class VoiceEngine {
this.effectiveBitrate = this.allowedBitrate(); this.effectiveBitrate = this.allowedBitrate();
if (!this.ctx) return; if (!this.ctx) return;
try { this.encoder?.close(); } catch { /* closed */ } try { this.encoder?.close(); } catch { /* closed */ }
this.encoder = new AudioEncoder({ if (this.opus) {
this.encoder = new WasmEncoder(this.opus, this.effectiveBitrate, this.settings.frameMs, packet => this.onPacket(packet));
return;
}
const encoder = new AudioEncoder({
output: chunk => this.onEncoded(chunk), output: chunk => this.onEncoded(chunk),
error: e => { this.error = `Encoder: ${e.message}`; } error: e => { this.error = `Encoder: ${e.message}`; }
}); });
this.encoder.configure(this.encoderConfig()); encoder.configure(this.encoderConfig());
this.encoder = encoder;
} }
private canTalk(): boolean { private canTalk(): boolean {
@@ -320,18 +380,24 @@ class VoiceEngine {
if (!wanted && this.open) { this.endTransmission(); return; } if (!wanted && this.open) { this.endTransmission(); return; }
if (!this.open) return; if (!this.open) return;
const encoder = this.encoder!;
if (encoder instanceof WasmEncoder) return encoder.push(frame);
const data = new AudioData({ const data = new AudioData({
format: 'f32-planar', sampleRate: SAMPLE_RATE, numberOfFrames: FRAME, numberOfChannels: 1, format: 'f32-planar', sampleRate: SAMPLE_RATE, numberOfFrames: FRAME, numberOfChannels: 1,
timestamp: this.sampleTime, data: frame timestamp: this.sampleTime, data: frame
}); });
this.sampleTime += 10000; this.sampleTime += 10000;
this.encoder!.encode(data); encoder.encode(data);
data.close(); data.close();
} }
private onEncoded(chunk: EncodedAudioChunk): void { private onEncoded(chunk: EncodedAudioChunk): void {
const opus = new Uint8Array(chunk.byteLength); const opus = new Uint8Array(chunk.byteLength);
chunk.copyTo(opus); chunk.copyTo(opus);
this.onPacket(opus);
}
private onPacket(opus: Uint8Array): void {
if (this.stopping) { this.held.push(opus); return; } if (this.stopping) { this.held.push(opus); return; }
this.sendPacket(opus, false); this.sendPacket(opus, false);
} }
@@ -389,11 +455,15 @@ class VoiceEngine {
let d = this.decoders.get(p.session); let d = this.decoders.get(p.session);
if (!d) { if (!d) {
const session = p.session; const session = p.session;
const decoder = new AudioDecoder({ let decoder: AudioDecoder | WasmDecoder;
output: data => this.onDecoded(session, data), if (this.opus) decoder = new WasmDecoder(this.opus);
error: () => this.dropSession(session) else {
}); decoder = new AudioDecoder({
decoder.configure({ codec: 'opus', sampleRate: SAMPLE_RATE, numberOfChannels: 1 }); output: data => this.onDecoded(session, data),
error: () => this.dropSession(session)
});
decoder.configure({ codec: 'opus', sampleRate: SAMPLE_RATE, numberOfChannels: 1 });
}
d = { decoder, lastFrame: -1, timer: null }; d = { decoder, lastFrame: -1, timer: null };
this.decoders.set(session, d); this.decoders.set(session, d);
if (user) this.setSessionGain(session, this.settings.userVolumes[userKey(user)] ?? 1); if (user) this.setSessionGain(session, this.settings.userVolumes[userKey(user)] ?? 1);
@@ -402,7 +472,13 @@ class VoiceEngine {
d.lastFrame = p.frame; d.lastFrame = p.frame;
if (p.opus.length) { if (p.opus.length) {
d.decoder.decode(new EncodedAudioChunk({ type: 'key', timestamp: p.frame * 10000, data: p.opus })); if (d.decoder instanceof WasmDecoder) {
const pcm = d.decoder.decode(p.opus);
if (pcm) {
this.stats.decoded++;
this.playback?.port.postMessage({ session: p.session, pcm }, [pcm.buffer]);
}
} else d.decoder.decode(new EncodedAudioChunk({ type: 'key', timestamp: p.frame * 10000, data: p.opus }));
} }
if (!this.talking[p.session]) this.talking[p.session] = true; if (!this.talking[p.session]) this.talking[p.session] = true;
if (d.timer) clearTimeout(d.timer); if (d.timer) clearTimeout(d.timer);
@@ -455,6 +531,7 @@ class VoiceEngine {
setTesting(on: boolean): void { setTesting(on: boolean): void {
this.testing = on; this.testing = on;
this.syncInput();
if (on) this.start(); if (on) this.start();
else { else {
this.playback?.port.postMessage({ drop: -1 }); this.playback?.port.postMessage({ drop: -1 });
+4 -1
View File
@@ -21,4 +21,7 @@ function run<T>(mode: IDBTransactionMode, fn: (s: IDBObjectStore) => IDBRequest<
export const putBlob = (key: string, blob: Blob) => run('readwrite', s => s.put(blob, key)); export const putBlob = (key: string, blob: Blob) => run('readwrite', s => s.put(blob, key));
export const getBlob = (key: string) => run<Blob | undefined>('readonly', s => s.get(key)); export const getBlob = (key: string) => run<Blob | undefined>('readonly', s => s.get(key));
export const deleteBlob = (key: string) => run('readwrite', s => s.delete(key)); // Plain data that outgrows localStorage (text channel messages)
export const putData = (key: string, value: unknown) => run('readwrite', s => s.put(value, key));
export const getData = <T>(key: string) => run<T | undefined>('readonly', s => s.get(key));
export const deleteBlob =(key: string) => run('readwrite', s => s.delete(key));
+33
View File
@@ -0,0 +1,33 @@
import { store } from './settings.svelte.ts';
// Hints are the browser tooltips from title attributes. With the setting off, the title of
// whatever is under the pointer is set aside while it is hovered and put back afterwards, so
// the attributes stay in place for screen readers and everything else that reads them.
export function startHints(): void {
let held: { el: Element; title: string; labelled: boolean }[] = [];
const restore = () => {
for (const h of held) {
// Leave it alone if the app set a new title in the meantime
if (!h.el.hasAttribute('title')) h.el.setAttribute('title', h.title);
if (h.labelled) h.el.removeAttribute('aria-label');
}
held = [];
};
document.addEventListener('mouseover', e => {
restore();
if (store.settings.hints) return;
// A title on any ancestor would show too
for (let el = e.target as Element | null; el; el = el.parentElement) {
const title = el.getAttribute('title');
if (!title) continue;
// Keep the accessible name of icon buttons that are only named by their title
const labelled = !el.hasAttribute('aria-label') && !el.textContent?.trim();
if (labelled) el.setAttribute('aria-label', title);
el.removeAttribute('title');
held.push({ el, title, labelled });
}
}, true);
document.addEventListener('mouseleave', restore);
}
+13
View File
@@ -6,6 +6,14 @@ export const ICON_PATHS: Record<string, string> = {
'headphones-off': 'M3 14v-2a9 9 0 0 1 18 0v2M21 14v7h-4v-7zM3 14v7h4v-7zM3 3l18 18', 'headphones-off': 'M3 14v-2a9 9 0 0 1 18 0v2M21 14v7h-4v-7zM3 14v7h4v-7zM3 3l18 18',
settings: 'M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6', settings: 'M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6',
plus: 'M12 5v14M5 12h14', plus: 'M12 5v14M5 12h14',
switch: 'M4 8h13l-3-3M20 16H7l3 3',
camera: 'M2 6h14v12H2zM16 10l6-3v10l-6-3',
screen: 'M3 4h18v12H3zM8 20h8M12 16v4',
popout: 'M14 4h6v6M20 4l-9 9M18 14v6H4V6h6',
expand: 'M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5',
filter: 'M3 4h18l-7 8v7l-4 2v-9z',
info: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zM12 11v6M12 7h.01',
'arrow-right': 'M5 12h14M13 6l6 6-6 6',
paperclip: 'M21.4 11.1l-9.2 9.2a6 6 0 0 1-8.5-8.5l9.2-9.2a4 4 0 0 1 5.7 5.7l-9.2 9.2a2 2 0 0 1-2.8-2.8l8.5-8.5', paperclip: 'M21.4 11.1l-9.2 9.2a6 6 0 0 1-8.5-8.5l9.2-9.2a4 4 0 0 1 5.7 5.7l-9.2 9.2a2 2 0 0 1-2.8-2.8l8.5-8.5',
hash: 'M4 9h16M4 15h16M10 3L8 21M16 3l-2 18', hash: 'M4 9h16M4 15h16M10 3L8 21M16 3l-2 18',
channel: 'M11 5L6 9H2v6h4l5 4zM15.5 8.5a5 5 0 0 1 0 7', channel: 'M11 5L6 9H2v6h4l5 4zM15.5 8.5a5 5 0 0 1 0 7',
@@ -27,5 +35,10 @@ export const ICON_PATHS: Record<string, string> = {
link: 'M10 14a4 4 0 0 0 6 0l3-3a4 4 0 0 0-6-6l-1 1M14 10a4 4 0 0 0-6 0l-3 3a4 4 0 0 0 6 6l1-1', link: 'M10 14a4 4 0 0 0 6 0l3-3a4 4 0 0 0-6-6l-1 1M14 10a4 4 0 0 0-6 0l-3 3a4 4 0 0 0 6 6l1-1',
image: 'M3 3h18v18H3zM8.5 10a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zM21 15l-5-5L5 21', image: 'M3 3h18v18H3zM8.5 10a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zM21 15l-5-5L5 21',
code: 'M16 18l6-6-6-6M8 6l-6 6 6 6', code: 'M16 18l6-6-6-6M8 6l-6 6 6 6',
more: 'M4 11h2v2H4zM11 11h2v2h-2zM18 11h2v2h-2z',
back: 'M19 12H5M11 6l-6 6 6 6',
search: 'M10.5 3a7.5 7.5 0 1 0 0 15 7.5 7.5 0 0 0 0-15zM16 16l5 5',
user: 'M12 3a4 4 0 1 0 0 8 4 4 0 0 0 0-8zM4 21v-1a6 6 0 0 1 6-6h4a6 6 0 0 1 6 6v1',
rooms: 'M3 3h8v8H3zM13 3h8v8h-8zM3 13h8v8H3zM13 13h8v8h-8z',
eraser: 'M20 20H7L3 16l10-10 7 7-6.5 7M6 11l7 7' eraser: 'M20 20H7L3 16l10-10 7 7-6.5 7M6 11l7 7'
}; };
+44 -14
View File
@@ -1,8 +1,27 @@
import type { Transport, ServerCertInfo, CertDetails, UdpChannel } from '../core/transport.ts'; import type { Transport, ServerCertInfo, CertDetails, UdpChannel } from '../core/transport.ts';
import { webApi, openWebTransport } from './web.svelte.ts';
interface NativeApi { // What both the desktop app and the browser build (through the web proxy) provide
export interface PlatformApi {
platformInfo(): Promise<{ os: string; osVersion: string }>; platformInfo(): Promise<{ os: string; osVersion: string }>;
describeCerts(ders: Uint8Array[]): Promise<CertDetails[]>; describeCerts(ders: Uint8Array[]): Promise<CertDetails[]>;
identities: {
list(): Promise<{ identities: IdentitySummary[]; setupDone: boolean }>;
create(name: string, email: string): Promise<IdentitySummary>;
importP12(bytes: Uint8Array, password: string, name: string): Promise<IdentitySummary>;
findMumble(): Promise<{ name: string } | null>;
importMumble(name: string): Promise<IdentitySummary>;
// Where the backup was saved, or null when cancelled
exportP12(id: string, password: string): Promise<string | null>;
setDefault(id: string): Promise<void>;
rename(id: string, name: string): Promise<void>;
remove(id: string): Promise<void>;
finishSetup(): Promise<void>;
};
}
// The desktop app's preload API
interface NativeApi extends PlatformApi {
updateTray(state: { icon: string; tooltip: string; connected: boolean; server: string; muted: boolean; deafened: boolean; mode: string }): void; updateTray(state: { icon: string; tooltip: string; connected: boolean; server: string; muted: boolean; deafened: boolean; mode: string }): void;
onTrayAction(fn: (action: string) => void): void; onTrayAction(fn: (action: string) => void): void;
linkPreview(url: string): Promise<LinkPreviewData | null>; linkPreview(url: string): Promise<LinkPreviewData | null>;
@@ -10,18 +29,17 @@ interface NativeApi {
pingServer(host: string, port: number): Promise<PingResult | null>; pingServer(host: string, port: number): Promise<PingResult | null>;
onContextMenu(fn: (params: ContextParams) => void): void; onContextMenu(fn: (params: ContextParams) => void): void;
editAction(action: string, arg?: unknown): void; editAction(action: string, arg?: unknown): void;
identities: { // Screens and windows that can be shared; pick one before calling getDisplayMedia
list(): Promise<{ identities: IdentitySummary[]; setupDone: boolean }>; // chosen: the system's own picker already asked (Wayland), so `sources` is the one answer
create(name: string, email: string): Promise<IdentitySummary>; // Thumbnails are slower to get and can fail; ask for the names first
importP12(bytes: Uint8Array, password: string, name: string): Promise<IdentitySummary>; screenSources(thumbnails: boolean): Promise<{ sources: ScreenSource[]; chosen: boolean }>;
findMumble(): Promise<{ name: string } | null>; pickScreenSource(id: string): void;
importMumble(name: string): Promise<IdentitySummary>; // portal: the system shows its own source picker (Wayland). audio: how other programs' sound can be captured.
exportP12(id: string, password: string): Promise<string | null>; shareInfo(): Promise<{ portal: boolean; audio: 'pipewire' | 'loopback' | 'none'; micLabel: string }>;
setDefault(id: string): Promise<void>; shareAudioApps(): Promise<string[]>;
rename(id: string, name: string): Promise<void>; // Linux: route one program, or everything but this app, into a virtual microphone named micLabel
remove(id: string): Promise<void>; shareAudioStart(target: { kind: 'all' } | { kind: 'app'; name: string }): Promise<void>;
finishSetup(): Promise<void>; shareAudioStop(): Promise<void>;
};
open(connId: string, host: string, port: number, identityId?: string): Promise<void>; open(connId: string, host: string, port: number, identityId?: string): Promise<void>;
send(connId: string, bytes: Uint8Array): void; send(connId: string, bytes: Uint8Array): void;
close(connId: string): void; close(connId: string): void;
@@ -46,6 +64,7 @@ export interface ContextParams {
editFlags: { canUndo: boolean; canRedo: boolean; canCut: boolean; canCopy: boolean; canPaste: boolean; canSelectAll: boolean }; editFlags: { canUndo: boolean; canRedo: boolean; canCut: boolean; canCopy: boolean; canPaste: boolean; canSelectAll: boolean };
} }
export interface ScreenSource { id: string; name: string; thumbnail: string }
export interface PublicServer { name: string; host: string; port: number; url: string; country: string; countryCode: string; continent: string; ca: boolean } export interface PublicServer { name: string; host: string; port: number; url: string; country: string; countryCode: string; continent: string; ca: boolean }
export interface PingResult { ping: number; users: number; maxUsers: number; bandwidth: number; version: string } export interface PingResult { ping: number; users: number; maxUsers: number; bandwidth: number; version: string }
@@ -71,7 +90,18 @@ declare global {
interface Window { mumh5Native?: NativeApi } interface Window { mumh5Native?: NativeApi }
} }
export const native: NativeApi | null = typeof window !== 'undefined' ? window.mumh5Native ?? null : null; // Desktop-only features (tray, UDP voice, public list, direct link previews) check `desktop`
export const desktop: NativeApi | null = typeof window !== 'undefined' ? window.mumh5Native ?? null : null;
// The browser build (vite --mode web) talks to the web proxy instead
export const isWeb = !desktop && import.meta.env?.MODE === 'web';
export const native: PlatformApi | null = desktop ?? (isWeb ? webApi : null);
// A connection to a Mumble server: TLS in the desktop app, the proxy's WebSocket in the browser
export async function openTransport(host: string, port: number, identityId?: string): Promise<(Transport & { secure: Promise<ServerCertInfo> }) | null> {
if (desktop) return new ElectronTransport(desktop, host, port, identityId);
if (isWeb) return openWebTransport(host, port, identityId);
return null;
}
// Transport backed by a TLS socket in the Electron main process. // Transport backed by a TLS socket in the Electron main process.
// `secure` resolves once the TLS handshake is done, before any Mumble data is sent, // `secure` resolves once the TLS handshake is done, before any Mumble data is sent,
+14 -4
View File
@@ -1,18 +1,28 @@
import { native, type LinkPreviewData } from './native.ts'; import { desktop, type LinkPreviewData } from './native.ts';
import { store } from './settings.svelte.ts'; import { store } from './settings.svelte.ts';
// One request per URL and mode per app run // One request per URL and mode per app run
const cache = new Map<string, Promise<LinkPreviewData | null>>(); const cache = new Map<string, Promise<LinkPreviewData | null>>();
export function linkPreview(url: string): Promise<LinkPreviewData | null> { // The mode in effect: previews through the upload host need its key, without one they are off.
// Never falls back to fetching directly, that would show linked sites the user's IP.
// Fetching directly needs the desktop app (a web page cannot read other sites).
export function previewMode(): 'host' | 'direct' | 'off' {
const { linkPreviews, uploadHost, uploadKey } = store.settings; const { linkPreviews, uploadHost, uploadKey } = store.settings;
const mode = linkPreviews === 'host' && !(uploadHost && uploadKey) ? 'off' : linkPreviews; if (linkPreviews === 'host' && !(uploadHost && uploadKey)) return 'off';
if (linkPreviews === 'direct' && !desktop) return 'off';
return linkPreviews;
}
export function linkPreview(url: string): Promise<LinkPreviewData | null> {
const { uploadHost, uploadKey } = store.settings;
const mode = previewMode();
if (mode === 'off') return Promise.resolve(null); if (mode === 'off') return Promise.resolve(null);
const id = `${mode}|${url}`; const id = `${mode}|${url}`;
let p = cache.get(id); let p = cache.get(id);
if (!p) { if (!p) {
p = mode === 'direct' p = mode === 'direct'
? (native?.linkPreview(url) ?? Promise.resolve(null)).catch(() => null) ? (desktop?.linkPreview(url) ?? Promise.resolve(null)).catch(() => null)
: fetch(`${uploadHost.replace(/\/+$/, '')}/api/chat/preview?url=${encodeURIComponent(url)}`, { headers: { 'X-API-Key': uploadKey } }) : fetch(`${uploadHost.replace(/\/+$/, '')}/api/chat/preview?url=${encodeURIComponent(url)}`, { headers: { 'X-API-Key': uploadKey } })
.then(r => r.json()) .then(r => r.json())
.then(d => (d?.success ? d as LinkPreviewData : null)) .then(d => (d?.success ? d as LinkPreviewData : null))
+115 -14
View File
@@ -1,6 +1,9 @@
import { createCodec } from '../core/proto.ts'; import { createCodec } from '../core/proto.ts';
import { MumbleClient, describeDenial, type Channel, type User, type TextMessage } from '../core/client.ts'; import { MumbleClient, describeDenial, type Channel, type User, type TextMessage } from '../core/client.ts';
import { native, ElectronTransport } from './native.ts'; import { native, openTransport } from './native.ts';
import { share, type ShareKind } from './share.svelte.ts';
import { textChannels } from './text-channels.ts';
import { MAX_HTML, type TextMsg } from '../core/text-signal.ts';
import type { ServerCertInfo } from '../core/transport.ts'; import type { ServerCertInfo } from '../core/transport.ts';
import { store, type SavedServer } from './settings.svelte.ts'; import { store, type SavedServer } from './settings.svelte.ts';
import { renderIncoming, textToHtml, escapeHtml, formatBytes, type Embed } from './html.ts'; import { renderIncoming, textToHtml, escapeHtml, formatBytes, type Embed } from './html.ts';
@@ -60,6 +63,8 @@ export class Session {
uploads = $state<UploadJob[]>([]); uploads = $state<UploadJob[]>([]);
certPrompt = $state<CertPrompt | null>(null); certPrompt = $state<CertPrompt | null>(null);
rtt = $state(0); rtt = $state(0);
// Who in our channel is sharing their screen, by session (mumh5 clients only)
sharing = $state<Record<number, ShareKind>>({});
// Why the last connection ended, shown with a Reconnect button (null after a normal disconnect) // Why the last connection ended, shown with a Reconnect button (null after a normal disconnect)
disconnectInfo = $state<{ kind: 'kicked' | 'banned' | 'lost' | 'rejected'; by: string; reason: string } | null>(null); disconnectInfo = $state<{ kind: 'kicked' | 'banned' | 'lost' | 'rejected'; by: string; reason: string } | null>(null);
private removedBy: { kind: 'kicked' | 'banned'; by: string; reason: string } | null = null; private removedBy: { kind: 'kicked' | 'banned'; by: string; reason: string } | null = null;
@@ -155,12 +160,21 @@ export class Session {
this.expanded[id] = !this.isExpanded(id); this.expanded[id] = !this.isExpanded(id);
} }
// Collapsing goes back to the default: channels with people in them stay open
setAllExpanded(open: boolean): void { setAllExpanded(open: boolean): void {
const all: Record<number, boolean> = {}; const all: Record<number, boolean> = {};
for (const id of this.client?.channels.keys() ?? []) all[id] = open; if (open) for (const id of this.client?.channels.keys() ?? []) all[id] = true;
this.expanded = all; this.expanded = all;
} }
// What the server told us about itself, for the server information dialog
get serverInfo() {
void this.tick;
const c = this.client;
if (!c) return null;
return { version: c.serverVersion, maxBandwidth: c.maxBandwidth, config: { ...c.config }, users: c.users.size, channels: c.channels.size };
}
// How voice travels right now // How voice travels right now
get voiceTransport(): { udp: boolean; rtt: number } { get voiceTransport(): { udp: boolean; rtt: number } {
void this.tick; void this.tick;
@@ -176,6 +190,12 @@ export class Session {
} }
// Whether a channel lies somewhere below another (a channel cannot move into its own subtree) // Whether a channel lies somewhere below another (a channel cannot move into its own subtree)
// A channel that is read and written without joining it (mumh5 clients only)
isText(channelId: number): boolean {
void this.tick;
return textChannels.isText(this, channelId);
}
isInside(channelId: number, ancestor: number): boolean { isInside(channelId: number, ancestor: number): boolean {
let c = this.client?.channels.get(channelId); let c = this.client?.channels.get(channelId);
while (c && c.parent != null) { while (c && c.parent != null) {
@@ -216,13 +236,16 @@ export class Session {
this.status = 'connecting'; this.status = 'connecting';
if (!native) { if (!native) {
this.fail('Connecting needs the desktop app for now. The web version will connect through a proxy.'); this.fail('Connecting needs the desktop app or the web build served by a mumh5 proxy.');
return; return;
} }
const transport = new ElectronTransport(native, server.host, server.port, server.identityId); let transport;
let info; let info;
try { try {
transport = (await openTransport(server.host, server.port, server.identityId))!;
// Superseded while the identity was being prepared
if (attempt !== this.attempt) return transport.close();
info = await transport.secure; info = await transport.secure;
} catch (e) { } catch (e) {
if (attempt === this.attempt) this.fail((e as Error).message); if (attempt === this.attempt) this.fail((e as Error).message);
@@ -276,6 +299,9 @@ export class Session {
const client = this.client; const client = this.client;
this.client = null; this.client = null;
client?.disconnect(); client?.disconnect();
share.closed(this);
textChannels.closed(this);
this.sharing = {};
sessions.onClosed(this); sessions.onClosed(this);
this.status = 'idle'; this.status = 'idle';
this.tick++; this.tick++;
@@ -300,10 +326,18 @@ export class Session {
// What we may do per channel, for menus (not rate limited by the server) // What we may do per channel, for menus (not rate limited by the server)
for (const id of client.channels.keys()) client.requestPermissions(id); for (const id of client.channels.keys()) client.requestPermissions(id);
if (client.welcomeText) this.system(this.view, client.welcomeText, true); if (client.welcomeText) this.system(this.view, client.welcomeText, true);
share.entered(this);
textChannels.entered(this);
bump(); bump();
}); });
client.on('pluginData', (sender, dataId, data) => {
if (!live()) return;
share.receive(this, sender, dataId, data);
textChannels.receive(this, sender, dataId, data);
});
client.on('channel', ch => { client.on('channel', ch => {
if (live() && client.synced && !client.permissions.has(ch.id)) client.requestPermissions(ch.id); if (live() && client.synced && !client.permissions.has(ch.id)) client.requestPermissions(ch.id);
if (live() && client.synced) textChannels.channelChanged(this, ch.id);
bump(); bump();
}); });
client.on('permissions', () => { client.on('permissions', () => {
@@ -315,6 +349,11 @@ export class Session {
client.on('channelRemove', id => { client.on('channelRemove', id => {
if (!live()) return; if (!live()) return;
if (this.panel.kind === 'channel' && this.panel.id === id) this.panel = { kind: 'members' }; if (this.panel.kind === 'channel' && this.panel.id === id) this.panel = { kind: 'members' };
textChannels.channelRemoved(this, id);
// The chat on screen is gone: back to the channel we are in
if (this.view === `ch:${id}`) this.view = `ch:${client.self?.channelId ?? 0}`;
delete this.logs[`ch:${id}`];
delete this.unread[`ch:${id}`];
bump(); bump();
}); });
client.on('ping', rtt => { if (live()) this.rtt = rtt; }); client.on('ping', rtt => { if (live()) this.rtt = rtt; });
@@ -325,6 +364,10 @@ export class Session {
const self = client.self; const self = client.self;
const before = this.lastChannel.get(user.session); const before = this.lastChannel.get(user.session);
this.lastChannel.set(user.session, user.channelId); this.lastChannel.set(user.session, user.channelId);
if (client.synced && self && (isNew || changed.includes('channelId'))) {
if (user.session === self.session) share.entered(this);
else share.userMoved(this, user);
}
if (client.synced && self && this.isVoice()) this.userSound(user.session === self.session, user, changed, actor, isNew, before, self.channelId); if (client.synced && self && this.isVoice()) this.userSound(user.session === self.session, user, changed, actor, isNew, before, self.channelId);
if (client.synced && self) { if (client.synced && self) {
if (isNew) { if (isNew) {
@@ -354,6 +397,8 @@ export class Session {
this.removedBy = { kind: ban ? 'banned' : 'kicked', by, reason }; this.removedBy = { kind: ban ? 'banned' : 'kicked', by, reason };
} }
const self = client.self; const self = client.self;
share.userMoved(this, user, true);
textChannels.userLeft(this, user.session);
if (self && user.session !== self.session && this.isVoice() && user.channelId === self.channelId) { if (self && user.session !== self.session && this.isVoice() && user.channelId === self.channelId) {
const byOther = actor != null && actor !== user.session; const byOther = actor != null && actor !== user.session;
sounds.play(ban ? 'userBanned' : byOther ? 'userKicked' : 'userDisconnected'); sounds.play(ban ? 'userBanned' : byOther ? 'userKicked' : 'userDisconnected');
@@ -403,6 +448,7 @@ export class Session {
const kind = this.disconnectInfo?.kind; const kind = this.disconnectInfo?.kind;
sounds.play(kind === 'kicked' ? 'kicked' : kind === 'banned' ? 'banned' : kind === 'lost' ? 'connectionLost' : kind === 'rejected' ? 'error' : 'disconnected'); sounds.play(kind === 'kicked' ? 'kicked' : kind === 'banned' ? 'banned' : kind === 'lost' ? 'connectionLost' : kind === 'rejected' ? 'error' : 'disconnected');
this.client = null; this.client = null;
textChannels.closed(this);
sessions.onClosed(this); sessions.onClosed(this);
this.status = 'idle'; this.status = 'idle';
this.error = reason === 'Disconnected' ? '' : reason; this.error = reason === 'Disconnected' ? '' : reason;
@@ -495,11 +541,27 @@ export class Session {
// ─── Chat ─────────────────────────────────────────────────────────────────── // ─── Chat ───────────────────────────────────────────────────────────────────
private push(key: string, entry: Omit<ChatEntry, 'id' | 'time'>): void { // time: for messages from earlier, which go where they belong in the log. quiet: not counted as unread.
private push(key: string, entry: Omit<ChatEntry, 'id' | 'time'>, opts: { time?: number; quiet?: boolean } = {}): void {
// Assign first, then push through the stored proxy; pushing onto the raw array would not be reactive // Assign first, then push through the stored proxy; pushing onto the raw array would not be reactive
if (!this.logs[key]) this.logs[key] = []; if (!this.logs[key]) this.logs[key] = [];
this.logs[key].push({ ...entry, id: nextId++, time: Date.now() }); const log = this.logs[key];
if (sessions.active !== this || !this.visibleKeys().includes(key)) this.unread[key] = (this.unread[key] ?? 0) + 1; const time = opts.time ?? Date.now();
let at = log.length;
while (at > 0 && log[at - 1].time > time) at--;
log.splice(at, 0, { ...entry, id: nextId++, time });
if (!opts.quiet && (sessions.active !== this || !this.visibleKeys().includes(key))) this.unread[key] = (this.unread[key] ?? 0) + 1;
}
// A text channel message: live, kept from earlier, or handed over by another client
addText(m: TextMsg, self: boolean, quiet: boolean, sender: number | null = null): void {
const client = this.client;
if (!client) return;
const online = sender != null ? client.users.get(sender) : self ? client.self : m.k ? [...client.users.values()].find(u => u.hash === m.k) : undefined;
this.push(`ch:${m.c}`, {
kind: 'text', author: online ? store.displayName(online) : m.n, authorSession: online?.session ?? null, self,
...renderIncoming(m.h, store.trustedHosts)
}, { time: m.t, quiet });
} }
private system(key: string, html: string, trusted = false): void { private system(key: string, html: string, trusted = false): void {
@@ -568,6 +630,16 @@ export class Session {
sendHtml(html: string, key: string = this.view): boolean { sendHtml(html: string, key: string = this.view): boolean {
const client = this.client; const client = this.client;
if (!client || this.status !== 'connected') return false; if (!client || this.status !== 'connected') return false;
if (key.startsWith('ch:') && this.isText(Number(key.slice(3)))) {
if (html.length > MAX_HTML) {
this.system(key, `Message too long for a text channel (${html.length} / ${MAX_HTML} characters)`);
return false;
}
// Shown in the log by the text channel code once it is on its way
textChannels.post(this, Number(key.slice(3)), html).then(problem => { if (problem) this.system(key, escapeHtml(problem)); });
sounds.play('messageSent');
return true;
}
const limit = /<img/i.test(html) ? client.config.imageMessageLength : client.config.messageLength; const limit = /<img/i.test(html) ? client.config.imageMessageLength : client.config.messageLength;
if (limit && html.length > limit) { if (limit && html.length > limit) {
this.system(key, `Message too long for this server (${html.length} / ${limit} characters)`); this.system(key, `Message too long for this server (${html.length} / ${limit} characters)`);
@@ -599,6 +671,7 @@ export class Session {
const problem = checkFile(info, f); const problem = checkFile(info, f);
if (problem) this.system(key, escapeHtml(problem)); if (problem) this.system(key, escapeHtml(problem));
} }
const textKey = key.startsWith('ch:') && this.isText(Number(key.slice(3)));
for (const file of files.filter(f => !checkFile(info, f))) { for (const file of files.filter(f => !checkFile(info, f))) {
const job = { id: nextId++, key, name: file.name, progress: 0, error: '' }; const job = { id: nextId++, key, name: file.name, progress: 0, error: '' };
this.uploads.push(job); this.uploads.push(job);
@@ -609,7 +682,8 @@ export class Session {
const link = `<a href="${escapeHtml(res.url)}">${escapeHtml(res.name)}</a> (${formatBytes(res.size)})`; const link = `<a href="${escapeHtml(res.url)}">${escapeHtml(res.name)}</a> (${formatBytes(res.size)})`;
let html = link; let html = link;
const cfg = this.client?.config; const cfg = this.client?.config;
if (sendThumbnails && cfg?.allowHtml !== false) { // Text channels only reach mumh5, which shows the file itself instead of a thumbnail
if (sendThumbnails && cfg?.allowHtml !== false && !textKey) {
const budget = Math.min(cfg?.imageMessageLength || 131072, 100000) - link.length - 64; const budget = Math.min(cfg?.imageMessageLength || 131072, 100000) - link.length - 64;
const thumb = await makeThumbnail(file, budget); const thumb = await makeThumbnail(file, budget);
if (thumb) html += `<br/><img src="${thumb}"/>`; if (thumb) html += `<br/><img src="${thumb}"/>`;
@@ -630,17 +704,18 @@ export class Session {
// scaled to the server's image limit. Other files need an upload host. // scaled to the server's image limit. Other files need an upload host.
private async sendInlineImages(files: File[], key: string): Promise<void> { private async sendInlineImages(files: File[], key: string): Promise<void> {
const cfg = this.client?.config; const cfg = this.client?.config;
const textKey = key.startsWith('ch:') && this.isText(Number(key.slice(3)));
for (const file of files) { for (const file of files) {
if (!file.type.startsWith('image/')) { if (!file.type.startsWith('image/')) {
this.system(key, `${escapeHtml(file.name)}: sending files needs file hosting. Set up an upload host in Settings; without one only images can be sent.`); this.system(key, `${escapeHtml(file.name)}: sending files needs file hosting. Set up an upload host in Settings; without one only images can be sent.`);
continue; continue;
} }
if (cfg?.allowHtml === false) { if (cfg?.allowHtml === false && !textKey) {
this.system(key, 'This server does not allow images in messages.'); this.system(key, 'This server does not allow images in messages.');
return; return;
} }
// 0 means no limit; stay reasonable anyway // 0 means no limit; stay reasonable anyway
const limit = cfg?.imageMessageLength || 1024 * 1024; const limit = textKey ? MAX_HTML : cfg?.imageMessageLength || 1024 * 1024;
const uri = await makeInlineImage(file, limit - 64); const uri = await makeInlineImage(file, limit - 64);
if (!uri) { if (!uri) {
this.system(key, `${escapeHtml(file.name)}: could not make this image small enough for the server (limit ${formatBytes(limit)})`); this.system(key, `${escapeHtml(file.name)}: could not make this image small enough for the server (limit ${formatBytes(limit)})`);
@@ -663,8 +738,23 @@ export class Session {
// ─── Voice state and moderation ───────────────────────────────────────────── // ─── Voice state and moderation ─────────────────────────────────────────────
setSelfMute(v: boolean) { this.client?.setSelfMute(v); } setSharing(session: number, kind: ShareKind | false): void {
setSelfDeaf(v: boolean) { this.client?.setSelfDeaf(v); } if (kind) this.sharing[session] = kind;
else delete this.sharing[session];
}
clearSharing(): void { this.sharing = {}; }
// 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); } kick(session: number, reason: string, ban = false) { this.client?.kick(session, reason, ban); }
setServerMute(session: number, v: boolean) { this.client?.setUserState(session, { mute: v }); } setServerMute(session: number, v: boolean) { this.client?.setUserState(session, { mute: v }); }
@@ -683,6 +773,10 @@ class Sessions {
activeId = $state<string | null>(null); activeId = $state<string | null>(null);
voiceId = $state<string | null>(null); voiceId = $state<string | null>(null);
private idle = new Session(); private idle = new Session();
// Self mute/deaf as the user last chose it, like the desktop client: shown and toggled
// while disconnected and applied to servers when they connect
mute = $state(false);
deaf = $state(false);
// Self mute/deaf before we deafened a background server, to restore later // Self mute/deaf before we deafened a background server, to restore later
private saved = new Map<Session, { mute: boolean; deaf: boolean }>(); private saved = new Map<Session, { mute: boolean; deaf: boolean }>();
@@ -725,8 +819,15 @@ class Sessions {
} }
onSynced(s: Session): void { onSynced(s: Session): void {
if (!this.voiceId || !this.voiceSession?.client) this.setVoice(s, true); const want = { mute: this.mute, deaf: this.deaf };
else this.toBackground(s); if (!this.voiceId || !this.voiceSession?.client) {
this.setVoice(s, true);
if (want.mute) s.client?.send('UserState', { self_mute: true, self_deaf: want.deaf });
} else {
this.toBackground(s);
// A fresh login is unmuted; restore the user's choice when voice comes here
if (this.saved.has(s)) this.saved.set(s, want);
}
} }
onClosed(s: Session): void { onClosed(s: Session): void {
+43 -9
View File
@@ -1,4 +1,5 @@
import { load, loadList, save } from './storage.ts'; import { load, loadList, save } from './storage.ts';
import { GENERATED_THEMES } from './themes-generated.ts';
export interface SavedServer { export interface SavedServer {
id: string; id: string;
@@ -24,19 +25,36 @@ export interface Settings {
theme: ThemeId; theme: ThemeId;
publicListUrl: string; // where the public server list comes from publicListUrl: string; // where the public server list comes from
arrangement: Arrangement; arrangement: Arrangement;
// standard: the channel tree in one of the layouts above. rooms: one screen at a time, made for phones.
interface: 'standard' | 'rooms';
shareQuality: 'low' | 'medium' | 'high'; // bitrate and frame rate of our own stream
autoCameras: boolean; // show other people's cameras as they come on
shareVolume: number; // volume of streams we watch, 0 to 1
relayServer: string; // a mumh5 web proxy whose STUN and relay to use for screen sharing
stunServer: string; // helps screen sharing connect through routers; empty contacts nobody
shareNoticeSeen: boolean; // the note that screen sharing reveals IP addresses was confirmed
hints: boolean; // tooltips (title attributes) when resting the pointer on buttons
} }
// Window layouts; classic and stacked follow the Mumble desktop client's layouts of the same name // Window layouts; classic and stacked follow the Mumble desktop client's layouts of the same name
export type Arrangement = 'side' | 'classic' | 'stacked'; export type Arrangement = 'side' | 'classic' | 'stacked';
export type ThemeId = 'dark' | 'light' | 'midnight' | 'frost' | 'win95'; export type ThemeId = 'dark' | 'light' | 'midnight' | 'frost' | 'forest' | 'ember' | 'ocean' | 'plum' | 'paper' | 'contrast' | 'win95' | typeof GENERATED_THEMES[number]['id'];
export const THEMES: { id: ThemeId; name: string; note: string }[] = [ // Hand-made schemes first, then the generated ones (scripts/gen-themes.mjs), dark before light
{ id: 'dark', name: 'Dark', note: 'The default' }, export const THEMES: { id: ThemeId; name: string; note: string; light: boolean }[] = [
{ id: 'light', name: 'Light', note: 'For bright rooms' }, { id: 'dark', name: 'Dark', note: 'The default', light: false },
{ id: 'midnight', name: 'Midnight', note: 'True black, red accents' }, { id: 'light', name: 'Light', note: 'For bright rooms', light: true },
{ id: 'frost', name: 'Frost', note: 'Cool blue-grey' }, { id: 'midnight', name: 'Midnight', note: 'True black, red accents', light: false },
{ id: 'win95', name: 'Windows 95', note: 'Start me up' } { id: 'frost', name: 'Frost', note: 'Cool blue-grey', light: false },
{ id: 'forest', name: 'Forest', note: 'Deep green, warm light', light: false },
{ id: 'ember', name: 'Ember', note: 'Warm dark, orange accents', light: false },
{ id: 'ocean', name: 'Ocean', note: 'Deep navy, cyan accents', light: false },
{ id: 'plum', name: 'Plum', note: 'Dark purple, pink accents', light: false },
{ id: 'paper', name: 'Paper', note: 'Warm light, easy on the eyes', light: true },
{ id: 'contrast', name: 'High contrast', note: 'Black and white, strong lines', light: false },
{ id: 'win95', name: 'Windows 95', note: 'Start me up', light: true },
...GENERATED_THEMES
]; ];
const defaults: Settings = { const defaults: Settings = {
@@ -46,11 +64,19 @@ const defaults: Settings = {
embedHosts: [], embedHosts: [],
sendThumbnails: true, sendThumbnails: true,
compact: false, compact: false,
youtube: 'click', youtube: 'off',
linkPreviews: 'host', linkPreviews: 'host',
theme: 'dark', theme: 'dark',
publicListUrl: 'https://publist.mumble.info/v1/list?version=1.5.0', publicListUrl: 'https://publist.mumble.info/v1/list?version=1.5.0',
arrangement: 'side' arrangement: 'side',
interface: 'standard',
shareQuality: 'medium',
autoCameras: true,
shareVolume: 1,
relayServer: '',
stunServer: '',
shareNoticeSeen: false,
hints: false
}; };
class SettingsStore { class SettingsStore {
@@ -58,9 +84,17 @@ class SettingsStore {
servers = $state<SavedServer[]>(loadList('mumh5.servers')); servers = $state<SavedServer[]>(loadList('mumh5.servers'));
// Pinned sha256 fingerprints of server certificates, keyed by host:port (trust on first use) // Pinned sha256 fingerprints of server certificates, keyed by host:port (trust on first use)
pins = $state<Record<string, string>>(load('mumh5.pins', {})); pins = $state<Record<string, string>>(load('mumh5.pins', {}));
// Username to suggest when connecting with an identity, keyed by identity id
identityNames = $state<Record<string, string>>(load('mumh5.identityNames', {}));
// Local nicknames for other users, only visible to us; keyed by certificate hash (or name) // Local nicknames for other users, only visible to us; keyed by certificate hash (or name)
nicknames = $state<Record<string, string>>(load('mumh5.nicknames', {})); nicknames = $state<Record<string, string>>(load('mumh5.nicknames', {}));
setIdentityName(id: string, name: string) {
if (name.trim()) this.identityNames[id] = name.trim();
else delete this.identityNames[id];
save('mumh5.identityNames', $state.snapshot(this.identityNames));
}
saveSettings() { save('mumh5.settings', $state.snapshot(this.settings)); } saveSettings() { save('mumh5.settings', $state.snapshot(this.settings)); }
upsertServer(s: SavedServer) { upsertServer(s: SavedServer) {
+474
View File
@@ -0,0 +1,474 @@
// Screen sharing between mumh5 clients in the same channel. Mumble has no video, so the
// picture goes directly from the sharer to each viewer over WebRTC; only the setup messages
// travel through the Mumble server (as plugin data, see core/share-signal.ts). The server has
// checked the sender's certificate, so a stream is known to come from that user.
import { SHARE, SHARE_DATA_ID, encodeShare, ShareAssembler, type ShareType } from '../core/share-signal.ts';
import type { User } from '../core/client.ts';
import type { Session } from './session.svelte.ts';
import { desktop, isWeb } from './native.ts';
import { proxyIce, relayIce } from './web.svelte.ts';
import { store } from './settings.svelte.ts';
import { ui } from './ui.svelte.ts';
import { sounds } from './audio/sounds.svelte.ts';
// Every viewer is a separate upload from the sharer
const MAX_VIEWERS = 8;
// Cameras watched at once; each is a download and a decoder
const MAX_CAMERAS = 12;
// A face needs far less than a screen full of text
const CAMERA_BITRATE = 1_200_000;
export const QUALITY = {
low: { label: 'Low (1 Mbit/s, 30 fps)', bitrate: 1_000_000, fps: 30 },
medium: { label: 'Medium (4 Mbit/s, 30 fps)', bitrate: 4_000_000, fps: 30 },
high: { label: 'High (8 Mbit/s, 60 fps)', bitrate: 8_000_000, fps: 60 }
} as const;
// Opus is negotiated as mono speech by default; shared sound is stereo and often music
const stereo = (sdp: string) => sdp.replace(/a=fmtp:(\d+) ([^\r\n]*useinbandfec=1[^\r\n]*)/g,
(line, pt, rest) => rest.includes('stereo=1') ? line : `a=fmtp:${pt} ${rest};stereo=1;sprop-stereo=1;maxaveragebitrate=192000`);
// What a stream shows. Sent along with the announcement, so others see the right icon.
export type ShareKind = 'screen' | 'camera';
export interface Watching {
host: Session;
session: number;
kind: ShareKind;
stream: MediaStream | null;
state: 'connecting' | 'live' | 'failed';
}
class ScreenShare {
// The server we are sharing on, and what we send
host = $state.raw<Session | null>(null);
stream = $state.raw<MediaStream | null>(null);
kind = $state<ShareKind>('screen');
// Show our own camera mirrored to ourselves, as a mirror would. Not a camera pointing away
// from us (a phone's back camera), and never what the others get.
mirror = $state(false);
// How many cameras this device has; with more than one there is something to switch to
cameras = $state(0);
switching = $state(false);
viewers = $state(0);
// Streams we receive: one screen at a time, and any number of cameras next to it
watches = $state.raw<Watching[]>([]);
// People whose camera we chose not to see, by certificate (or name); until the app restarts
hidden = $state<Record<string, boolean>>({});
error = $state('');
// The server the start dialog is open for
dialog = $state.raw<Session | null>(null);
private usesVirtualMic = false;
private peers = new Map<number, RTCPeerConnection>();
private watchPcs = new Map<number, RTCPeerConnection>();
private watchTimers = new Map<number, ReturnType<typeof setTimeout>>();
private assemblers = new WeakMap<Session, ShareAssembler>();
// Viewers whose connection came up, so leaving is only announced for those
private live = new Set<number>();
// When we entered the channel: streams already running there are not announced with a sound
private enteredAt = new WeakMap<Session, number>();
// Plugin data needs a 1.4 server; older ones would not pass the signals on
supported(s: Session): boolean {
return !!s.client && s.client.serverVersionNum >= 0x010400 && typeof RTCPeerConnection !== 'undefined';
}
private async iceServers(): Promise<RTCIceServer[]> {
const stun = store.settings.stunServer.trim();
const chosen: RTCIceServer[] = stun ? [{ urls: /^stuns?:/.test(stun) ? stun : `stun:${stun}` }] : [];
// A mumh5 proxy adds its STUN and its relay, which takes over by itself when no direct
// connection comes up (mobile and company networks, browsers that forbid direct UDP). The
// browser build has the proxy it is served from; the desktop app the one set in Settings.
const relay = store.settings.relayServer.trim();
return [...chosen, ...(relay ? await relayIce(relay) : isWeb ? await proxyIce() : [])];
}
// For testing the relay: localStorage mumh5.iceDebug = "relay" uses only relayed routes,
// "relay-tcp" only the relay reached over TCP (what a browser without direct UDP is left with)
private async rtcConfig(): Promise<RTCConfiguration> {
const iceServers = await this.iceServers();
let debug = '';
try { debug = localStorage.getItem('mumh5.iceDebug') ?? ''; } catch { /* storage unavailable */ }
if (!debug.startsWith('relay')) return { iceServers };
const tcpOnly = (urls: string | string[]) => [urls].flat().filter(u => !u.startsWith('turn') || u.includes('transport=tcp'));
return { iceTransportPolicy: 'relay', iceServers: debug === 'relay-tcp' ? iceServers.map(s => ({ ...s, urls: tcpOnly(s.urls) })) : iceServers };
}
private others(s: Session): number[] {
const self = s.client?.self;
if (!self) return [];
return [...s.client!.users.values()].filter(u => u.session !== self.session && u.channelId === self.channelId).map(u => u.session);
}
private inMyChannel(s: Session, session: number): boolean {
const self = s.client?.self;
return !!self && s.client!.users.get(session)?.channelId === self.channelId;
}
private async send(s: Session, to: number[], type: ShareType, text?: string): Promise<void> {
if (!to.length) return;
for (const packet of await encodeShare(type, text)) s.client?.sendPluginData(to, SHARE_DATA_ID, packet);
}
// Sharer and viewer connect directly, so each learns the other's address; say so once
private notice(): Promise<boolean> {
if (store.settings.shareNoticeSeen) return Promise.resolve(true);
return new Promise(resolve => {
let answered = false;
ui.prompt = {
title: 'Screen sharing', label: '', confirm: 'Continue', input: 'none',
text: 'Screen sharing connects you directly to other participants in this channel. You see each other\'s IP address. Only mumh5 users in your channel can watch.',
onsubmit: () => { answered = true; store.settings.shareNoticeSeen = true; store.saveSettings(); resolve(true); },
oncancel: () => { if (!answered) resolve(false); }
};
});
}
// Opens the dialog where the source, the sound and the quality are chosen
async start(s: Session): Promise<void> {
if (this.stream || this.dialog || !this.supported(s) || !(await this.notice())) return;
this.error = '';
this.dialog = s;
}
// The dialog hands over what it captured
go(s: Session, stream: MediaStream, usesVirtualMic: boolean, kind: ShareKind = 'screen'): void {
this.dialog = null;
// Disconnected while the dialog was open
if (!s.client || this.stream) {
stream.getTracks().forEach(t => t.stop());
if (usesVirtualMic) desktop?.shareAudioStop();
return;
}
for (const t of stream.getVideoTracks()) {
// Text on a screen must stay sharp; a face may blur a little but should move smoothly
t.contentHint = kind === 'camera' ? 'motion' : 'detail';
// The system's "stop sharing" control
t.addEventListener('ended', () => this.stop());
}
this.usesVirtualMic = usesVirtualMic;
this.kind = kind;
this.mirror = kind === 'camera' && stream.getVideoTracks()[0]?.getSettings().facingMode !== 'environment';
this.cameras = 0;
if (kind === 'camera') navigator.mediaDevices.enumerateDevices().then(d => { this.cameras = d.filter(x => x.kind === 'videoinput').length; }).catch(() => {});
this.host = s;
this.stream = stream;
this.send(s, this.others(s), SHARE.on, kind);
s.setSharing(s.client.session!, kind);
sounds.play('shareStarted');
}
// The next camera of this device (front and back on a phone), without interrupting the stream
async switchCamera(): Promise<void> {
const stream = this.stream;
const old = stream?.getVideoTracks()[0];
if (!stream || !old || this.kind !== 'camera' || this.switching) return;
this.switching = true;
this.error = '';
const open = (deviceId: string) => navigator.mediaDevices.getUserMedia({
video: { deviceId: { exact: deviceId }, width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: QUALITY[store.settings.shareQuality].fps } }
}).catch(() => navigator.mediaDevices.getUserMedia({ video: { deviceId: { exact: deviceId } } }));
try {
const ids = (await navigator.mediaDevices.enumerateDevices()).filter(d => d.kind === 'videoinput').map(d => d.deviceId);
this.cameras = ids.length;
const current = old.getSettings().deviceId ?? '';
const next = ids[(ids.indexOf(current) + 1) % ids.length];
if (ids.length < 2 || !next || next === current) return;
let fresh: MediaStream;
try {
// Computers can have both open for a moment, which keeps the picture going
fresh = await open(next);
} catch {
// A phone opens one camera at a time, so the old one has to go first
old.stop();
try {
fresh = await open(next);
} catch (e) {
// Could not get the other one: back to the camera we had
fresh = await open(current).catch(() => { throw e; });
this.error = `Could not switch the camera: ${(e as Error).message}`;
}
}
// Stopped sharing in the meantime
if (this.stream !== stream) return fresh.getTracks().forEach(t => t.stop());
const track = fresh.getVideoTracks()[0];
track.contentHint = 'motion';
track.addEventListener('ended', () => { if (this.stream?.getVideoTracks()[0] === track) this.stop(); });
// Viewers keep their connection; only the picture changes
for (const pc of this.peers.values()) {
for (const sender of pc.getSenders()) {
if (sender.track !== old) continue;
await sender.replaceTrack(track).catch(e => { this.error = `A viewer did not get the new camera: ${(e as Error).message}`; });
}
}
old.stop();
this.stream = new MediaStream([track, ...stream.getAudioTracks()]);
this.mirror = track.getSettings().facingMode !== 'environment';
} catch (e) {
this.error = `The camera stopped: ${(e as Error).message}`;
this.stop();
} finally {
this.switching = false;
}
}
private release(): void {
this.dropPeers();
this.stream?.getTracks().forEach(t => t.stop());
this.stream = null;
this.host = null;
if (this.usesVirtualMic) desktop?.shareAudioStop();
this.usesVirtualMic = false;
}
stop(): void {
const s = this.host;
if (this.stream) sounds.play('shareStopped');
if (s?.client) {
this.send(s, this.others(s), SHARE.off);
if (s.client.session != null) s.setSharing(s.client.session, false);
}
this.release();
}
private dropPeers(): void {
for (const pc of this.peers.values()) pc.close();
this.peers.clear();
this.live.clear();
this.viewers = 0;
}
private dropPeer(session: number): void {
this.peers.get(session)?.close();
this.peers.delete(session);
if (this.live.delete(session)) sounds.play('viewerLeft');
this.viewers = this.peers.size;
}
// The screen being watched, if any (there is at most one)
get watching(): Watching | null {
return this.watches.find(w => w.kind === 'screen') ?? null;
}
watchOf(session: number): Watching | undefined {
return this.watches.find(w => w.session === session);
}
// Cameras come on by themselves, like in a video call, once the note about IP addresses was
// confirmed; each person's can be hidden, and the whole thing switched off
get cams(): boolean {
return store.settings.autoCameras;
}
private who(s: Session, session: number): string {
const u = s.client?.users.get(session);
return u ? u.hash || `name:${u.name}` : '';
}
isHidden(s: Session, session: number): boolean {
return !!this.hidden[this.who(s, session)];
}
hideCamera(s: Session, session: number, hide: boolean): void {
const key = this.who(s, session);
if (!key) return;
if (hide) { this.hidden[key] = true; this.unwatch(session); }
else { delete this.hidden[key]; this.watch(s, session); }
}
private autoWatch(s: Session, session: number): void {
if (this.cams && store.settings.shareNoticeSeen && s.sharing[session] === 'camera' && !this.isHidden(s, session)) this.watch(s, session);
}
async watch(s: Session, session: number): Promise<void> {
const kind = s.sharing[session];
if (!this.supported(s) || !kind || this.watchOf(session) || !(await this.notice())) return;
// Streams from another server, and another screen, make way
for (const w of this.watches) if (w.host !== s || (kind === 'screen' && w.kind === 'screen')) this.unwatch(w.session);
if (kind === 'camera' && this.watches.filter(w => w.kind === 'camera').length >= MAX_CAMERAS) return;
if (!s.sharing[session] || this.watchOf(session)) return;
// Asking for it shows it again
delete this.hidden[this.who(s, session)];
this.watches = [...this.watches, { host: s, session, kind, stream: null, state: 'connecting' }];
this.send(s, [session], SHARE.watch);
this.watchTimers.set(session, setTimeout(() => this.setWatchState(session, 'failed'), 20000));
}
// One stream, or all of them
unwatch(session?: number): void {
for (const w of this.watches) {
if (session != null && w.session !== session) continue;
if (w.host.client) this.send(w.host, [w.session], SHARE.leave);
this.endWatch(w.session);
}
}
private endWatch(session: number): void {
clearTimeout(this.watchTimers.get(session));
this.watchTimers.delete(session);
this.watchPcs.get(session)?.close();
this.watchPcs.delete(session);
this.watches = this.watches.filter(w => w.session !== session);
}
private setWatchState(session: number, state: Watching['state'], stream?: MediaStream): void {
if (!this.watchOf(session)) return;
if (state !== 'connecting') { clearTimeout(this.watchTimers.get(session)); this.watchTimers.delete(session); }
this.watches = this.watches.map(w => w.session === session ? { ...w, state, stream: stream ?? w.stream } : w);
}
// All cameras on or off, remembered
async setCams(s: Session, on: boolean): Promise<void> {
if (on && (!this.supported(s) || !(await this.notice()))) return;
store.settings.autoCameras = on;
store.saveSettings();
if (!on) {
for (const w of this.watches) if (w.kind === 'camera') this.unwatch(w.session);
return;
}
for (const session of Object.keys(s.sharing)) if (Number(session) !== s.client?.session) this.autoWatch(s, Number(session));
}
// Candidates travel inside the description, so wait for them instead of sending each one
private gathered(pc: RTCPeerConnection): Promise<void> {
if (pc.iceGatheringState === 'complete') return Promise.resolve();
return new Promise(resolve => {
const done = () => { clearTimeout(timer); pc.removeEventListener('icegatheringstatechange', check); resolve(); };
const check = () => { if (pc.iceGatheringState === 'complete') done(); };
const timer = setTimeout(done, 3000);
pc.addEventListener('icegatheringstatechange', check);
});
}
private async offerTo(s: Session, viewer: number): Promise<void> {
const stream = this.stream;
if (!stream || this.host !== s) return;
const config = await this.rtcConfig();
if (this.stream !== stream || this.host !== s) return;
this.dropPeer(viewer);
if (this.peers.size >= MAX_VIEWERS) return;
const pc = new RTCPeerConnection(config);
this.peers.set(viewer, pc);
this.viewers = this.peers.size;
pc.addEventListener('connectionstatechange', () => {
if (this.peers.get(viewer) !== pc) return;
if (pc.connectionState === 'connected' && !this.live.has(viewer)) {
this.live.add(viewer);
sounds.play('viewerJoined');
} else if (pc.connectionState === 'failed' || pc.connectionState === 'closed') this.dropPeer(viewer);
});
for (const track of stream.getTracks()) {
const sender = pc.addTrack(track, stream);
if (track.kind !== 'video') continue;
const params = sender.getParameters();
// Keep the picture sharp and let the frame rate drop when the connection is tight
params.degradationPreference = this.kind === 'camera' ? 'maintain-framerate' : 'maintain-resolution';
const wanted = QUALITY[store.settings.shareQuality].bitrate;
for (const enc of params.encodings ?? []) enc.maxBitrate = this.kind === 'camera' ? Math.min(wanted, CAMERA_BITRATE) : wanted;
await sender.setParameters(params).catch(() => {});
}
await pc.setLocalDescription({ type: 'offer', sdp: stereo((await pc.createOffer()).sdp ?? '') });
await this.gathered(pc);
if (this.peers.get(viewer) === pc && pc.localDescription) await this.send(s, [viewer], SHARE.offer, pc.localDescription.sdp);
}
private async answerTo(s: Session, sharer: number, sdp: string): Promise<void> {
const mine = () => { const w = this.watchOf(sharer); return !!w && w.host === s; };
if (!mine()) return;
const config = await this.rtcConfig();
if (!mine()) return;
this.watchPcs.get(sharer)?.close();
const pc = new RTCPeerConnection(config);
this.watchPcs.set(sharer, pc);
const current = () => this.watchPcs.get(sharer) === pc;
// The track is announced with the description, before any route exists: the stream only
// counts as live once the connection is up, otherwise the viewer stares at a black picture
pc.addEventListener('track', e => {
if (current()) this.setWatchState(sharer, pc.connectionState === 'connected' ? 'live' : 'connecting', e.streams[0]);
});
pc.addEventListener('connectionstatechange', () => {
if (!current()) return;
if (pc.connectionState === 'connected') this.setWatchState(sharer, 'live');
else if (pc.connectionState === 'failed') this.setWatchState(sharer, 'failed');
});
try {
await pc.setRemoteDescription({ type: 'offer', sdp });
await pc.setLocalDescription({ type: 'answer', sdp: stereo((await pc.createAnswer()).sdp ?? '') });
await this.gathered(pc);
if (current() && pc.localDescription) await this.send(s, [sharer], SHARE.answer, pc.localDescription.sdp);
} catch {
if (current()) this.setWatchState(sharer, 'failed');
}
}
// Raw WebRTC statistics for the overlay: of a stream we watch, or one per viewer of ours
async reports(own: boolean, session?: number): Promise<{ viewer: number | null; report: RTCStatsReport }[]> {
if (!own) {
const pc = session != null ? this.watchPcs.get(session) : undefined;
return pc ? [{ viewer: null, report: await pc.getStats() }] : [];
}
return Promise.all([...this.peers].map(async ([viewer, pc]) => ({ viewer, report: await pc.getStats() })));
}
// ─── Called by the session ────────────────────────────────────────────────
async receive(s: Session, sender: number, dataId: string, data: Uint8Array): Promise<void> {
if (dataId !== SHARE_DATA_ID) return;
let assembler = this.assemblers.get(s);
if (!assembler) this.assemblers.set(s, assembler = new ShareAssembler());
const msg = await assembler.push(sender, data);
// Only people in our channel take part
if (!msg || !s.client || !this.inMyChannel(s, sender)) return;
switch (msg.type) {
case SHARE.on:
if (!s.sharing[sender] && Date.now() - (this.enteredAt.get(s) ?? 0) > 3000) sounds.play('shareStarted');
s.setSharing(sender, msg.text === 'camera' ? 'camera' : 'screen');
this.autoWatch(s, sender);
break;
case SHARE.off:
if (s.sharing[sender]) sounds.play('shareStopped');
s.setSharing(sender, false);
if (this.watchOf(sender)?.host === s) this.endWatch(sender);
break;
case SHARE.who: if (this.host === s) this.send(s, [sender], SHARE.on, this.kind); break;
case SHARE.watch: if (this.host === s) this.offerTo(s, sender).catch(() => this.dropPeer(sender)); break;
case SHARE.leave: if (this.host === s) this.dropPeer(sender); break;
case SHARE.offer: if (msg.text) this.answerTo(s, sender, msg.text); break;
case SHARE.answer:
if (this.host === s && msg.text) this.peers.get(sender)?.setRemoteDescription({ type: 'answer', sdp: msg.text }).catch(() => this.dropPeer(sender));
break;
}
}
// We connected or moved: ask who is sharing here, and tell the new channel about our stream
entered(s: Session): void {
if (!this.supported(s)) return;
const self = s.client!.session!;
this.enteredAt.set(s, Date.now());
s.clearSharing();
for (const w of this.watches) if (w.host === s) this.unwatch(w.session);
if (this.host === s) {
this.dropPeers();
s.setSharing(self, this.kind);
this.send(s, this.others(s), SHARE.on, this.kind);
}
this.send(s, this.others(s), SHARE.who);
}
// Someone else connected, moved or left
userMoved(s: Session, user: User, gone = false): void {
if (!s.client?.self || user.session === s.client.session) return;
if (!gone && this.inMyChannel(s, user.session)) {
if (this.host === s) this.send(s, [user.session], SHARE.on, this.kind);
return;
}
// A sharer who left or disconnected: their stream is over for us
if (s.sharing[user.session]) sounds.play('shareStopped');
s.setSharing(user.session, false);
if (this.host === s) this.dropPeer(user.session);
if (this.watchOf(user.session)?.host === s) this.endWatch(user.session);
}
closed(s: Session): void {
if (this.host === s) this.release();
if (this.dialog === s) this.dialog = null;
for (const w of this.watches) if (w.host === s) this.endWatch(w.session);
}
}
export const share = new ScreenShare();
+275
View File
@@ -0,0 +1,275 @@
// Text channels: channels you read and write without sitting in them (see core/text-signal.ts).
// Messages go to everyone on the server as plugin data; the server has checked the sender's
// certificate, so a live message is known to come from that user. Messages handed over by
// another client after connecting are that client's word for what was said.
import { TEXT, TEXT_DATA_ID, KEEP, MAX_HTML, TextAssembler, encodeText, encodeHistory, parseMsg, parseHistory, mergeMsgs, newId, isTextChannel, type TextMsg, type TextType } from '../core/text-signal.ts';
import type { Session } from './session.svelte.ts';
import { getData, putData } from './blobstore.ts';
import { sounds } from './audio/sounds.svelte.ts';
// Murmur allows a burst of 15 plugin messages per user and 4 a second after that, and drops
// the rest silently. Screen sharing signals count against the same limit, so stay well under it.
const BURST = 6;
const PER_SECOND = 2.5;
// How long a client may take to hand over its messages before the next one is asked
const ANSWER_MS = 6000;
const MAX_ASKED = 3;
interface State {
assembler: TextAssembler;
msgs: Map<number, TextMsg[]>;
// Channels whose kept messages are in the chat log already
shown: Set<number>;
// What this computer kept has been read; nothing is shown before, or it would come out of order
loaded: boolean;
queue: { to: number[]; packet: Uint8Array }[];
level: number;
levelAt: number;
timer: ReturnType<typeof setTimeout> | undefined;
// Catching up: who answered our hello, who we asked, and whether that is settled
peers: number[];
asked: number[];
caughtUp: boolean;
askTimer: ReturnType<typeof setTimeout> | undefined;
saveTimer: ReturnType<typeof setTimeout> | undefined;
storeKey: string;
}
class TextChannels {
private states = new WeakMap<Session, State>();
// Plugin data needs a 1.4 server; older ones would not pass the messages on
supported(s: Session): boolean {
return !!s.client && s.client.serverVersionNum >= 0x010400;
}
isText(s: Session, channelId: number): boolean {
return this.supported(s) && isTextChannel(s.client!.channels.get(channelId)?.description ?? '');
}
// After connecting: bring back what this computer kept, then ask the others for what we missed
async entered(s: Session): Promise<void> {
const client = s.client;
if (!client || !this.supported(s) || !s.server) return;
const st: State = {
assembler: new TextAssembler(), msgs: new Map(), shown: new Set(), loaded: false, queue: [], level: 0, levelAt: Date.now(), timer: undefined,
peers: [], asked: [], caughtUp: false, askTimer: undefined, saveTimer: undefined,
storeKey: `text:${s.server.host.toLowerCase()}:${s.server.port}`
};
this.states.set(s, st);
// The marker is in the description, and long descriptions are only announced by hash
for (const c of client.channels.values()) s.loadDescription(c.id);
try {
const kept = await getData<Record<number, TextMsg[]>>(st.storeKey);
if (s.client !== client) return;
for (const [id, list] of Object.entries(kept ?? {})) {
if (Array.isArray(list)) st.msgs.set(Number(id), mergeMsgs(st.msgs.get(Number(id)) ?? [], list).all);
}
} catch { /* no storage: start empty */ }
if (s.client !== client) return;
st.loaded = true;
this.reveal(s);
this.send(s, this.everyone(s), TEXT.hello);
}
closed(s: Session): void {
const st = this.states.get(s);
if (!st) return;
clearTimeout(st.timer);
clearTimeout(st.askTimer);
if (st.saveTimer) { clearTimeout(st.saveTimer); this.save(st); }
this.states.delete(s);
}
// A channel appeared or its description changed
channelChanged(s: Session, channelId: number): void {
if (!this.states.has(s)) return;
s.loadDescription(channelId);
this.reveal(s);
}
// Murmur gives the id of a removed channel to a later one
channelRemoved(s: Session, channelId: number): void {
const st = this.states.get(s);
if (!st) return;
st.shown.delete(channelId);
st.msgs.delete(channelId);
this.forget(st, channelId);
}
userLeft(s: Session, session: number): void {
const st = this.states.get(s);
if (!st) return;
st.peers = st.peers.filter(p => p !== session);
// The one we were waiting for is gone: ask the next
if (!st.caughtUp && st.asked[st.asked.length - 1] === session) this.askNext(s, st);
}
// Sends a message to a text channel. The caller shows it in the log.
async post(s: Session, channelId: number, html: string): Promise<string | null> {
const st = this.states.get(s);
const self = s.client?.self;
if (!st || !self) return 'Not connected.';
if (html.length > MAX_HTML) return `Message too long for a text channel (${html.length} / ${MAX_HTML} characters)`;
const m: TextMsg = { i: newId(), c: channelId, t: Date.now(), n: self.name, k: self.hash, h: html };
let packets: Uint8Array[];
try {
packets = await encodeText(TEXT.msg, { i: m.i, c: m.c, h: m.h });
} catch {
return 'Message too long for a text channel.';
}
const to = this.everyone(s);
for (const packet of packets) st.queue.push({ to, packet });
this.flush(s, st);
this.keep(st, [m]);
st.shown.add(channelId);
s.addText(m, true, false);
return null;
}
async receive(s: Session, sender: number, dataId: string, data: Uint8Array): Promise<void> {
const st = this.states.get(s);
if (dataId !== TEXT_DATA_ID || !st) return;
const signal = await st.assembler.push(sender, data);
const client = s.client;
if (!signal || !client || this.states.get(s) !== st) return;
switch (signal.type) {
case TEXT.hello:
this.send(s, [sender], TEXT.here);
break;
case TEXT.here:
if (!st.peers.includes(sender)) st.peers.push(sender);
if (!st.caughtUp && !st.asked.length) this.askNext(s, st);
break;
case TEXT.msg: {
const m = parseMsg(signal.text);
const from = client.users.get(sender);
const ch = m && client.channels.get(m.c);
// Only into text channels, or channels whose description has not arrived yet
if (!m || !from || !ch || !(isTextChannel(ch.description) || (ch.descriptionHash && !ch.description))) return;
const msg: TextMsg = { ...m, t: Date.now(), n: from.name, k: from.hash };
if (!this.keep(st, [msg]).length) return;
if (st.shown.has(m.c)) {
s.addText(msg, false, false, sender);
sounds.play('message');
}
break;
}
case TEXT.want: {
for (const [c, list] of st.msgs) {
if (!this.isText(s, c) || !list.length) continue;
for (const packet of await encodeHistory(c, list)) st.queue.push({ to: [sender], packet });
}
// Also when there is nothing, so the asker stops waiting
for (const packet of await encodeText(TEXT.history, { c: 0, m: [] })) st.queue.push({ to: [sender], packet });
this.flush(s, st);
break;
}
case TEXT.history: {
// Only from the client we asked
if (st.asked[st.asked.length - 1] !== sender) return;
st.caughtUp = true;
clearTimeout(st.askTimer);
const list = parseHistory(signal.text);
if (!list.length || !client.channels.has(list[0].c)) return;
const added = this.keep(st, list);
if (st.shown.has(list[0].c)) for (const m of added) s.addText(m, this.mine(s, m), true);
break;
}
}
}
private mine(s: Session, m: TextMsg): boolean {
const self = s.client?.self;
return !!self && (m.k ? m.k === self.hash : m.n === self.name);
}
private everyone(s: Session): number[] {
const client = s.client;
return client ? [...client.users.keys()].filter(id => id !== client.session) : [];
}
private askNext(s: Session, st: State): void {
clearTimeout(st.askTimer);
const next = st.peers.find(p => !st.asked.includes(p));
if (next == null || st.asked.length >= MAX_ASKED) return;
st.asked.push(next);
this.send(s, [next], TEXT.want);
st.askTimer = setTimeout(() => { if (!st.caughtUp && this.states.get(s) === st) this.askNext(s, st); }, ANSWER_MS);
}
// Puts the kept messages of text channels into their chat logs, once per channel
private reveal(s: Session): void {
const st = this.states.get(s);
if (!st?.loaded) return;
for (const [c, list] of st.msgs) {
if (st.shown.has(c) || !this.isText(s, c)) continue;
st.shown.add(c);
for (const m of list) s.addText(m, this.mine(s, m), true);
}
for (const c of s.client?.channels.keys() ?? []) if (this.isText(s, c)) st.shown.add(c);
}
private keep(st: State, list: TextMsg[]): TextMsg[] {
if (!list.length) return [];
const c = list[0].c;
const { all, added } = mergeMsgs(st.msgs.get(c) ?? [], list, KEEP);
if (added.length) {
st.msgs.set(c, all);
this.saveSoon(st);
}
return added;
}
private saveSoon(st: State): void {
clearTimeout(st.saveTimer);
st.saveTimer = setTimeout(() => this.save(st), 1000);
}
// Merged into what is stored: a second connection to the same server writes there too
private async save(st: State): Promise<void> {
st.saveTimer = undefined;
try {
const kept = await getData<Record<number, TextMsg[]>>(st.storeKey) ?? {};
for (const [c, list] of st.msgs) kept[c] = mergeMsgs(Array.isArray(kept[c]) ? kept[c] : [], list).all;
await putData(st.storeKey, kept);
} catch { /* storage unavailable */ }
}
private async forget(st: State, channelId: number): Promise<void> {
try {
const kept = await getData<Record<number, TextMsg[]>>(st.storeKey);
if (!kept || !(channelId in kept)) return;
delete kept[channelId];
await putData(st.storeKey, kept);
} catch { /* storage unavailable */ }
}
private async send(s: Session, to: number[], type: TextType, body?: unknown): Promise<void> {
const st = this.states.get(s);
if (!st || !to.length) return;
for (const packet of await encodeText(type, body)) st.queue.push({ to, packet });
this.flush(s, st);
}
private flush(s: Session, st: State): void {
if (st.timer) return;
const client = s.client;
while (st.queue.length && client && this.states.get(s) === st) {
const now = Date.now();
st.level = Math.max(0, st.level - (now - st.levelAt) / 1000 * PER_SECOND);
st.levelAt = now;
if (st.level > BURST - 1) {
st.timer = setTimeout(() => { st.timer = undefined; this.flush(s, st); }, 1000 / PER_SECOND);
return;
}
st.level++;
const { to, packet } = st.queue.shift()!;
// Queued packets can outlive the people they were meant for
const there = to.filter(id => client.users.has(id));
if (there.length) client.sendPluginData(there, TEXT_DATA_ID, packet);
}
}
}
export const textChannels = new TextChannels();
+3 -3
View File
@@ -1,11 +1,11 @@
import { native } from './native.ts'; import { desktop } from './native.ts';
import { menu, type MenuItem } from './menu.svelte.ts'; import { menu, type MenuItem } from './menu.svelte.ts';
// Right-click menu for text fields, messages, links and images. Elements with their own menu // Right-click menu for text fields, messages, links and images. Elements with their own menu
// (users, channels, servers) call preventDefault, so Electron does not report those. // (users, channels, servers) call preventDefault, so Electron does not report those.
export function startTextMenu(): void { export function startTextMenu(): void {
if (!native) return; if (!desktop) return;
const api = native; const api = desktop;
api.onContextMenu(p => { api.onContextMenu(p => {
// Edit commands act on the focused element; keep it focused through the menu // Edit commands act on the focused element; keep it focused through the menu
+92
View File
@@ -0,0 +1,92 @@
// Generated by scripts/gen-themes.mjs (npm run themes). Do not edit by hand.
export const GENERATED_THEMES = [
{ id: 'slate', name: 'Slate', note: 'Blue-grey, calm', light: false },
{ id: 'graphite', name: 'Graphite', note: 'Neutral grey, no color', light: false },
{ id: 'charcoal', name: 'Charcoal', note: 'Warm grey, amber accents', light: false },
{ id: 'obsidian', name: 'Obsidian', note: 'Near black, violet accents', light: false },
{ id: 'navy', name: 'Navy', note: 'Dark blue', light: false },
{ id: 'cobalt', name: 'Cobalt', note: 'Blue, yellow accents', light: false },
{ id: 'indigo', name: 'Indigo', note: 'Blue-violet', light: false },
{ id: 'violet', name: 'Violet', note: 'Purple', light: false },
{ id: 'orchid', name: 'Orchid', note: 'Soft purple-pink', light: false },
{ id: 'magenta', name: 'Magenta', note: 'Dark pink', light: false },
{ id: 'crimson', name: 'Crimson', note: 'Dark red', light: false },
{ id: 'ruby', name: 'Ruby', note: 'Deep red, orange accents', light: false },
{ id: 'rust', name: 'Rust', note: 'Red-brown', light: false },
{ id: 'copper', name: 'Copper', note: 'Brown, muted orange', light: false },
{ id: 'amber', name: 'Amber', note: 'Dark yellow-brown', light: false },
{ id: 'olive', name: 'Olive', note: 'Yellow-green', light: false },
{ id: 'moss', name: 'Moss', note: 'Muted green', light: false },
{ id: 'emerald', name: 'Emerald', note: 'Rich green', light: false },
{ id: 'jade', name: 'Jade', note: 'Blue-green', light: false },
{ id: 'teal', name: 'Teal', note: 'Green-blue', light: false },
{ id: 'lagoon', name: 'Lagoon', note: 'Bright turquoise accents', light: false },
{ id: 'arctic', name: 'Arctic', note: 'Lighter cool grey-blue', light: false },
{ id: 'storm', name: 'Storm', note: 'Lighter blue-grey', light: false },
{ id: 'dusk', name: 'Dusk', note: 'Purple evening, orange accents', light: false },
{ id: 'wine', name: 'Wine', note: 'Dark red-purple', light: false },
{ id: 'cocoa', name: 'Cocoa', note: 'Brown', light: false },
{ id: 'espresso', name: 'Espresso', note: 'Very dark brown', light: false },
{ id: 'terminal', name: 'Terminal', note: 'Green on black', light: false },
{ id: 'phosphor', name: 'Phosphor', note: 'Amber on black', light: false },
{ id: 'neon', name: 'Neon', note: 'Black, pink and cyan', light: false },
{ id: 'synthwave', name: 'Synthwave', note: 'Purple, pink and cyan', light: false },
{ id: 'aurora', name: 'Aurora', note: 'Dark blue, green accents', light: false },
{ id: 'abyss', name: 'Abyss', note: 'Blue-black, turquoise accents', light: false },
{ id: 'carbon', name: 'Carbon', note: 'Black, red accents', light: false },
{ id: 'ash', name: 'Ash', note: 'Lighter grey, blue accents', light: false },
{ id: 'steel', name: 'Steel', note: 'Grey-blue, orange accents', light: false },
{ id: 'ink', name: 'Ink', note: 'Blue-black, yellow accents', light: false },
{ id: 'sapphire', name: 'Sapphire', note: 'Strong blue', light: false },
{ id: 'azure', name: 'Azure', note: 'Bright blue accents', light: false },
{ id: 'peacock', name: 'Peacock', note: 'Turquoise, purple accents', light: false },
{ id: 'pine', name: 'Pine', note: 'Dark green, orange accents', light: false },
{ id: 'fern', name: 'Fern', note: 'Soft green', light: false },
{ id: 'lime', name: 'Lime', note: 'Yellow-green accents', light: false },
{ id: 'citrus', name: 'Citrus', note: 'Dark yellow, orange accents', light: false },
{ id: 'sunset', name: 'Sunset', note: 'Purple, orange accents', light: false },
{ id: 'flamingo', name: 'Flamingo', note: 'Dark rose, bright pink', light: false },
{ id: 'cherry', name: 'Cherry', note: 'Deep red', light: false },
{ id: 'brick', name: 'Brick', note: 'Red-brown, gold accents', light: false },
{ id: 'terracotta', name: 'Terracotta', note: 'Clay, teal accents', light: false },
{ id: 'sandstone', name: 'Sandstone', note: 'Lighter warm grey', light: false },
{ id: 'mocha', name: 'Mocha', note: 'Brown, lilac accents', light: false },
{ id: 'walnut', name: 'Walnut', note: 'Dark brown, gold accents', light: false },
{ id: 'bronze', name: 'Bronze', note: 'Yellow-brown', light: false },
{ id: 'brass', name: 'Brass', note: 'Dark gold', light: false },
{ id: 'mulberry', name: 'Mulberry', note: 'Dark purple-pink', light: false },
{ id: 'grape', name: 'Grape', note: 'Strong purple', light: false },
{ id: 'iris', name: 'Iris', note: 'Lighter blue-violet', light: false },
{ id: 'twilight', name: 'Twilight', note: 'Night blue, pink accents', light: false },
{ id: 'nebula', name: 'Nebula', note: 'Purple, turquoise accents', light: false },
{ id: 'cathode', name: 'Cathode', note: 'Cyan on black', light: false },
{ id: 'bubblegum', name: 'Bubblegum', note: 'Purple-pink, cyan links', light: false },
{ id: 'volcano', name: 'Volcano', note: 'Black, lava orange', light: false },
{ id: 'glacier', name: 'Glacier', note: 'The lightest dark, cool', light: false },
{ id: 'seaweed', name: 'Seaweed', note: 'Dark teal, green accents', light: false },
{ id: 'snow', name: 'Snow', note: 'Cool white', light: true },
{ id: 'linen', name: 'Linen', note: 'Warm white', light: true },
{ id: 'sky', name: 'Sky', note: 'Light blue', light: true },
{ id: 'mint', name: 'Mint', note: 'Light green', light: true },
{ id: 'lavender', name: 'Lavender', note: 'Light purple', light: true },
{ id: 'blush', name: 'Blush', note: 'Light pink', light: true },
{ id: 'peach', name: 'Peach', note: 'Light orange', light: true },
{ id: 'lemon', name: 'Lemon', note: 'Light yellow', light: true },
{ id: 'sage', name: 'Sage', note: 'Grey-green', light: true },
{ id: 'latte', name: 'Latte', note: 'Milky brown', light: true },
{ id: 'ice', name: 'Ice', note: 'Pale turquoise', light: true },
{ id: 'cloud', name: 'Cloud', note: 'Soft blue-grey', light: true },
{ id: 'pearl', name: 'Pearl', note: 'Near white, rose accents', light: true },
{ id: 'cream', name: 'Cream', note: 'Soft yellow', light: true },
{ id: 'sand', name: 'Sand', note: 'Beige', light: true },
{ id: 'rose', name: 'Rose', note: 'Pink-red', light: true },
{ id: 'coral', name: 'Coral', note: 'Light red-orange', light: true },
{ id: 'apricot', name: 'Apricot', note: 'Warm orange', light: true },
{ id: 'pistachio', name: 'Pistachio', note: 'Yellow-green', light: true },
{ id: 'seafoam', name: 'Seafoam', note: 'Green-turquoise', light: true },
{ id: 'aqua', name: 'Aqua', note: 'Turquoise', light: true },
{ id: 'periwinkle', name: 'Periwinkle', note: 'Blue-violet', light: true },
{ id: 'lilac', name: 'Lilac', note: 'Soft purple', light: true },
{ id: 'petal', name: 'Petal', note: 'Purple-pink', light: true },
{ id: 'fog', name: 'Fog', note: 'Grey with a little blue', light: true }
] as const;
+3 -3
View File
@@ -1,4 +1,4 @@
import { native } from './native.ts'; import { desktop } from './native.ts';
import { ICON_PATHS } from './icons.ts'; import { ICON_PATHS } from './icons.ts';
import { session, sessions } from './session.svelte.ts'; import { session, sessions } from './session.svelte.ts';
import { voice, type TransmitMode } from './audio/voice.svelte.ts'; import { voice, type TransmitMode } from './audio/voice.svelte.ts';
@@ -40,8 +40,8 @@ function drawIcon(look: TrayLook): string {
// Mirrors voice state into the system tray and handles its menu // Mirrors voice state into the system tray and handles its menu
export function startTray(): void { export function startTray(): void {
if (!native) return; if (!desktop) return;
const api = native; const api = desktop;
$effect.root(() => { $effect.root(() => {
$effect(() => { $effect(() => {
+8 -1
View File
@@ -9,6 +9,8 @@ export interface PromptRequest {
initial?: string; initial?: string;
text?: string; text?: string;
onsubmit: (value: string) => void; onsubmit: (value: string) => void;
// Closed without confirming
oncancel?: () => void;
} }
export interface CertRequest { export interface CertRequest {
@@ -25,8 +27,13 @@ class Ui {
wizard = $state<'setup' | 'add' | null>(null); wizard = $state<'setup' | 'add' | null>(null);
// Quick audio popover from right-clicking mute (input) or deafen (output) // Quick audio popover from right-clicking mute (input) or deafen (output)
quickAudio = $state<{ kind: 'input' | 'output'; x: number; y: number } | null>(null); quickAudio = $state<{ kind: 'input' | 'output'; x: number; y: number } | null>(null);
settingsTab = $state<'voice' | 'sounds' | 'appearance' | 'files' | 'identities'>('voice'); settingsTab = $state<'voice' | 'sounds' | 'appearance' | 'accessibility' | 'files' | 'identities'>('voice');
publicServers = $state(false); publicServers = $state(false);
serverInfo = $state(false);
// Image shown large (clicked in the chat or in a description)
image = $state<string | null>(null);
// Set by the toolbar to open the own profile with the description editor
editDescription = $state(false);
// Add-server dialog, optionally prefilled (from the public list) // Add-server dialog, optionally prefilled (from the public list)
addServer = $state<{ host?: string; port?: number; label?: string } | null>(null); addServer = $state<{ host?: string; port?: number; label?: string } | null>(null);
// Channel dialog: create a subchannel of `parent`, or edit channel `id` // Channel dialog: create a subchannel of `parent`, or edit channel `id`
+183
View File
@@ -0,0 +1,183 @@
// The browser build: what the desktop app does in its main process goes through the mumh5 web
// proxy instead (server/proxy.ts). Identities are kept in this browser and sent to the proxy
// when connecting, because the proxy presents the certificate to the Mumble server.
import type { CertDetails } from '../core/transport.ts';
import { WebSocketTransport } from '../core/ws-transport.ts';
import type { IdentitySummary, PlatformApi } from './native.ts';
interface StoredIdentity {
id: string;
name: string;
certPem: string;
keyPem: string;
fingerprint: string;
cert: CertDetails;
createdAt: string;
}
interface StoreData { identities: StoredIdentity[]; defaultId: string | null; setupDone: boolean }
const KEY = 'mumh5.identities';
// The proxy normally serves the page; VITE_PROXY_URL points a separately hosted page at it
const base = (() => {
const fixed = import.meta.env?.VITE_PROXY_URL as string | undefined;
if (fixed) return fixed.replace(/\/*$/, '/');
return typeof location !== 'undefined' ? new URL('.', location.href).href : '';
})();
async function call<T>(route: string, body?: unknown): Promise<T> {
let res: Response;
try {
res = await fetch(`${base}api/${route}`, body === undefined ? undefined
: { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
} catch {
throw new Error('Could not reach the proxy.');
}
const data = await res.json().catch(() => null);
if (!res.ok) throw new Error(data?.error ?? `The proxy answered ${res.status}.`);
return data as T;
}
const toBase64 = (bytes: Uint8Array) => { let s = ''; for (const b of bytes) s += String.fromCharCode(b); return btoa(s); };
const fromBase64 = (b64: string) => Uint8Array.from(atob(b64), c => c.charCodeAt(0));
function read(): StoreData {
try {
const d = JSON.parse(localStorage.getItem(KEY) ?? 'null');
if (d && Array.isArray(d.identities)) return d;
} catch { /* storage unavailable or damaged */ }
return { identities: [], defaultId: null, setupDone: false };
}
function write(d: StoreData): void {
// Unlike settings, losing this silently would lose the user's registrations
try { localStorage.setItem(KEY, JSON.stringify(d)); } catch { throw new Error('This browser does not allow storing the identity (private window or storage disabled).'); }
}
const summary = (d: StoreData, i: StoredIdentity): IdentitySummary =>
({ id: i.id, name: i.name, fingerprint: i.fingerprint, isDefault: i.id === d.defaultId, cert: i.cert });
type Fresh = { certPem: string; keyPem: string; fingerprint: string; cert: CertDetails; commonName: string };
function add(fresh: Fresh, name: string): IdentitySummary {
const d = read();
const existing = d.identities.find(i => i.fingerprint === fresh.fingerprint);
if (existing) throw new Error(`This certificate is already stored as "${existing.name}".`);
const stored: StoredIdentity = {
id: crypto.randomUUID(), name: name.trim() || fresh.commonName || 'Identity', certPem: fresh.certPem, keyPem: fresh.keyPem,
fingerprint: fresh.fingerprint, cert: fresh.cert, createdAt: new Date().toISOString()
};
d.identities.push(stored);
d.defaultId ??= stored.id;
write(d);
return summary(d, stored);
}
// The identity to connect with: the requested one, else the default, else a new one
async function identityFor(id?: string): Promise<StoredIdentity> {
let d = read();
if (!d.identities.length) {
add(await call<Fresh>('identity/create', { name: 'mumh5 user', email: '' }), 'My identity');
d = read();
}
return d.identities.find(i => i.id === id) ?? d.identities.find(i => i.id === d.defaultId) ?? d.identities[0];
}
function browserVersion(): string {
const m = /(Firefox|Edg|Chrome|Version)\/(\d+)/.exec(navigator.userAgent);
if (!m) return '';
return `${m[1] === 'Edg' ? 'Edge' : m[1] === 'Version' ? 'Safari' : m[1]} ${m[2]}`;
}
export const webApi: PlatformApi = {
platformInfo: async () => ({ os: 'Web', osVersion: browserVersion() }),
describeCerts: ders => call<CertDetails[]>('certs/describe', { ders: ders.map(toBase64) }),
identities: {
list: async () => { const d = read(); return { identities: d.identities.map(i => summary(d, i)), setupDone: d.setupDone }; },
create: async (name, email) => add(await call<Fresh>('identity/create', { name, email }), name),
importP12: async (bytes, password, name) => add(await call<Fresh>('identity/import', { p12: toBase64(bytes), password }), name),
// The desktop client's settings are out of a browser's reach
findMumble: async () => null,
importMumble: async () => { throw new Error('Not available in the browser. Export the certificate from Mumble and import the file.'); },
// Returns the file name; the browser decides where downloads go
exportP12: async (id, password) => {
const i = read().identities.find(x => x.id === id);
if (!i) throw new Error('Identity not found.');
const { p12 } = await call<{ p12: string }>('identity/export', { certPem: i.certPem, keyPem: i.keyPem, password, name: i.name });
const file = `${i.name.replace(/[^\w.-]+/g, '_') || 'identity'}.p12`;
const url = URL.createObjectURL(new Blob([fromBase64(p12)], { type: 'application/x-pkcs12' }));
const a = document.createElement('a');
a.href = url;
a.download = file;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 10000);
return `your downloads as ${file}`;
},
setDefault: async id => { const d = read(); if (d.identities.some(i => i.id === id)) d.defaultId = id; write(d); },
rename: async (id, name) => { const d = read(); const i = d.identities.find(x => x.id === id); if (i && name.trim()) i.name = name.trim(); write(d); },
remove: async id => {
const d = read();
d.identities = d.identities.filter(i => i.id !== id);
if (d.defaultId === id) d.defaultId = d.identities[0]?.id ?? null;
write(d);
},
finishSetup: async () => { const d = read(); d.setupDone = true; write(d); }
}
};
export async function openWebTransport(host: string, port: number, identityId?: string): Promise<WebSocketTransport> {
const identity = await identityFor(identityId);
return new WebSocketTransport(`${base.replace(/^http/, 'ws')}api/connect`, { host, port }, identity);
}
// Which servers the proxy connects to; the connect dialog offers these
export interface ProxyServer { host: string; port: number; label: string }
class ProxyInfo {
servers = $state<ProxyServer[]>([]);
any = $state(false);
// UDP port of the proxy's STUN responder, if it runs one
stun = $state<number | null>(null);
loaded = $state(false);
error = $state('');
async load(): Promise<void> {
try {
const c = await call<{ servers: ProxyServer[]; any: boolean; stun?: number | null; turn?: boolean }>('config');
this.servers = c.servers;
this.any = c.any;
this.stun = c.stun ?? null;
this.error = '';
} catch (e) {
this.error = (e as Error).message;
}
this.loaded = true;
}
}
export const proxyInfo = new ProxyInfo();
// STUN and the relay of a mumh5 proxy, for screen sharing. The browser build uses the proxy it
// is served from, a host the user already uses; the desktop app one the user entered. The relay
// needs credentials, which the proxy hands out for a few hours at a time.
const relays = new Map<string, { servers: RTCIceServer[]; until: number }>();
export async function relayIce(address: string): Promise<RTCIceServer[]> {
let url: URL;
try { url = new URL(/^https?:\/\//i.test(address) ? address : `https://${address}`); } catch { return []; }
const root = url.href.replace(/\/*$/, '/');
const known = relays.get(root);
if (known && known.until > Date.now()) return known.servers;
try {
const res = await fetch(`${root}api/relay`);
if (!res.ok) return [];
const c = await res.json() as { port: number; username?: string; credential?: string; ttl?: number; urls?: string[] };
const servers: RTCIceServer[] = [{ urls: `stun:${url.hostname}:${c.port}` }];
// UDP where it is allowed, TCP for networks and browsers that forbid it
// The operator may name other ways in, such as a TLS front on port 443 for networks that allow nothing else
const direct = [`turn:${url.hostname}:${c.port}?transport=udp`, `turn:${url.hostname}:${c.port}?transport=tcp`];
if (c.username) servers.push({ urls: [...direct, ...(c.urls ?? [])], username: c.username, credential: c.credential });
relays.set(root, { servers, until: Date.now() + Math.max(60, (c.ttl ?? 3600) - 3000) * 1000 });
return servers;
} catch {
return [];
}
}
export const proxyIce = (): Promise<RTCIceServer[]> => base ? relayIce(base) : Promise.resolve([]);
+7
View File
@@ -3,9 +3,16 @@ import './app.css';
import App from './App.svelte'; import App from './App.svelte';
// After the components' styles, so themes override them // After the components' styles, so themes override them
import './themes.css'; import './themes.css';
import './themes-generated.css';
import { startTray } from './lib/tray.svelte.ts'; import { startTray } from './lib/tray.svelte.ts';
import { startTextMenu } from './lib/text-menu.ts'; import { startTextMenu } from './lib/text-menu.ts';
import { startHints } from './lib/hints.ts';
import { isWeb } from './lib/native.ts';
import { proxyInfo } from './lib/web.svelte.ts';
if (isWeb) proxyInfo.load();
mount(App, { target: document.getElementById('app')! }); mount(App, { target: document.getElementById('app')! });
startTray(); startTray();
startTextMenu(); startTextMenu();
startHints();
File diff suppressed because it is too large Load Diff
+134
View File
@@ -89,6 +89,138 @@
color-scheme: dark; color-scheme: dark;
} }
:root[data-theme="forest"], [data-theme-preview="forest"] {
--rail: var(--bg-0);
--bg-0: #0c1410;
--bg-1: #111c16;
--bg-2: #16231c;
--bg-3: #1f3027;
--bg-hover: #263a2f;
--bg-active: #2f4739;
--line: #243529;
--text: #e2ebe4;
--text-dim: #9db3a4;
--text-faint: #67806f;
--accent: #4fbf7b;
--accent-hover: #66d08f;
--accent-text: #07140c;
--speaking: #f2c14e;
--danger: #f0605a;
--warn: #e8b23c;
--link: #7fd4c1;
color-scheme: dark;
}
:root[data-theme="ember"], [data-theme-preview="ember"] {
--rail: var(--bg-0);
--bg-0: #120d0a;
--bg-1: #1a130f;
--bg-2: #211914;
--bg-3: #2d221b;
--bg-hover: #372a21;
--bg-active: #443428;
--line: #33271f;
--text: #efe6dd;
--text-dim: #b5a596;
--text-faint: #7d6f62;
--accent: #f08a3c;
--accent-hover: #ff9d52;
--accent-text: #1a0d03;
--speaking: #5fd08a;
--danger: #f0554e;
--warn: #f0c04a;
--link: #f4b27a;
color-scheme: dark;
}
:root[data-theme="ocean"], [data-theme-preview="ocean"] {
--rail: var(--bg-0);
--bg-0: #071018;
--bg-1: #0b1722;
--bg-2: #0f1e2c;
--bg-3: #172a3c;
--bg-hover: #1d3348;
--bg-active: #254058;
--line: #1b2f42;
--text: #e0ebf5;
--text-dim: #93a9bd;
--text-faint: #5f7689;
--accent: #2fc4e0;
--accent-hover: #56d6ee;
--accent-text: #041018;
--speaking: #4be0a0;
--danger: #ff6259;
--warn: #f0c04a;
--link: #7cc4ff;
color-scheme: dark;
}
:root[data-theme="plum"], [data-theme-preview="plum"] {
--rail: var(--bg-0);
--bg-0: #120c16;
--bg-1: #19111f;
--bg-2: #201628;
--bg-3: #2c1f37;
--bg-hover: #372743;
--bg-active: #443152;
--line: #32243e;
--text: #eee4f3;
--text-dim: #b3a2bf;
--text-faint: #7a6a86;
--accent: #e667a8;
--accent-hover: #f07fba;
--accent-text: #1c0612;
--speaking: #5fd08a;
--danger: #ff6259;
--warn: #f0c04a;
--link: #c9a2ff;
color-scheme: dark;
}
:root[data-theme="paper"], [data-theme-preview="paper"] {
--rail: var(--bg-0);
--bg-0: #e4dccb;
--bg-1: #efe8d9;
--bg-2: #f8f3e7;
--bg-3: #e6dfcf;
--bg-hover: #ded6c3;
--bg-active: #d2c9b3;
--line: #d3cab6;
--text: #2b2620;
--text-dim: #5f574b;
--text-faint: #8a8172;
--accent: #a2542a;
--accent-hover: #8a441f;
--accent-text: #ffffff;
--speaking: #2e7d46;
--danger: #b93a2f;
--warn: #96690f;
--link: #2a5f9e;
color-scheme: light;
}
:root[data-theme="contrast"], [data-theme-preview="contrast"] {
--rail: var(--bg-0);
--bg-0: #000000;
--bg-1: #000000;
--bg-2: #000000;
--bg-3: #1a1a1a;
--bg-hover: #262626;
--bg-active: #3a3a3a;
--line: #8c8c8c;
--text: #ffffff;
--text-dim: #e0e0e0;
--text-faint: #b0b0b0;
--accent: #ffd400;
--accent-hover: #ffe14d;
--accent-text: #000000;
--speaking: #00ff7f;
--danger: #ff6b6b;
--warn: #ffd400;
--link: #7fd4ff;
color-scheme: dark;
}
/* ─── Windows 95 ───────────────────────────────────────────────────────────── */ /* ─── Windows 95 ───────────────────────────────────────────────────────────── */
:root[data-theme="win95"], [data-theme-preview="win95"] { :root[data-theme="win95"], [data-theme-preview="win95"] {
@@ -164,6 +296,8 @@
box-shadow: var(--sunken); box-shadow: var(--sunken);
} }
:root[data-theme="win95"] .composer .box textarea { box-shadow: none; } :root[data-theme="win95"] .composer .box textarea { box-shadow: none; }
/* Raised frame in the window face color around the sunken input */
:root[data-theme="win95"] .composer { border: 2px outset #c0c0c0; }
:root[data-theme="win95"] .rich .area, :root[data-theme="win95"] .rich textarea { box-shadow: none; } :root[data-theme="win95"] .rich .area, :root[data-theme="win95"] .rich textarea { box-shadow: none; }
:root[data-theme="win95"] input[type=range] { accent-color: #000080; } :root[data-theme="win95"] input[type=range] { accent-color: #000080; }
+61 -2
View File
@@ -7,11 +7,26 @@
{ id: 'stacked', name: 'Stacked', note: 'Channels on top, chat below, like Mumble' } { id: 'stacked', name: 'Stacked', note: 'Channels on top, chat below, like Mumble' }
]; ];
const INTERFACES: { id: 'standard' | 'rooms'; name: string; note: string }[] = [
{ id: 'standard', name: 'Standard', note: 'Channel tree, chat and members side by side' },
{ id: 'rooms', name: 'Rooms', note: 'One screen at a time, made for phones. New and not complete yet' }
];
function choose(id: 'standard' | 'rooms') {
store.settings.interface = id;
store.saveSettings();
}
function arrange(id: Arrangement) { function arrange(id: Arrangement) {
store.settings.arrangement = id; store.settings.arrangement = id;
store.saveSettings(); store.saveSettings();
} }
// With this many schemes the list can be narrowed by name and by dark or light
let find = $state('');
let shade = $state<'all' | 'dark' | 'light'>('all');
const shown = $derived(THEMES.filter(t => (shade === 'all' || (shade === 'light') === t.light)
&& (!find.trim() || `${t.name} ${t.note}`.toLowerCase().includes(find.trim().toLowerCase()))));
function pick(id: ThemeId) { function pick(id: ThemeId) {
store.settings.theme = id; store.settings.theme = id;
store.saveSettings(); store.saveSettings();
@@ -19,8 +34,16 @@
</script> </script>
<h3>Color scheme</h3> <h3>Color scheme</h3>
<div class="narrow">
<input type="search" placeholder="Find a color scheme" aria-label="Find a color scheme" bind:value={find} />
<div class="shades" role="group" aria-label="Show">
{#each ['all', 'dark', 'light'] as const as k (k)}
<button class:on={shade === k} aria-pressed={shade === k} onclick={() => (shade = k)}>{k === 'all' ? `All ${THEMES.length}` : k === 'dark' ? 'Dark' : 'Light'}</button>
{/each}
</div>
</div>
<div class="themes" role="radiogroup" aria-label="Color scheme"> <div class="themes" role="radiogroup" aria-label="Color scheme">
{#each THEMES as t (t.id)} {#each shown as t (t.id)}
<button role="radio" aria-checked={store.settings.theme === t.id} class:active={store.settings.theme === t.id} <button role="radio" aria-checked={store.settings.theme === t.id} class:active={store.settings.theme === t.id}
onclick={() => pick(t.id)} aria-label={t.name}> onclick={() => pick(t.id)} aria-label={t.name}>
<!-- A miniature of the app in that theme --> <!-- A miniature of the app in that theme -->
@@ -38,7 +61,32 @@
</button> </button>
{/each} {/each}
</div> </div>
{#if !shown.length}<p class="nothing">No color scheme matches.</p>{/if}
<h3 class="gap">Interface</h3>
<div class="themes" role="radiogroup" aria-label="Interface">
{#each INTERFACES as f (f.id)}
{@const on = (store.settings.interface ?? 'standard') === f.id}
<button role="radio" aria-checked={on} class:active={on} onclick={() => choose(f.id)} aria-label={f.name}>
<span class="mini face {f.id}" aria-hidden="true">
{#if f.id === 'standard'}
<span class="m-rail"><span></span><span></span></span>
<span class="m-side"><i></i><i class="sel"></i><i></i></span>
<span class="m-chat"><span class="m-head"></span><i class="a"></i><i></i><span class="m-box"></span></span>
{:else}
<span class="m-col">
<span class="m-chat"><span class="m-head"></span><i class="a"></i><i></i><i class="short"></i></span>
<span class="m-tabs"><i class="sel"></i><i></i><i></i><i></i></span>
</span>
{/if}
</span>
<span class="name">{f.name}</span>
<span class="note">{f.note}</span>
</button>
{/each}
</div>
{#if (store.settings.interface ?? 'standard') === 'standard'}
<h3 class="gap">Layout</h3> <h3 class="gap">Layout</h3>
<div class="themes" role="radiogroup" aria-label="Layout"> <div class="themes" role="radiogroup" aria-label="Layout">
{#each ARRANGEMENTS as a (a.id)} {#each ARRANGEMENTS as a (a.id)}
@@ -63,10 +111,17 @@
</button> </button>
{/each} {/each}
</div> </div>
<p class="help">Narrow windows always use the phone layout.</p> <p class="help">Narrow windows use the phone layout, except Stacked, which stays as it is.</p>
{/if}
<style> <style>
.gap { margin-top: 22px; } .gap { margin-top: 22px; }
.narrow { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
.narrow input { flex: 1 1 180px; width: auto; }
.shades { display: flex; border: 1px solid var(--line); }
.shades button { padding: 0 14px; font-size: 14px; color: var(--text-dim); }
.shades button.on { background: var(--accent); color: var(--accent-text); }
.nothing { margin: 10px 0 0; font-size: 14px; color: var(--text-dim); }
.help { margin: 6px 0 0; font-size: 13px; color: var(--text-dim); } .help { margin: 6px 0 0; font-size: 13px; color: var(--text-dim); }
.m-col { flex: 1; display: flex; flex-direction: column; min-width: 0; } .m-col { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.m-col .m-chat { flex: 1; } .m-col .m-chat { flex: 1; }
@@ -93,5 +148,9 @@
.m-chat i { height: 4px; background: var(--text-dim); width: 80%; } .m-chat i { height: 4px; background: var(--text-dim); width: 80%; }
.m-chat i.a { background: var(--accent); width: 35%; } .m-chat i.a { background: var(--accent); width: 35%; }
.m-chat i.short { width: 55%; } .m-chat i.short { width: 55%; }
.face.rooms .m-col { flex: 1; display: flex; flex-direction: column; }
.m-tabs { flex: none; height: 12px; display: flex; gap: 6px; align-items: center; justify-content: center; background: var(--bg-0); border-top: 1px solid var(--line); }
.m-tabs i { width: 10px; height: 4px; background: var(--text-faint); }
.m-tabs i.sel { background: var(--accent); }
.m-box { margin-top: auto; height: 10px; background: var(--bg-3); border: 1px solid var(--line); } .m-box { margin-top: auto; height: 10px; background: var(--bg-3); border: 1px solid var(--line); }
</style> </style>
+19 -4
View File
@@ -4,6 +4,9 @@
import { session } from '../lib/session.svelte.ts'; import { session } from '../lib/session.svelte.ts';
import { PERM, type ChannelAcl, type AclEntry, type AclGroup } from '../core/client.ts'; import { PERM, type ChannelAcl, type AclEntry, type AclGroup } from '../core/client.ts';
import { sounds } from '../lib/audio/sounds.svelte.ts'; import { sounds } from '../lib/audio/sounds.svelte.ts';
import { isTextChannel, markText, stripMark } from '../core/text-signal.ts';
import { textChannels } from '../lib/text-channels.ts';
import { sessions } from '../lib/session.svelte.ts';
type Props = type Props =
| { mode: 'create'; parent: number; onclose: () => void; tab?: undefined; id?: undefined } | { mode: 'create'; parent: number; onclose: () => void; tab?: undefined; id?: undefined }
@@ -30,7 +33,11 @@
// svelte-ignore state_referenced_locally // svelte-ignore state_referenced_locally
const initial = channelId != null ? session.channel(channelId) : undefined; const initial = channelId != null ? session.channel(channelId) : undefined;
let name = $state(initial?.name ?? ''); let name = $state(initial?.name ?? '');
let description = $state(initial?.description ?? ''); // The editor never sees the text channel marker; it is put back when saving
let description = $state(stripMark(initial?.description ?? ''));
let textChannel = $state(isTextChannel(initial?.description ?? ''));
// The marker is an HTML comment and the messages are plugin data
const textPossible = textChannels.supported(sessions.active) && session.serverInfo?.config.allowHtml !== false;
let position = $state(initial?.position ?? 0); let position = $state(initial?.position ?? 0);
let maxUsers = $state(initial?.maxUsers ?? 0); let maxUsers = $state(initial?.maxUsers ?? 0);
let temporary = $state(false); let temporary = $state(false);
@@ -43,7 +50,7 @@
let descLoaded = $state(!(initial?.descriptionHash && !initial?.description)); let descLoaded = $state(!(initial?.descriptionHash && !initial?.description));
$effect(() => { $effect(() => {
if (channelId != null && descPending) session.loadDescription(channelId); if (channelId != null && descPending) session.loadDescription(channelId);
if (!descLoaded && channel?.description) { description = channel.description; descLoaded = true; } if (!descLoaded && channel?.description) { description = stripMark(channel.description); textChannel = isTextChannel(channel.description); descLoaded = true; }
}); });
// ─── ACL ──────────────────────────────────────────────────────────────────── // ─── ACL ────────────────────────────────────────────────────────────────────
@@ -217,7 +224,7 @@
error = ''; error = '';
if (mode === 'create') { if (mode === 'create') {
if (!name.trim()) { error = 'The channel needs a name.'; return; } if (!name.trim()) { error = 'The channel needs a name.'; return; }
client.createChannel(parentId!, name.trim(), { temporary, description, position, maxUsers }); client.createChannel(parentId!, name.trim(), { temporary, description: markText(description, textChannel), position, maxUsers });
sounds.play('channelCreated'); sounds.play('channelCreated');
return onclose(); return onclose();
} }
@@ -225,7 +232,9 @@
if (!ch || channelId == null) return onclose(); if (!ch || channelId == null) return onclose();
const fields: { name?: string; description?: string; position?: number; maxUsers?: number } = {}; const fields: { name?: string; description?: string; position?: number; maxUsers?: number } = {};
if (!isRoot && name.trim() && name.trim() !== ch.name) fields.name = name.trim(); if (!isRoot && name.trim() && name.trim() !== ch.name) fields.name = name.trim();
if (descLoaded && description !== ch.description) fields.description = description; // An untouched description may not start with the marker; that alone is no change
const changed = description !== stripMark(ch.description) || textChannel !== isTextChannel(ch.description);
if (descLoaded && changed) fields.description = markText(description, textChannel);
if (position !== ch.position) fields.position = Number(position) || 0; if (position !== ch.position) fields.position = Number(position) || 0;
if (maxUsers !== ch.maxUsers) fields.maxUsers = Number(maxUsers) || 0; if (maxUsers !== ch.maxUsers) fields.maxUsers = Number(maxUsers) || 0;
if (Object.keys(fields).length) client.updateChannel(channelId, fields); if (Object.keys(fields).length) client.updateChannel(channelId, fields);
@@ -268,6 +277,12 @@
<label class="check"><input type="checkbox" bind:checked={temporary} disabled={!canTemp || !canPermanent} /> Temporary (removed when empty)</label> <label class="check"><input type="checkbox" bind:checked={temporary} disabled={!canTemp || !canPermanent} /> Temporary (removed when empty)</label>
{/if} {/if}
</div> </div>
{#if !isRoot}
<label class="check top" title="Read and written without joining, from any channel. Only mumh5 shows it this way; in other Mumble clients it is an ordinary channel and its messages are not visible there. The server keeps no messages: mumh5 clients keep them and pass the recent ones on to people who connect later. Everyone on the server who uses mumh5 can read it.">
<input type="checkbox" bind:checked={textChannel} disabled={!textPossible} /> Text channel (mumh5 only)
</label>
{#if !textPossible}<p class="dim small">Text channels need a server from version 1.4 that allows HTML.</p>{/if}
{/if}
<p class="dim small">Lower positions come first. A user limit of 0 uses the server default.</p> <p class="dim small">Lower positions come first. A user limit of 0 uses the server default.</p>
{:else if !acl} {:else if !acl}
<p class="dim">Loading permissions...</p> <p class="dim">Loading permissions...</p>
+200 -28
View File
@@ -10,7 +10,12 @@
import { store } from '../lib/settings.svelte.ts'; import { store } from '../lib/settings.svelte.ts';
import { drag } from '../lib/drag.svelte.ts'; import { drag } from '../lib/drag.svelte.ts';
import { sounds } from '../lib/audio/sounds.svelte.ts'; import { sounds } from '../lib/audio/sounds.svelte.ts';
import { renderIncoming } from '../lib/html.ts';
import { share } from '../lib/share.svelte.ts';
import { sessions } from '../lib/session.svelte.ts';
import type { Channel } from '../core/client.ts'; import type { Channel } from '../core/client.ts';
import { stripMark } from '../core/text-signal.ts';
import { planMove } from '../core/channel-order.ts';
let { channel, depth = 0, onnavigate }: { channel: Channel; depth?: number; onnavigate: () => void } = $props(); let { channel, depth = 0, onnavigate }: { channel: Channel; depth?: number; onnavigate: () => void } = $props();
@@ -18,10 +23,73 @@
const children = $derived(session.childrenOf(channel.id)); const children = $derived(session.childrenOf(channel.id));
const expanded = $derived(session.isExpanded(channel.id)); const expanded = $derived(session.isExpanded(channel.id));
const here = $derived(session.self?.channelId === channel.id); const here = $derived(session.self?.channelId === channel.id);
const selected = $derived(session.panel.kind === 'channel' && session.panel.id === channel.id); // Read and written without joining: a click opens its chat
const isText = $derived(session.isText(channel.id));
const selected = $derived((session.panel.kind === 'channel' && session.panel.id === channel.id) || (isText && session.view === `ch:${channel.id}`));
const unread = $derived(session.unread[`ch:${channel.id}`] ?? 0); const unread = $derived(session.unread[`ch:${channel.id}`] ?? 0);
const total = $derived(session.subtreeUserCount(channel.id)); const total = $derived(session.subtreeUserCount(channel.id));
// Resting on a channel that has a description for 1 second opens a quick preview next to its row
const hasDescription = $derived(!!(stripMark(channel.description) || (!channel.description && channel.descriptionHash)));
const descriptionHtml = $derived(channel.description ? renderIncoming(channel.description, store.trustedHosts).html : '');
let preview = $state<{ x: number; y: number } | null>(null);
let hoverTimer: ReturnType<typeof setTimeout> | undefined;
// Where the pointer is while it rests on the row; the preview opens there
let pointer = { x: 0, y: 0 };
function trackPointer(e: MouseEvent) {
pointer = { x: e.clientX, y: e.clientY };
}
function hoverRow(e: MouseEvent) {
if (!hasDescription) return;
trackPointer(e);
clearTimeout(hoverTimer);
hoverTimer = setTimeout(() => {
// Not while something is dragged, and not when the row moved away in the meantime
if (drag.item || !rowEl?.matches(':hover')) return;
// 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);
// An image opened from the preview covers it; the preview stays underneath
hoverTimer = setTimeout(() => { if (!ui.image) preview = null; }, 250);
}
// A row that moves away under the pointer (after a drop) never reports the pointer leaving,
// so an open preview also watches where the pointer is
let rowEl = $state<HTMLElement>();
$effect(() => {
if (!preview) return;
let away: ReturnType<typeof setTimeout> | undefined;
const inside = () => !!rowEl?.matches(':hover') || !!popEl?.matches(':hover') || !!ui.image;
const check = () => {
if (inside()) { clearTimeout(away); away = undefined; }
else away ??= setTimeout(() => { away = undefined; if (!inside()) preview = null; }, 300);
};
window.addEventListener('mousemove', check);
return () => { window.removeEventListener('mousemove', check); clearTimeout(away); };
});
// Back from the image: the preview stays while the pointer is on it, and goes when it is not
let popEl = $state<HTMLElement>();
let hadImage = false;
$effect(() => {
const open = !!ui.image;
if (hadImage && !open && preview && !popEl?.matches(':hover')) {
clearTimeout(hoverTimer);
hoverTimer = setTimeout(() => { if (!popEl?.matches(':hover')) preview = null; }, 1500);
}
hadImage = open;
});
function showDescription() {
clearTimeout(hoverTimer);
preview = null;
session.showChannel(channel.id, 'info');
ui.panelOpen = true;
}
// Single click previews, double click acts. The single action waits briefly so a // Single click previews, double click acts. The single action waits briefly so a
// double click does not also flash the preview. // double click does not also flash the preview.
let timer: ReturnType<typeof setTimeout> | undefined; let timer: ReturnType<typeof setTimeout> | undefined;
@@ -32,44 +100,100 @@
}; };
} }
// Drop a person to move them here, or a channel to make it a subchannel of this one // Drop a person to move them here. A channel dropped on the middle of a row becomes a
let over = $state(false); // subchannel; dropped on the upper or lower third it goes in front of or behind this one.
const canDrop = $derived.by(() => { type Zone = 'before' | 'after' | 'into';
let zone = $state<Zone | null>(null);
const dragging = $derived(drag.item?.kind === 'channel' && drag.item.id === channel.id);
const open = $derived(expanded && (children.length > 0 || users.length > 0));
function intoAllowed(): boolean {
const d = drag.item; const d = drag.item;
if (!d) return false; if (!d) return false;
if (d.kind === 'user') return session.user(d.id)?.channelId !== channel.id; if (d.kind === 'user') return session.user(d.id)?.channelId !== channel.id;
return d.id !== channel.id && channel.id !== session.channel(d.id)?.parent && !session.isInside(channel.id, d.id); return d.id !== channel.id && channel.id !== session.channel(d.id)?.parent && !session.isInside(channel.id, d.id);
}); }
function onDrop(e: DragEvent) { function zoneAt(e: DragEvent, row: HTMLElement): Zone | null {
e.preventDefault();
over = false;
// Check before clearing: canDrop is derived from the dragged item
const d = drag.item; const d = drag.item;
const ok = canDrop; if (!d) return null;
const into = intoAllowed();
if (d.kind === 'user' || channel.id === 0) return into ? 'into' : null;
if (d.id === channel.id || session.isInside(channel.id, d.id)) return null;
const box = row.getBoundingClientRect();
const y = (e.clientY - box.top) / box.height;
if (y < 0.34) return 'before';
if (y > 0.66) return 'after';
if (into) return 'into';
return y < 0.5 ? 'before' : 'after';
}
// Resting on a closed channel opens it, to reach the channels inside
let openTimer: ReturnType<typeof setTimeout> | undefined;
function setZone(z: Zone | null) {
if (z === zone) return;
zone = z;
clearTimeout(openTimer);
if (z === 'into' && !expanded && children.length) openTimer = setTimeout(() => { if (zone === 'into' && !expanded) session.toggleExpanded(channel.id); }, 700);
}
function onDragOver(e: DragEvent, fixed?: Zone) {
const z = fixed ? (intoAllowed() ? fixed : null) : zoneAt(e, e.currentTarget as HTMLElement);
if (z) e.preventDefault();
setZone(z);
}
// Leaving for an element inside the same target is not leaving
function onDragLeave(e: DragEvent) {
if (!(e.currentTarget as HTMLElement).contains(e.relatedTarget as Node | null)) setZone(null);
}
function onDrop(e: DragEvent, fixed?: Zone) {
e.preventDefault();
// Work out the target before clearing: it depends on the dragged item
const d = drag.item;
const z = fixed ? (intoAllowed() ? fixed : null) : zoneAt(e, e.currentTarget as HTMLElement);
setZone(null);
drag.item = null; drag.item = null;
if (!d || !ok) return; const client = session.client;
if (!d || !z || !client) return;
if (d.kind === 'user') { if (d.kind === 'user') {
if (d.id === session.self?.session) session.joinChannel(channel.id); if (d.id === session.self?.session) session.joinChannel(channel.id);
else session.moveUser(d.id, channel.id); else session.moveUser(d.id, channel.id);
} else { return;
session.client?.updateChannel(d.id, { parent: channel.id });
sounds.play('channelMoved');
} }
if (z === 'into') {
client.updateChannel(d.id, { parent: channel.id });
} else {
const moved = session.channel(d.id);
if (!moved || channel.parent == null) return;
const rest = session.childrenOf(channel.parent).filter(c => c.id !== d.id);
const plan = planMove(rest, moved, rest.findIndex(c => c.id === channel.id) + (z === 'after' ? 1 : 0));
const own = plan.find(u => u.id === d.id);
const reparent = moved.parent !== channel.parent;
if (!reparent && !plan.length) return;
if (reparent || own) client.updateChannel(d.id, { ...(reparent ? { parent: channel.parent } : {}), ...(own ? { position: own.position } : {}) });
for (const u of plan) if (u.id !== d.id) client.updateChannel(u.id, { position: u.position });
}
sounds.play('channelMoved');
} }
const channelClicks = clickOrDouble( const openText = () => { session.selectView(`ch:${channel.id}`); onnavigate(); };
const channelClicks = $derived(isText ? { onclick: openText } : clickOrDouble(
() => { session.showChannel(channel.id); ui.panelOpen = true; }, () => { session.showChannel(channel.id); ui.panelOpen = true; },
() => { session.joinChannel(channel.id); onnavigate(); } () => { session.joinChannel(channel.id); onnavigate(); }
); ));
</script> </script>
<li> <!-- Behind an open channel is below its people and subchannels; the line is drawn there -->
<div class="row channel" class:here class:selected class:over style:padding-left="{8 + depth * 14}px" <li class:after-end={zone === 'after' && open} style:--indent="{8 + depth * 14}px">
<div class="row channel" class:here class:selected class:dragging class:into={zone === 'into'} class:before={zone === 'before'} class:after={zone === 'after' && !open} class:target={zone === 'after' && open}
style:padding-left="{8 + depth * 14}px"
oncontextmenu={e => menu.show(e, channelMenu(channel))} role="presentation" oncontextmenu={e => menu.show(e, channelMenu(channel))} role="presentation"
ondragover={e => { if (canDrop) { e.preventDefault(); over = true; } }} ondragover={onDragOver}
ondragleave={() => (over = false)} ondragleave={onDragLeave}
ondrop={onDrop}> ondrop={onDrop} bind:this={rowEl}
onmouseenter={hoverRow} onmousemove={trackPointer} onmouseleave={leaveRow}>
{#if children.length || users.length} {#if children.length || users.length}
<button class="caret" class:collapsed={!expanded} onclick={() => session.toggleExpanded(channel.id)} <button class="caret" class:collapsed={!expanded} onclick={() => session.toggleExpanded(channel.id)}
aria-label={expanded ? 'Collapse' : 'Expand'} aria-expanded={expanded}> aria-label={expanded ? 'Collapse' : 'Expand'} aria-expanded={expanded}>
@@ -79,19 +203,32 @@
<span class="caret"></span> <span class="caret"></span>
{/if} {/if}
<!-- The name is what people grab; a drag cannot start from a button inside a draggable element --> <!-- The name is what people grab; a drag cannot start from a button inside a draggable element -->
<button class="name" title="Click for details, double-click to join" {...channelClicks} <button class="name" class:text={isText} title={isText ? 'Text channel: click to open' : 'Click for details, double-click to join'} {...channelClicks}
draggable={channel.id !== 0} draggable={channel.id !== 0}
ondragstart={e => { drag.item = { kind: 'channel', id: channel.id }; e.dataTransfer?.setData('text/plain', channel.name); }} ondragstart={e => { clearTimeout(hoverTimer); preview = null; drag.item = { kind: 'channel', id: channel.id }; e.dataTransfer?.setData('text/plain', channel.name); }}
ondragend={() => { drag.item = null; over = false; }}> ondragend={() => { drag.item = null; setZone(null); }}>
{#if !channel.canEnter}<Icon name="lock" size={14} />{/if} {#if isText}<Icon name="hash" size={14} />{:else if !channel.canEnter}<Icon name="lock" size={14} />{/if}
<span class="label">{channel.name || 'Root'}</span> <span class="label">{channel.name || 'Root'}</span>
{#if !expanded && total > 0}<span class="count" title="{total} {total === 1 ? 'person' : 'people'} inside">{total}</span>{/if} {#if !expanded && total > 0}<span class="count" title="{total} {total === 1 ? 'person' : 'people'} inside">{total}</span>{/if}
{#if unread}<span class="badge">{unread}</span>{/if} {#if unread}<span class="badge">{unread}</span>{/if}
</button> </button>
{#if hasDescription}
<button class="desc" title="Show description" aria-label="Show description of {channel.name || 'Root'}"
onclick={showDescription}><span></span></button>
{/if}
</div> </div>
{#if preview}
<div class="desc-pop" role="tooltip" bind:this={popEl} style:left="{preview.x}px" style:top="{preview.y}px"
onmouseenter={() => clearTimeout(hoverTimer)} onmouseleave={leaveRow}>
<strong>{channel.name || 'Root'}</strong>
{#if descriptionHtml}<div class="html">{@html descriptionHtml}</div>{:else}<p>Loading description...</p>{/if}
</div>
{/if}
{#if users.length && expanded} {#if users.length && expanded}
<ul class="users"> <!-- The people under a channel belong to its drop target, so there is more to aim at -->
<ul class="users" class:into={zone === 'into'} role="presentation"
ondragover={e => onDragOver(e, 'into')} ondragleave={onDragLeave} ondrop={e => onDrop(e, 'into')}>
{#each users as u (u.session)} {#each users as u (u.session)}
{@const isSelf = u.session === session.self?.session} {@const isSelf = u.session === session.self?.session}
{@const clicks = clickOrDouble( {@const clicks = clickOrDouble(
@@ -108,6 +245,18 @@
<Avatar name={u.name} size={22} speaking={voice.talking[u.session] || (isSelf && voice.transmitting)} /> <Avatar name={u.name} size={22} speaking={voice.talking[u.session] || (isSelf && voice.transmitting)} />
<span class="label" class:talking={voice.talking[u.session] || (isSelf && voice.transmitting)}>{store.displayName(u)}</span> <span class="label" class:talking={voice.talking[u.session] || (isSelf && voice.transmitting)}>{store.displayName(u)}</span>
{#if u.userId != null}<span class="reg" title="Registered">R</span>{/if} {#if u.userId != null}<span class="reg" title="Registered">R</span>{/if}
{#if session.sharing[u.session]}
{@const kind = session.sharing[u.session]}
{#if isSelf}
<span class="state sharing" title="You are sharing your {kind}"><Icon name={kind} size={14} /></span>
{:else}
<!-- A button cannot sit inside the row's button -->
<span class="state sharing" role="button" tabindex="0" title="Sharing their {kind}. Click to watch" aria-label="Watch the {kind} of {store.displayName(u)}"
onclick={e => { e.stopPropagation(); clearTimeout(timer); share.watch(sessions.active, u.session); }}
ondblclick={e => e.stopPropagation()}
onkeydown={e => { if (e.key === 'Enter') { e.stopPropagation(); share.watch(sessions.active, u.session); } }}><Icon name={kind} size={14} /></span>
{/if}
{/if}
{#if u.comment || u.commentHash}<span class="state" title="Has a description"><Icon name="message" size={12} /></span>{/if} {#if u.comment || u.commentHash}<span class="state" title="Has a description"><Icon name="message" size={12} /></span>{/if}
{#if u.prioritySpeaker}<span class="state" title="Priority speaker"><Icon name="channel" size={14} /></span>{/if} {#if u.prioritySpeaker}<span class="state" title="Priority speaker"><Icon name="channel" size={14} /></span>{/if}
{#if u.recording}<span class="rec" title="Recording">REC</span>{/if} {#if u.recording}<span class="rec" title="Recording">REC</span>{/if}
@@ -136,14 +285,35 @@
.row { display: flex; align-items: center; gap: 4px; width: 100%; min-height: 30px; padding-right: 8px; color: var(--text-dim); } .row { display: flex; align-items: center; gap: 4px; width: 100%; min-height: 30px; padding-right: 8px; color: var(--text-dim); }
.channel:hover, .user:hover { background: var(--bg-hover); color: var(--text); } .channel:hover, .user:hover { background: var(--bg-hover); color: var(--text); }
.channel.selected { background: var(--bg-active); color: var(--text); } .channel.selected { background: var(--bg-active); color: var(--text); }
.channel.over { background: var(--accent); color: var(--accent-text); } /* Drop targets: the whole row for "into", a line with a square end for "in front of" and "behind" */
.channel.over .label { color: var(--accent-text); } .channel { position: relative; }
.channel.dragging { opacity: 0.4; }
.channel.into { background: var(--accent); color: var(--accent-text); outline: 2px solid var(--accent-hover); outline-offset: -2px; }
.channel.into .label { color: var(--accent-text); }
.users.into { background: var(--bg-active); box-shadow: inset 3px 0 0 var(--accent); }
li.after-end { position: relative; }
.channel.before::before, .channel.after::before, li.after-end::before { content: ''; position: absolute; z-index: 2; left: var(--indent); right: 4px; height: 4px; background: var(--accent); pointer-events: none; }
.channel.before::after, .channel.after::after, li.after-end::after { content: ''; position: absolute; z-index: 2; left: calc(var(--indent) - 4px); width: 12px; height: 12px; background: var(--accent); pointer-events: none; }
.channel.before::before { top: -2px; }
.channel.before::after { top: -6px; }
.channel.after::before, li.after-end::before { bottom: -2px; }
.channel.after::after, li.after-end::after { bottom: -6px; }
.channel.before, .channel.after, .channel.target { background: var(--bg-hover); color: var(--text); }
.channel.here .label { color: var(--text); font-weight: 600; } .channel.here .label { color: var(--text); font-weight: 600; }
.channel.here { box-shadow: inset 3px 0 0 var(--speaking); } .channel.here { box-shadow: inset 3px 0 0 var(--speaking); }
.caret { width: 14px; flex: none; display: inline-flex; justify-content: center; color: var(--text-faint); } .caret { width: 14px; flex: none; display: inline-flex; justify-content: center; color: var(--text-faint); }
.caret.collapsed svg { transform: rotate(-90deg); } .caret.collapsed svg { transform: rotate(-90deg); }
.name { flex: 1; min-width: 0; display: flex; align-items: center; gap: 6px; padding: 4px 0; text-align: left; color: inherit; user-select: none; } .name { flex: 1; min-width: 0; display: flex; align-items: center; gap: 6px; padding: 4px 0; text-align: left; color: inherit; user-select: none; }
.label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.desc { flex: none; width: 20px; height: 20px; display: inline-flex; align-items: center; justify-content: center; }
.desc span { width: 9px; height: 9px; background: var(--warn); }
.desc:hover span { outline: 2px solid var(--warn); outline-offset: 1px; }
.desc-pop { position: fixed; z-index: 40; width: 340px; max-width: calc(100vw - 16px); max-height: 320px; overflow: auto; padding: 10px 12px;
background: var(--bg-2); border: 1px solid var(--line); color: var(--text); font-size: 14px; box-shadow: 0 8px 32px rgb(0 0 0 / 0.4); }
.desc-pop strong { display: block; margin-bottom: 6px; }
.desc-pop p { margin: 0; color: var(--text-dim); }
.desc-pop .html { overflow-wrap: anywhere; user-select: text; }
.desc-pop .html :global(img) { max-width: 100%; }
.count { font-size: 11px; color: var(--text-faint); border: 1px solid var(--line); padding: 0 5px; line-height: 16px; } .count { font-size: 11px; color: var(--text-faint); border: 1px solid var(--line); padding: 0 5px; line-height: 16px; }
.badge { background: var(--danger); color: #fff; font-size: 11px; font-weight: 700; padding: 0 5px; line-height: 16px; } .badge { background: var(--danger); color: #fff; font-size: 11px; font-weight: 700; padding: 0 5px; line-height: 16px; }
.user { gap: 8px; text-align: left; font-size: 14px; user-select: none; } .user { gap: 8px; text-align: left; font-size: 14px; user-select: none; }
@@ -152,5 +322,7 @@
.reg { font-size: 10px; font-weight: 700; color: var(--accent-hover); border: 1px solid var(--accent); padding: 0 3px; line-height: 14px; } .reg { font-size: 10px; font-weight: 700; color: var(--accent-hover); border: 1px solid var(--accent); padding: 0 3px; line-height: 14px; }
.state { color: var(--text-faint); display: inline-flex; } .state { color: var(--text-faint); display: inline-flex; }
.state.admin { color: var(--danger); } .state.admin { color: var(--danger); }
.state.sharing { color: var(--speaking); padding: 3px; }
.state.sharing[role=button]:hover { background: var(--bg-active); }
.rec { font-size: 10px; font-weight: 700; color: var(--danger); border: 1px solid var(--danger); padding: 0 3px; } .rec { font-size: 10px; font-weight: 700; color: var(--danger); border: 1px solid var(--danger); padding: 0 3px; }
</style> </style>
+32 -4
View File
@@ -3,13 +3,25 @@
import ChatLog from './ChatLog.svelte'; import ChatLog from './ChatLog.svelte';
import Composer from './Composer.svelte'; import Composer from './Composer.svelte';
import VoiceStage from './VoiceStage.svelte'; import VoiceStage from './VoiceStage.svelte';
import ResizeHandle from './ResizeHandle.svelte';
import { share } from '../lib/share.svelte.ts';
import { ui } from '../lib/ui.svelte.ts'; import { ui } from '../lib/ui.svelte.ts';
import { session } from '../lib/session.svelte.ts'; import { session } from '../lib/session.svelte.ts';
// showStage: voice tiles above the chat when the window is too narrow for the member list // showStage: voice tiles above the chat when the window is too narrow for the member list
let { onmenu, onpanel, showMenu, showStage = false }: { onmenu: () => void; onpanel: () => void; showMenu: boolean; showStage?: boolean } = $props(); // videoOnly: streams as a plain picture without the tiles (stacked layout)
let { onmenu, onpanel, showMenu, showStage = false, videoOnly = false }: { onmenu: () => void; onpanel: () => void; showMenu: boolean; showStage?: boolean; videoOnly?: boolean } = $props();
// Stacked layout with a stream above the chat: the chat's height can be dragged larger than
// its minimum; the picture gets the rest. Unset until dragged, then the picture decides.
const LOG_MIN = 124;
const here = (s: { server?: { id: string } | null } | null | undefined) => !!s && s.server?.id === session.server?.id;
const streamAbove = $derived(videoOnly && session.status === 'connected' && ((!!share.stream && here(share.host)) || share.watches.some(w => here(w.host))));
let logHeight = $state(Number((() => { try { return localStorage.getItem('mumh5.stackedChatHeight'); } catch { return null; } })()) || 0);
const logFixed = $derived(streamAbove && logHeight >= LOG_MIN);
const isDm = $derived(session.view.startsWith('dm:')); const isDm = $derived(session.view.startsWith('dm:'));
const isText = $derived(!isDm && session.isText(Number(session.view.slice(3))));
const title = $derived(session.status === 'connected' ? session.viewTitle() : 'mumh5'); const title = $derived(session.status === 'connected' ? session.viewTitle() : 'mumh5');
const topic = $derived.by(() => { const topic = $derived.by(() => {
if (isDm || session.status !== 'connected') return ''; if (isDm || session.status !== 'connected') return '';
@@ -17,7 +29,9 @@
}); });
</script> </script>
<section class="chat" aria-label="Chat"> <section class="chat" class:video-only={videoOnly} class:log-fixed={logFixed} aria-label="Chat">
<!-- Stacked layout: no title bar; the channel list above already names the channel -->
{#if !videoOnly}
<header> <header>
{#if showMenu}<button class="icon-btn" onclick={onmenu} aria-label="Open channels"><Icon name="menu" /></button>{/if} {#if showMenu}<button class="icon-btn" onclick={onmenu} aria-label="Open channels"><Icon name="menu" /></button>{/if}
<Icon name={isDm ? 'message' : 'hash'} size={20} /> <Icon name={isDm ? 'message' : 'hash'} size={20} />
@@ -26,8 +40,9 @@
<span class="grow"></span> <span class="grow"></span>
<button class="icon-btn" onclick={onpanel} title="Members"><Icon name="users" /></button> <button class="icon-btn" onclick={onpanel} title="Members"><Icon name="users" /></button>
</header> </header>
{/if}
{#if showStage && session.status === 'connected'}<VoiceStage />{/if} {#if session.status === 'connected'}<VoiceStage always={showStage} {videoOnly} />{/if}
{#if session.status === 'idle' && session.disconnectInfo} {#if session.status === 'idle' && session.disconnectInfo}
{@const d = session.disconnectInfo} {@const d = session.disconnectInfo}
@@ -47,12 +62,17 @@
{/if} {/if}
<div class="logbox" style:height={logFixed ? `${logHeight}px` : undefined}>
{#if streamAbove}
<ResizeHandle bind:width={logHeight} edge="top" min={LOG_MIN} max={() => Math.max(LOG_MIN, window.innerHeight - 320)} initial={220}
storageKey="mumh5.stackedChatHeight" label="Resize the chat" />
{/if}
<ChatLog key={session.view}> <ChatLog key={session.view}>
{#snippet empty()} {#snippet empty()}
<div class="empty"> <div class="empty">
{#if session.status === 'connected'} {#if session.status === 'connected'}
<h2>{isDm ? `Direct messages with ${title}` : `Welcome to #${title}`}</h2> <h2>{isDm ? `Direct messages with ${title}` : `Welcome to #${title}`}</h2>
<p>{isDm ? 'Messages here are only visible to the two of you.' : 'Messages go to everyone in this channel. Drop files here to share them.'}</p> <p>{isDm ? 'Messages here are only visible to the two of you.' : isText ? 'A text channel: everyone on this server who uses mumh5 can read and write here, from any channel. Other Mumble clients do not see these messages.' : 'Messages go to everyone in this channel. Drop files here to share them.'}</p>
{:else if session.status === 'connecting'} {:else if session.status === 'connecting'}
<h2>Connecting...</h2> <h2>Connecting...</h2>
{:else} {:else}
@@ -66,6 +86,7 @@
</div> </div>
{/snippet} {/snippet}
</ChatLog> </ChatLog>
</div>
{#if session.status === 'connected'} {#if session.status === 'connected'}
<Composer target={session.view} placeholder={`Message ${isDm ? '@' : '#'}${title}`} /> <Composer target={session.view} placeholder={`Message ${isDm ? '@' : '#'}${title}`} />
@@ -73,6 +94,13 @@
</section> </section>
<style> <style>
/* Stacked layout: room for about five lines of chat whatever the stream above takes */
.chat.video-only .logbox { min-height: 124px; flex-shrink: 0; }
.logbox { position: relative; flex: 1; min-height: 0; display: flex; flex-direction: column; }
/* Dragged to a height of its own: the stream above takes what is left */
.chat.log-fixed .logbox { flex: none; }
.chat.log-fixed :global(.stage.solo), .chat.log-fixed :global(.stage.solo .spot), .chat.log-fixed :global(.stage.solo .pic) { flex: 1 1 0; min-height: 0; }
.chat.log-fixed :global(.stage.solo .spot video) { flex: 1 1 0; max-height: none; }
.chat { position: relative; flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--bg-2); } .chat { position: relative; flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--bg-2); }
header { height: 52px; flex: none; display: flex; align-items: center; gap: 8px; padding: 0 8px 0 16px; border-bottom: 1px solid var(--line); color: var(--text-dim); } header { height: 52px; flex: none; display: flex; align-items: center; gap: 8px; padding: 0 8px 0 16px; border-bottom: 1px solid var(--line); color: var(--text-dim); }
h1 { flex: 0 1 auto; max-width: 60%; font-size: 16px; margin: 0; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } h1 { flex: 0 1 auto; max-width: 60%; font-size: 16px; margin: 0; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+1 -1
View File
@@ -32,7 +32,7 @@
const grouped = (i: number) => { const grouped = (i: number) => {
const a = entries[i - 1], b = entries[i]; const a = entries[i - 1], b = entries[i];
return !!a && a.kind === 'text' && b.kind === 'text' && a.authorSession === b.authorSession && a.self === b.self && b.time - a.time < 5 * 60000; return !!a && a.kind === 'text' && b.kind === 'text' && a.authorSession === b.authorSession && a.author === b.author && a.self === b.self && b.time - a.time < 5 * 60000;
}; };
</script> </script>
+4 -4
View File
@@ -132,11 +132,11 @@
</div> </div>
<style> <style>
/* Same height as the user panel at the bottom of the sidebar; grows for longer messages */ /* Same height as the bar at the bottom of the sidebar, buttons centered like there; grows for longer messages */
.composer { flex: none; min-height: var(--footer-h); display: flex; flex-direction: column; justify-content: flex-end; padding: 0; background: var(--bg-0); } .composer { flex: none; min-height: var(--footer-h); display: flex; flex-direction: column; justify-content: flex-end; padding: 0; background: var(--bg-0); border: 1px solid var(--line); }
.frame { background: var(--bg-3); } .frame { flex: 1; display: flex; flex-direction: column; background: var(--bg-3); }
.frame :global(.toolbar) { background: var(--bg-2); } .frame :global(.toolbar) { background: var(--bg-2); }
.box { display: flex; align-items: flex-end; gap: 4px; min-height: var(--footer-h); padding: 6px 4px; } .box { flex: 1; display: flex; align-items: center; gap: 4px; padding: 0 8px; }
.input { position: relative; flex: 1; min-width: 0; padding: 8px 4px; max-height: 200px; overflow-y: auto; line-height: 1.4; outline: none; overflow-wrap: anywhere; white-space: pre-wrap; } .input { position: relative; flex: 1; min-width: 0; padding: 8px 4px; max-height: 200px; overflow-y: auto; line-height: 1.4; outline: none; overflow-wrap: anywhere; white-space: pre-wrap; }
.input.blank::before { content: attr(data-placeholder); position: absolute; left: 4px; top: 8px; color: var(--text-faint); pointer-events: none; } .input.blank::before { content: attr(data-placeholder); position: absolute; left: 4px; top: 8px; color: var(--text-faint); pointer-events: none; }
.input :global(img) { max-height: 120px; } .input :global(img) { max-height: 120px; }
+47 -8
View File
@@ -4,6 +4,8 @@
import { sessions } from '../lib/session.svelte.ts'; import { sessions } from '../lib/session.svelte.ts';
import { identities } from '../lib/identities.svelte.ts'; import { identities } from '../lib/identities.svelte.ts';
import { makeServerIcon } from '../lib/upload.ts'; import { makeServerIcon } from '../lib/upload.ts';
import { isWeb } from '../lib/native.ts';
import { proxyInfo } from '../lib/web.svelte.ts';
// prefill: values for a new server, e.g. picked from the public list // prefill: values for a new server, e.g. picked from the public list
let { server, prefill, onclose }: { server: SavedServer | null; prefill?: { host?: string; port?: number; label?: string }; onclose: () => void } = $props(); let { server, prefill, onclose }: { server: SavedServer | null; prefill?: { host?: string; port?: number; label?: string }; onclose: () => void } = $props();
@@ -15,14 +17,41 @@
let label = $state(initial?.label ?? pre.label ?? ''); let label = $state(initial?.label ?? pre.label ?? '');
let host = $state(initial?.host ?? pre.host ?? ''); let host = $state(initial?.host ?? pre.host ?? '');
let port = $state(initial?.port ?? pre.port ?? 64738); let port = $state(initial?.port ?? pre.port ?? 64738);
// New servers start with the name you used last
let username = $state(initial?.username ?? store.servers.at(-1)?.username ?? '');
let password = $state(initial?.password ?? ''); let password = $state(initial?.password ?? '');
let identityId = $state(initial?.identityId ?? ''); let identityId = $state(initial?.identityId ?? '');
// The name chosen for an identity when it was created, if any
const identityName = (id: string) => store.identityNames[identities.byId(id || undefined)?.id ?? ''] ?? '';
// New servers start with the identity's name, else the name you used last
const firstName = initial?.username ?? (identityName('') || store.servers.at(-1)?.username || '');
let username = $state(firstName);
// Picking another identity swaps the suggestion, unless the name was typed by hand
let suggested = firstName;
function identityChanged() {
const next = identityName(identityId);
if (next && (!username.trim() || username === suggested)) username = suggested = next;
}
let icon = $state(initial?.icon ?? ''); let icon = $state(initial?.icon ?? '');
let iconError = $state(''); let iconError = $state('');
let iconPicker: HTMLInputElement; let iconPicker: HTMLInputElement;
// In the browser the proxy decides which servers can be reached; offer those instead of free text
const listed = $derived(isWeb && !proxyInfo.any ? proxyInfo.servers : null);
const pick = $derived(listed ? `${host}:${port}` : '');
function choose(value: string) {
const s = listed?.find(x => `${x.host}:${x.port}` === value);
if (!s) return;
// Keep a label the user typed; replace one that came from the previous choice
if (!label.trim() || listed?.some(x => x.label === label)) label = s.label;
host = s.host;
port = s.port;
}
// Refreshed per dialog, so a changed allowlist shows up without a reload
if (isWeb) proxyInfo.load();
// A new server starts on the first one the proxy offers
$effect(() => {
if (!initial && listed?.length && !listed.some(x => x.host === host && x.port === port)) choose(`${listed[0].host}:${listed[0].port}`);
});
async function pickIcon() { async function pickIcon() {
const file = iconPicker.files?.[0]; const file = iconPicker.files?.[0];
iconPicker.value = ''; iconPicker.value = '';
@@ -62,18 +91,27 @@
<Modal title={initial ? 'Edit server' : 'Add a server'} {onclose}> <Modal title={initial ? 'Edit server' : 'Add a server'} {onclose}>
<form id="connect" onsubmit={saveAndConnect}> <form id="connect" onsubmit={saveAndConnect}>
<label for="c-host">Address</label> {#if listed}
<div class="row"> <label for="c-host">Server</label>
<input id="c-host" bind:value={host} placeholder="mumble.example.com" autocomplete="off" required /> <select id="c-host" value={pick} onchange={e => choose(e.currentTarget.value)}>
<input class="port" type="number" bind:value={port} min="1" max="65535" aria-label="Port" /> {#each listed as s (`${s.host}:${s.port}`)}<option value={`${s.host}:${s.port}`}>{s.label === s.host ? '' : `${s.label} - `}{s.host}{s.port !== 64738 ? `:${s.port}` : ''}</option>{/each}
</div> </select>
<p class="note">This proxy connects to the servers listed here.</p>
{:else}
<label for="c-host">Address</label>
<div class="row">
<input id="c-host" bind:value={host} placeholder="mumble.example.com" autocomplete="off" required />
<input class="port" type="number" bind:value={port} min="1" max="65535" aria-label="Port" />
</div>
{/if}
{#if isWeb && proxyInfo.error}<p class="err">{proxyInfo.error}</p>{/if}
<label for="c-user">Username</label> <label for="c-user">Username</label>
<input id="c-user" bind:value={username} autocomplete="username" required /> <input id="c-user" bind:value={username} autocomplete="username" required />
<label for="c-pass">Password <span class="opt">(optional)</span></label> <label for="c-pass">Password <span class="opt">(optional)</span></label>
<input id="c-pass" type="password" bind:value={password} autocomplete="current-password" /> <input id="c-pass" type="password" bind:value={password} autocomplete="current-password" />
{#if identities.list.length > 1} {#if identities.list.length > 1}
<label for="c-id">Identity</label> <label for="c-id">Identity</label>
<select id="c-id" bind:value={identityId}> <select id="c-id" bind:value={identityId} onchange={identityChanged}>
<option value="">Default ({identities.default?.name})</option> <option value="">Default ({identities.default?.name})</option>
{#each identities.list as i (i.id)}<option value={i.id}>{i.name}</option>{/each} {#each identities.list as i (i.id)}<option value={i.id}>{i.name}</option>{/each}
</select> </select>
@@ -104,6 +142,7 @@
.icon-row { display: flex; align-items: center; gap: 8px; } .icon-row { display: flex; align-items: center; gap: 8px; }
.preview { width: 44px; height: 44px; flex: none; display: flex; align-items: center; justify-content: center; background: var(--bg-3); font-weight: 700; overflow: hidden; } .preview { width: 44px; height: 44px; flex: none; display: flex; align-items: center; justify-content: center; background: var(--bg-3); font-weight: 700; overflow: hidden; }
.preview img { width: 100%; height: 100%; object-fit: cover; display: block; } .preview img { width: 100%; height: 100%; object-fit: cover; display: block; }
.note { margin: 6px 0 0; font-size: 13px; color: var(--text-dim); }
.err { color: var(--danger); font-size: 13px; margin: 6px 0 0; } .err { color: var(--danger); font-size: 13px; margin: 6px 0 0; }
.opt { text-transform: none; font-weight: 400; letter-spacing: 0; color: var(--text-faint); } .opt { text-transform: none; font-weight: 400; letter-spacing: 0; color: var(--text-faint); }
</style> </style>
+2 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { native, type IdentitySummary } from '../lib/native.ts'; import { native, isWeb, type IdentitySummary } from '../lib/native.ts';
import { identities } from '../lib/identities.svelte.ts'; import { identities } from '../lib/identities.svelte.ts';
import { store } from '../lib/settings.svelte.ts'; import { store } from '../lib/settings.svelte.ts';
import { ui } from '../lib/ui.svelte.ts'; import { ui } from '../lib/ui.svelte.ts';
@@ -20,7 +20,7 @@
function exportId(i: IdentitySummary) { function exportId(i: IdentitySummary) {
ui.prompt = { ui.prompt = {
title: `Back up ${i.name}`, label: 'Password for the backup file', confirm: 'Choose location...', input: 'password', title: `Back up ${i.name}`, label: 'Password for the backup file', confirm: isWeb ? 'Download' : 'Choose location...', input: 'password',
text: 'The .p12 file contains your private key. Protect it with a password; the desktop Mumble client can import it.', text: 'The .p12 file contains your private key. Protect it with a password; the desktop Mumble client can import it.',
onsubmit: pw => act(async () => { onsubmit: pw => act(async () => {
const path = await native!.identities.exportP12(i.id, pw); const path = await native!.identities.exportP12(i.id, pw);
+15 -4
View File
@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import Modal from './Modal.svelte'; import Modal from './Modal.svelte';
import Icon from './Icon.svelte'; import Icon from './Icon.svelte';
import { native, type IdentitySummary } from '../lib/native.ts'; import { native, isWeb, type IdentitySummary } from '../lib/native.ts';
import { store } from '../lib/settings.svelte.ts';
import { identities } from '../lib/identities.svelte.ts'; import { identities } from '../lib/identities.svelte.ts';
// setup: first start (marks setup as done at the end); add: adding another identity from Settings // setup: first start (marks setup as done at the end); add: adding another identity from Settings
@@ -44,9 +45,15 @@
} }
} }
// Suggest the identity's name as the username in the connect dialog
let useAsUsername = $state(true);
const create = (e: SubmitEvent) => { const create = (e: SubmitEvent) => {
e.preventDefault(); e.preventDefault();
run(() => native!.identities.create(name.trim(), email.trim()), 'backup'); run(async () => {
const created = await native!.identities.create(name.trim(), email.trim());
if (useAsUsername) store.setIdentityName(created.id, name);
return created;
}, 'backup');
}; };
const importFile = async (e: SubmitEvent) => { const importFile = async (e: SubmitEvent) => {
@@ -93,6 +100,7 @@
<Modal {title} onclose={mode === 'setup' ? () => {} : onclose} width={560}> <Modal {title} onclose={mode === 'setup' ? () => {} : onclose} width={560}>
{#if step === 'welcome'} {#if step === 'welcome'}
<p>Mumble recognizes you by a <strong>certificate</strong>, not by a password. Registered names, admin rights and friends lists on servers are tied to it, so it is worth setting up once and keeping a backup.</p> <p>Mumble recognizes you by a <strong>certificate</strong>, not by a password. Registered names, admin rights and friends lists on servers are tied to it, so it is worth setting up once and keeping a backup.</p>
{#if isWeb}<p class="dim">In the browser the certificate is kept in this browser's storage and passed to the proxy at {location.host} when you connect, which presents it to the Mumble server.</p>{/if}
<div class="choices"> <div class="choices">
<button class="choice" onclick={() => (step = 'create')}> <button class="choice" onclick={() => (step = 'create')}>
<Icon name="plus" /><span><strong>Create a new identity</strong><small>Start fresh with a new certificate</small></span> <Icon name="plus" /><span><strong>Create a new identity</strong><small>Start fresh with a new certificate</small></span>
@@ -116,7 +124,8 @@
<p>The name is stored in the certificate and helps you tell identities apart. Servers show the username you enter when connecting, not this.</p> <p>The name is stored in the certificate and helps you tell identities apart. Servers show the username you enter when connecting, not this.</p>
<label for="w-name">Name</label> <label for="w-name">Name</label>
<!-- svelte-ignore a11y_autofocus --> <!-- svelte-ignore a11y_autofocus -->
<input id="w-name" bind:value={name} required autofocus placeholder="e.g. kibi" /> <input id="w-name" bind:value={name} required autofocus placeholder="e.g. your nickname" />
<label class="check"><input type="checkbox" bind:checked={useAsUsername} /> Use this name as my username when connecting with this identity</label>
<label for="w-email">Email <span class="opt">(optional, stored in the certificate)</span></label> <label for="w-email">Email <span class="opt">(optional, stored in the certificate)</span></label>
<input id="w-email" type="email" bind:value={email} /> <input id="w-email" type="email" bind:value={email} />
</form> </form>
@@ -130,7 +139,7 @@
{:else if step === 'backup' && result} {:else if step === 'backup' && result}
<p>Identity <strong>{result.name}</strong> is ready.</p> <p>Identity <strong>{result.name}</strong> is ready.</p>
<p class="mono">{result.fingerprint}</p> <p class="mono">{result.fingerprint}</p>
<p>If this computer dies, the certificate goes with it, and so do your registrations. Save a password-protected backup now; the desktop Mumble client can import it too.</p> <p>{isWeb ? 'If this browser\'s data is cleared' : 'If this computer dies'}, the certificate goes with it, and so do your registrations. Save a password-protected backup now; the desktop Mumble client can import it too.</p>
<label for="w-bpass">Backup password <span class="opt">(at least 8 characters)</span></label> <label for="w-bpass">Backup password <span class="opt">(at least 8 characters)</span></label>
<input id="w-bpass" type="password" bind:value={backupPassword} autocomplete="new-password" /> <input id="w-bpass" type="password" bind:value={backupPassword} autocomplete="new-password" />
<label for="w-bconf">Repeat password</label> <label for="w-bconf">Repeat password</label>
@@ -163,6 +172,8 @@
</Modal> </Modal>
<style> <style>
.check { display: flex; align-items: center; gap: 8px; text-transform: none; font-weight: 400; letter-spacing: 0; font-size: 14px; color: var(--text); margin-top: 14px; }
.check input { width: auto; }
p { color: var(--text-dim); margin: 10px 0 0; } p { color: var(--text-dim); margin: 10px 0 0; }
p strong { color: var(--text); } p strong { color: var(--text); }
.choices { display: flex; flex-direction: column; gap: 8px; margin-top: 16px; } .choices { display: flex; flex-direction: column; gap: 8px; margin-top: 16px; }
+25
View File
@@ -0,0 +1,25 @@
<script lang="ts">
import { ui } from '../lib/ui.svelte.ts';
// An image from the chat or a description, as large as the window allows. A click anywhere
// or Escape closes it.
const src = $derived(ui.image);
const remote = $derived(!!src && /^https?:/i.test(src));
</script>
<svelte:window onkeydown={e => { if (src && e.key === 'Escape') { e.stopPropagation(); ui.image = null; } }} />
{#if src}
<div class="viewer" role="dialog" aria-modal="true" aria-label="Image">
<button class="shade" aria-label="Close image" onclick={() => (ui.image = null)}></button>
<img {src} alt="" />
{#if remote}<a class="open" href={src} target="_blank" rel="noopener noreferrer">Open original</a>{/if}
</div>
{/if}
<style>
.viewer { position: fixed; inset: 0; z-index: 300; display: flex; align-items: center; justify-content: center; padding: 16px; }
.shade { position: absolute; inset: 0; background: rgb(0 0 0 / 0.85); cursor: zoom-out; }
img { position: relative; max-width: 100%; max-height: 100%; object-fit: contain; pointer-events: none; box-shadow: 0 16px 48px rgb(0 0 0 / 0.6); }
.open { position: absolute; bottom: 14px; left: 50%; transform: translateX(-50%); padding: 4px 10px; font-size: 13px; color: #fff; background: rgb(0 0 0 / 0.7); }
</style>
+8 -8
View File
@@ -17,6 +17,8 @@
const u = entry.authorSession != null ? session.user(entry.authorSession) : undefined; const u = entry.authorSession != null ? session.user(entry.authorSession) : undefined;
if (u) menu.show(e, userMenu(u)); if (u) menu.show(e, userMenu(u));
} }
// Full date and time, shown at the right edge while the pointer is on the message
const full = $derived(time.toLocaleString([], { dateStyle: 'medium', timeStyle: 'medium' }));
const hhmm = $derived(time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })); const hhmm = $derived(time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }));
</script> </script>
@@ -27,12 +29,9 @@
</div> </div>
{:else} {:else}
<article class="msg" class:grouped> <article class="msg" class:grouped>
<time class="stamp" datetime={time.toISOString()}>{full}</time>
<div class="gutter"> <div class="gutter">
{#if grouped} {#if !grouped}<span role="presentation" oncontextmenu={authorMenu}><Avatar name={entry.author} size={38} /></span>{/if}
<span class="time hover">{hhmm}</span>
{:else}
<span role="presentation" oncontextmenu={authorMenu}><Avatar name={entry.author} size={38} /></span>
{/if}
</div> </div>
<div class="content"> <div class="content">
{#if !grouped} {#if !grouped}
@@ -64,7 +63,10 @@
{/if} {/if}
<style> <style>
.msg { display: flex; gap: 14px; padding: 2px 16px; margin-top: 14px; } .msg { position: relative; display: flex; gap: 14px; padding: 2px 16px; margin-top: 14px; }
/* Over the end of the line, with its own background so it stays readable on long messages */
.stamp { position: absolute; top: 2px; right: 12px; padding: 1px 6px; line-height: 18px; background: var(--bg-1); border: 1px solid var(--line); visibility: hidden; pointer-events: none; }
.msg:hover .stamp { visibility: visible; }
.msg.grouped { margin-top: 0; } .msg.grouped { margin-top: 0; }
.msg:hover { background: rgb(255 255 255 / 0.02); } .msg:hover { background: rgb(255 255 255 / 0.02); }
.gutter { width: 38px; flex: none; display: flex; justify-content: center; } .gutter { width: 38px; flex: none; display: flex; justify-content: center; }
@@ -73,8 +75,6 @@
header strong { font-weight: 600; } header strong { font-weight: 600; }
header strong.self { color: var(--accent-hover); } header strong.self { color: var(--accent-hover); }
time, .time { font-size: 11px; color: var(--text-faint); } time, .time { font-size: 11px; color: var(--text-faint); }
.time.hover { visibility: hidden; line-height: 22px; }
.msg:hover .time.hover { visibility: visible; }
.html { overflow-wrap: anywhere; user-select: text; } .html { overflow-wrap: anywhere; user-select: text; }
.html :global(p) { margin: 0; } .html :global(p) { margin: 0; }
.html :global(img) { max-width: min(520px, 100%); max-height: 360px; display: block; margin-top: 4px; border: 1px solid var(--line); } .html :global(img) { max-width: min(520px, 100%); max-height: 360px; display: block; margin-top: 4px; border: 1px solid var(--line); }
+57 -10
View File
@@ -1,9 +1,12 @@
<script lang="ts"> <script lang="ts">
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import Modal from './Modal.svelte'; import Modal from './Modal.svelte';
import { native, type PublicServer, type PingResult } from '../lib/native.ts'; import Icon from './Icon.svelte';
import { desktop as native, type PublicServer, type PingResult } from '../lib/native.ts';
import { store } from '../lib/settings.svelte.ts'; import { store } from '../lib/settings.svelte.ts';
import { ui } from '../lib/ui.svelte.ts'; import { ui } from '../lib/ui.svelte.ts';
import { sessions } from '../lib/session.svelte.ts';
import { identities } from '../lib/identities.svelte.ts';
let { onclose }: { onclose: () => void } = $props(); let { onclose }: { onclose: () => void } = $props();
@@ -12,7 +15,10 @@
let error = $state(''); let error = $state('');
let search = $state(''); let search = $state('');
let continent = $state(''); let continent = $state('');
let sort = $state<'users' | 'ping' | 'name' | 'country'>('users'); type Sort = 'users' | 'ping' | 'name' | 'country';
let sort = $state<Sort>('users');
// Each column has a natural order (most people, lowest ping, A to Z); a second click flips it
let reversed = $state(false);
let pings = $state<Record<string, PingResult | null>>({}); let pings = $state<Record<string, PingResult | null>>({});
let alive = true; let alive = true;
// Ping queue; the generation drops answers from a previous list // Ping queue; the generation drops answers from a previous list
@@ -53,14 +59,24 @@
const list = servers.filter(s => (!continent || s.continent === continent) && const list = servers.filter(s => (!continent || s.continent === continent) &&
(!q || s.name.toLowerCase().includes(q) || s.country.toLowerCase().includes(q) || s.host.toLowerCase().includes(q))); (!q || s.name.toLowerCase().includes(q) || s.country.toLowerCase().includes(q) || s.host.toLowerCase().includes(q)));
const p = (s: PublicServer) => pings[key(s)]; const p = (s: PublicServer) => pings[key(s)];
const dir = reversed ? -1 : 1;
return list.sort((a, b) => { return list.sort((a, b) => {
if (sort === 'name') return a.name.localeCompare(b.name); if (sort === 'name') return dir * a.name.localeCompare(b.name);
if (sort === 'country') return a.country.localeCompare(b.country) || a.name.localeCompare(b.name); if (sort === 'country') return dir * a.country.localeCompare(b.country) || a.name.localeCompare(b.name);
if (sort === 'ping') return (p(a)?.ping ?? 1e9) - (p(b)?.ping ?? 1e9); // Servers that have not answered stay at the bottom in both directions
return (p(b)?.users ?? -1) - (p(a)?.users ?? -1); const pa = p(a), pb = p(b);
if (!pa || !pb) return pa ? -1 : pb ? 1 : 0;
return dir * (sort === 'ping' ? pa.ping - pb.ping : pb.users - pa.users);
}).slice(0, 300); }).slice(0, 300);
}); });
function sortBy(col: Sort) {
reversed = sort === col && !reversed;
sort = col;
}
// Most people first counts as descending, the other columns start ascending
const ariaSort = (col: Sort) => sort !== col ? 'none' : reversed === (col === 'users') ? 'ascending' : 'descending';
// Ping what the filter shows, a few at a time; each answer fills in users and latency // Ping what the filter shows, a few at a time; each answer fills in users and latency
function pingVisible() { function pingVisible() {
queue = servers.filter(s => !(key(s) in pings)); queue = servers.filter(s => !(key(s) in pings));
@@ -80,6 +96,18 @@
}); });
} }
// Connects right away with the name used last (or the default identity's name); the server is saved so it shows in the rail.
// Without a name to reuse, the add dialog asks for one.
function connect(s: PublicServer) {
const saved = store.servers.find(x => x.host === s.host && x.port === s.port);
const username = store.servers.at(-1)?.username || store.identityNames[identities.default?.id ?? ''];
if (!saved && !username) return add(s);
const server = saved ?? { id: crypto.randomUUID(), label: s.name || s.host, host: s.host, port: s.port, username: username!, password: '' };
if (!saved) store.upsertServer(server);
sessions.open(server);
onclose();
}
function add(s: PublicServer) { function add(s: PublicServer) {
ui.addServer = { host: s.host, port: s.port, label: s.name }; ui.addServer = { host: s.host, port: s.port, label: s.name };
onclose(); onclose();
@@ -106,7 +134,7 @@
<option value="">All continents</option> <option value="">All continents</option>
{#each Object.entries(CONTINENTS) as [code, name] (code)}<option value={code}>{name}</option>{/each} {#each Object.entries(CONTINENTS) as [code, name] (code)}<option value={code}>{name}</option>{/each}
</select> </select>
<select bind:value={sort} aria-label="Sort by"> <select bind:value={sort} onchange={() => (reversed = false)} aria-label="Sort by">
<option value="users">Most people</option> <option value="users">Most people</option>
<option value="ping">Lowest ping</option> <option value="ping">Lowest ping</option>
<option value="name">Name</option> <option value="name">Name</option>
@@ -121,7 +149,14 @@
<div class="row"><button class="btn" onclick={load}>Try again</button><button class="btn" onclick={changeSource}>Use another list...</button></div> <div class="row"><button class="btn" onclick={load}>Try again</button><button class="btn" onclick={changeSource}>Use another list...</button></div>
{:else} {:else}
<table> <table>
<thead><tr><th>Server</th><th>Country</th><th class="num">People</th><th class="num">Ping</th><th></th></tr></thead> <thead><tr>
{#each [['name', 'Server'], ['country', 'Country'], ['users', 'People'], ['ping', 'Ping']] as const as [col, text] (col)}
<th class:num={col === 'users' || col === 'ping'} aria-sort={ariaSort(col)}>
<button class="sort" class:on={sort === col} title="Sort by {text.toLowerCase()}" onclick={() => sortBy(col)}>{text}<span class="caret" class:up={ariaSort(col) === 'ascending'}></span></button>
</th>
{/each}
<th></th>
</tr></thead>
<tbody> <tbody>
{#each shown as s (key(s))} {#each shown as s (key(s))}
{@const p = pings[key(s)]} {@const p = pings[key(s)]}
@@ -134,7 +169,12 @@
<td>{s.country}</td> <td>{s.country}</td>
<td class="num">{p ? `${p.users} / ${p.maxUsers}` : key(s) in pings && p === null ? '...' : ''}</td> <td class="num">{p ? `${p.users} / ${p.maxUsers}` : key(s) in pings && p === null ? '...' : ''}</td>
<td class="num" class:good={p && p.ping < 60} class:bad={p && p.ping > 180}>{p ? `${Math.round(p.ping)} ms` : ''}</td> <td class="num" class:good={p && p.ping < 60} class:bad={p && p.ping > 180}>{p ? `${Math.round(p.ping)} ms` : ''}</td>
<td class="act"><button class="btn" onclick={() => add(s)}>Add</button></td> <td class="act">
<span class="acts">
<button class="icon-btn" title="Add to your servers" aria-label="Add" onclick={() => add(s)}><Icon name="plus" size={18} /></button>
<button class="icon-btn go" title="Connect" aria-label="Connect" onclick={() => connect(s)}><Icon name="arrow-right" size={18} /></button>
</span>
</td>
</tr> </tr>
{:else} {:else}
<tr><td colspan="5" class="dim">No servers match.</td></tr> <tr><td colspan="5" class="dim">No servers match.</td></tr>
@@ -157,6 +197,12 @@
.bar select { width: auto; flex: 0 0 auto; } .bar select { width: auto; flex: 0 0 auto; }
table { width: 100%; border-collapse: collapse; margin-top: 12px; font-size: 14px; } table { width: 100%; border-collapse: collapse; margin-top: 12px; font-size: 14px; }
th { text-align: left; font-size: 12px; color: var(--text-faint); font-weight: 600; padding: 6px 8px; border-bottom: 1px solid var(--line); position: sticky; top: 0; background: var(--bg-1); } th { text-align: left; font-size: 12px; color: var(--text-faint); font-weight: 600; padding: 6px 8px; border-bottom: 1px solid var(--line); position: sticky; top: 0; background: var(--bg-1); }
.sort { font: inherit; color: inherit; display: inline-flex; align-items: center; gap: 6px; }
.sort:hover, .sort.on { color: var(--text); }
/* Triangle from borders; only the active column shows it */
.caret { border: 4px solid transparent; border-top-color: currentColor; margin-top: 4px; visibility: hidden; }
.caret.up { border-top-color: transparent; border-bottom-color: currentColor; margin-top: -4px; }
.sort.on .caret { visibility: visible; }
td { padding: 6px 8px; border-bottom: 1px solid var(--line); vertical-align: middle; } td { padding: 6px 8px; border-bottom: 1px solid var(--line); vertical-align: middle; }
tr:hover td { background: var(--bg-hover); } tr:hover td { background: var(--bg-hover); }
.name { max-width: 360px; } .name { max-width: 360px; }
@@ -167,7 +213,8 @@
.good { color: var(--speaking); } .good { color: var(--speaking); }
.bad { color: var(--warn); } .bad { color: var(--warn); }
.act { width: 1%; } .act { width: 1%; }
.act .btn { padding: 4px 12px; font-size: 13px; } .acts { display: flex; gap: 4px; }
.go, .go:hover { color: var(--speaking); }
.dim { color: var(--text-dim); } .dim { color: var(--text-dim); }
.small { font-size: 12px; margin-top: 10px; } .small { font-size: 12px; margin-top: 10px; }
.error { color: var(--danger); } .error { color: var(--danger); }
+26 -1
View File
@@ -8,10 +8,13 @@
import { session } from '../lib/session.svelte.ts'; import { session } from '../lib/session.svelte.ts';
import { store } from '../lib/settings.svelte.ts'; import { store } from '../lib/settings.svelte.ts';
import { menu } from '../lib/menu.svelte.ts'; import { menu } from '../lib/menu.svelte.ts';
import { ui } from '../lib/ui.svelte.ts';
import { userMenu, changeNamePrompt } from '../lib/actions.ts'; import { userMenu, changeNamePrompt } from '../lib/actions.ts';
import { voice } from '../lib/audio/voice.svelte.ts'; import { voice } from '../lib/audio/voice.svelte.ts';
import { renderIncoming } from '../lib/html.ts'; import { renderIncoming } from '../lib/html.ts';
import RichEditor from './RichEditor.svelte'; import RichEditor from './RichEditor.svelte';
import { share } from '../lib/share.svelte.ts';
import { sessions } from '../lib/session.svelte.ts';
let { onnavigate, onclose }: { onnavigate: () => void; onclose: () => void } = $props(); let { onnavigate, onclose }: { onnavigate: () => void; onclose: () => void } = $props();
@@ -45,6 +48,13 @@
editing = true; editing = true;
} }
// After the reset above, so a request from the toolbar survives the panel switching to the profile
$effect(() => {
if (!ui.editDescription || !isSelf) return;
ui.editDescription = false;
startEdit();
});
function saveComment() { function saveComment() {
session.setComment(draft); session.setComment(draft);
editing = false; editing = false;
@@ -79,7 +89,9 @@
{/if} {/if}
<p class="dim small">{session.usersIn(channel.id).length} here, {session.subtreeUserCount(channel.id)} including subchannels{channel.maxUsers ? `, limit ${channel.maxUsers}` : ''}{channel.temporary ? ', temporary' : ''}</p> <p class="dim small">{session.usersIn(channel.id).length} here, {session.subtreeUserCount(channel.id)} including subchannels{channel.maxUsers ? `, limit ${channel.maxUsers}` : ''}{channel.temporary ? ', temporary' : ''}</p>
<div class="actions"> <div class="actions">
{#if session.self?.channelId === channel.id} {#if session.isText(channel.id)}
<button class="btn primary" onclick={() => { session.selectView(`ch:${channel.id}`); onnavigate(); }}>Open text channel</button>
{:else if session.self?.channelId === channel.id}
<span class="here">You are in this channel</span> <span class="here">You are in this channel</span>
{:else} {:else}
<button class="btn primary" disabled={!channel.canEnter} onclick={() => { session.joinChannel(channel.id); onnavigate(); }}> <button class="btn primary" disabled={!channel.canEnter} onclick={() => { session.joinChannel(channel.id); onnavigate(); }}>
@@ -116,6 +128,16 @@
</div> </div>
</div> </div>
{#if session.sharing[user.session]}
<div class="sharing">
<Icon name={session.sharing[user.session]} size={16} />
<span>{isSelf ? `You are sharing your ${session.sharing[user.session]}` : `Sharing their ${session.sharing[user.session]}`}</span>
{#if isSelf}<button class="btn" onclick={() => share.stop()}>Stop sharing</button>
{:else if share.watchOf(user.session)}<button class="btn" onclick={() => share.unwatch(user.session)}>Stop watching</button>
{:else}<button class="btn primary" onclick={() => share.watch(sessions.active, user.session)}>Watch</button>{/if}
</div>
{/if}
{#if isSelf} {#if isSelf}
<div class="actions"><button class="btn" onclick={() => changeNamePrompt(user)}>Change name</button></div> <div class="actions"><button class="btn" onclick={() => changeNamePrompt(user)}>Change name</button></div>
{/if} {/if}
@@ -150,6 +172,9 @@
</aside> </aside>
<style> <style>
.sharing { display: flex; align-items: center; gap: 8px; margin-top: 12px; padding: 8px 10px; font-size: 13px; color: var(--speaking); border: 1px solid var(--line); background: var(--bg-0); }
.sharing span { flex: 1; min-width: 0; color: var(--text); }
.sharing .btn { padding: 4px 10px; font-size: 12px; }
.panel { position: relative; max-width: 100%; height: 100%; flex: none; display: flex; flex-direction: column; background: var(--bg-1); border-left: 1px solid var(--line); min-height: 0; } .panel { position: relative; max-width: 100%; height: 100%; flex: none; display: flex; flex-direction: column; background: var(--bg-1); border-left: 1px solid var(--line); min-height: 0; }
header { height: 52px; flex: none; display: flex; align-items: center; gap: 8px; padding: 0 8px 0 14px; border-bottom: 1px solid var(--line); color: var(--text-dim); } header { height: 52px; flex: none; display: flex; align-items: center; gap: 8px; padding: 0 8px 0 14px; border-bottom: 1px solid var(--line); color: var(--text-dim); }
h2 { flex: 1; min-width: 0; margin: 0; font-size: 15px; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } h2 { flex: 1; min-width: 0; margin: 0; font-size: 15px; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+53
View File
@@ -0,0 +1,53 @@
<script lang="ts">
import Modal from './Modal.svelte';
import { session } from '../lib/session.svelte.ts';
let { onclose }: { onclose: () => void } = $props();
const info = $derived(session.serverInfo);
const server = $derived(session.server);
const cert = $derived(session.serverCert);
const voice = $derived(session.voiceTransport);
</script>
<Modal title="Server information" {onclose} width={520}>
{#if info && server}
<h3>Server</h3>
<dl>
<dt>Address</dt><dd class="mono">{server.host}:{server.port}</dd>
<dt>Version</dt><dd>{info.version || 'unknown'}</dd>
<dt>People</dt><dd>{info.users}{info.config.maxUsers ? ` of ${info.config.maxUsers}` : ''}</dd>
<dt>Channels</dt><dd>{info.channels}</dd>
</dl>
<h3>Connection</h3>
<dl>
<dt>Ping (TCP)</dt><dd>{session.rtt ? `${session.rtt} ms` : 'n/a'}</dd>
<dt>Voice</dt><dd>{voice.udp ? `Encrypted UDP${voice.rtt ? `, ${Math.round(voice.rtt)} ms` : ''}` : 'Through the TCP connection'}</dd>
<dt>Certificate</dt><dd>{cert ? (cert.authorized ? 'Signed by a trusted authority' : 'Self-signed or untrusted, pinned on first connect') : 'unknown'}</dd>
{#if cert}<dt>SHA-256</dt><dd class="mono">{cert.fingerprint}</dd>{/if}
</dl>
<h3>Limits</h3>
<dl>
<dt>Voice bitrate</dt><dd>{info.maxBandwidth ? `up to ${Math.round(info.maxBandwidth / 1000)} kbit/s` : 'unknown'}</dd>
<dt>Message length</dt><dd>{info.config.messageLength ? `${info.config.messageLength} characters` : 'unlimited'}</dd>
<dt>Image messages</dt><dd>{info.config.imageMessageLength ? `${info.config.imageMessageLength} characters` : 'unlimited'}</dd>
<dt>HTML in messages</dt><dd>{info.config.allowHtml ? 'allowed' : 'not allowed'}</dd>
</dl>
{/if}
{#snippet footer()}
<button class="btn primary" onclick={onclose}>Close</button>
{/snippet}
</Modal>
<style>
h3 { margin: 18px 0 6px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-dim); }
h3:first-child { margin-top: 8px; }
dl { display: grid; grid-template-columns: 130px 1fr; gap: 4px 12px; margin: 0; font-size: 14px; }
dt { color: var(--text-faint); }
dd { margin: 0; overflow-wrap: anywhere; user-select: text; }
.mono { font: 12px var(--mono); }
@media (max-width: 500px) { dl { grid-template-columns: 1fr; } dt { margin-top: 6px; } }
</style>
+5 -1
View File
@@ -3,6 +3,7 @@
import { store, type SavedServer } from '../lib/settings.svelte.ts'; import { store, type SavedServer } from '../lib/settings.svelte.ts';
import { sessions } from '../lib/session.svelte.ts'; import { sessions } from '../lib/session.svelte.ts';
import { menu } from '../lib/menu.svelte.ts'; import { menu } from '../lib/menu.svelte.ts';
import { desktop } from '../lib/native.ts';
import { makeServerIcon } from '../lib/upload.ts'; import { makeServerIcon } from '../lib/upload.ts';
let { onadd, onedit, onsettings, onbrowse }: let { onadd, onedit, onsettings, onbrowse }:
@@ -88,12 +89,15 @@
{#if dropIndex === store.servers.length && dragging}<div class="drop-line"></div>{/if} {#if dropIndex === store.servers.length && dragging}<div class="drop-line"></div>{/if}
<input bind:this={iconPicker} type="file" accept="image/*" hidden onchange={pickIcon} /> <input bind:this={iconPicker} type="file" accept="image/*" hidden onchange={pickIcon} />
<button class="tile add" title="Add a server" onclick={onadd}><Icon name="plus" /></button> <button class="tile add" title="Add a server" onclick={onadd}><Icon name="plus" /></button>
<button class="tile add" title="Browse public servers" onclick={onbrowse}><Icon name="globe" /></button> <!-- The public list and its pings need the desktop app -->
{#if desktop}<button class="tile add" title="Browse public servers" onclick={onbrowse}><Icon name="globe" /></button>{/if}
<div class="spacer"></div> <div class="spacer"></div>
<button class="tile ghost" title="Settings" onclick={onsettings}><Icon name="settings" /></button> <button class="tile ghost" title="Settings" onclick={onsettings}><Icon name="settings" /></button>
</nav> </nav>
<style> <style>
/* The server you are talking on leads the list, wherever it sits in your own order */
.tile.voice { order: -1; }
.rail { .rail {
width: 68px; flex: none; background: var(--bg-0); display: flex; flex-direction: column; width: 68px; flex: none; background: var(--bg-0); display: flex; flex-direction: column;
align-items: center; gap: 8px; padding: 12px 0; overflow-y: auto; align-items: center; gap: 8px; padding: 12px 0; overflow-y: auto;
+57 -15
View File
@@ -5,17 +5,36 @@
import AppearanceSettings from './AppearanceSettings.svelte'; import AppearanceSettings from './AppearanceSettings.svelte';
import SoundSettings from './SoundSettings.svelte'; import SoundSettings from './SoundSettings.svelte';
import { store } from '../lib/settings.svelte.ts'; import { store } from '../lib/settings.svelte.ts';
import { native } from '../lib/native.ts'; import { native, desktop, isWeb } from '../lib/native.ts';
import { fetchUploadInfo } from '../lib/upload.ts'; import { fetchUploadInfo } from '../lib/upload.ts';
import { previewMode } from '../lib/preview.ts';
let { onclose, tab: initialTab = 'voice' }: { onclose: () => void; tab?: 'voice' | 'sounds' | 'appearance' | 'files' | 'identities' } = $props(); let { onclose, tab: initialTab = 'voice' }: { onclose: () => void; tab?: 'voice' | 'sounds' | 'appearance' | 'accessibility' | 'files' | 'identities' } = $props();
// svelte-ignore state_referenced_locally // svelte-ignore state_referenced_locally
let tab = $state<'voice' | 'sounds' | 'appearance' | 'files' | 'identities'>(initialTab); let tab = $state<'voice' | 'sounds' | 'appearance' | 'accessibility' | 'files' | 'identities'>(initialTab);
const s = store.settings; const s = store.settings;
let embedHosts = $state(s.embedHosts.join('\n')); let embedHosts = $state(s.embedHosts.join('\n'));
let testResult = $state(''); let testResult = $state('');
// Public STUN servers to pick from; anything else is typed in
const STUN_PRESETS = [
{ name: 'Google', url: 'stun.l.google.com:19302' },
{ name: 'Cloudflare', url: 'stun.cloudflare.com:3478' }
];
let stunCustom = $state(!!s.stunServer && !STUN_PRESETS.some(p => p.url === s.stunServer));
const stunChoice = $derived(stunCustom ? 'custom' : s.stunServer);
function chooseStun(value: string) {
stunCustom = value === 'custom';
// Keep what was typed when switching to "another server"
if (!stunCustom) s.stunServer = value;
else if (STUN_PRESETS.some(p => p.url === s.stunServer)) s.stunServer = '';
store.saveSettings();
}
// Settings that only work through the upload host are disabled until it is set up
const hasUpload = $derived(!!(s.uploadHost.trim() && s.uploadKey.trim()));
const needsUpload = $derived(hasUpload ? undefined : 'Needs an upload host and key');
// Changes apply right away // Changes apply right away
function saveFiles() { function saveFiles() {
s.uploadHost = s.uploadHost.trim().replace(/\/+$/, ''); s.uploadHost = s.uploadHost.trim().replace(/\/+$/, '');
@@ -35,11 +54,12 @@
} }
} }
type Tab = 'voice' | 'sounds' | 'appearance' | 'files' | 'identities'; type Tab = 'voice' | 'sounds' | 'appearance' | 'accessibility' | 'files' | 'identities';
const tabs: { id: Tab; label: string }[] = [ const tabs: { id: Tab; label: string }[] = [
{ id: 'voice', label: 'Voice' }, { id: 'voice', label: 'Voice' },
{ id: 'sounds', label: 'Sounds' }, { id: 'sounds', label: 'Sounds' },
{ id: 'appearance', label: 'Appearance' }, { id: 'appearance', label: 'Appearance' },
{ id: 'accessibility', label: 'Accessibility' },
{ id: 'files', label: 'Chat and files' }, { id: 'files', label: 'Chat and files' },
...(native ? [{ id: 'identities' as Tab, label: 'Identities' }] : []) ...(native ? [{ id: 'identities' as Tab, label: 'Identities' }] : [])
]; ];
@@ -54,10 +74,28 @@
{#if tab === 'voice'} {#if tab === 'voice'}
<VoiceSettings /> <VoiceSettings />
<h3>Screen sharing</h3>
<label for="s-stun">STUN server</label>
<select id="s-stun" value={stunChoice} onchange={e => chooseStun(e.currentTarget.value)}>
<option value="">{isWeb ? 'This site\'s own server' : 'None (same network only)'}</option>
{#each STUN_PRESETS as p (p.url)}<option value={p.url}>{p.name} ({p.url})</option>{/each}
<option value="custom">Another server...</option>
</select>
{#if stunChoice === 'custom'}
<input class="stun-custom" bind:value={s.stunServer} onchange={() => store.saveSettings()} placeholder="stun.example.com:3478" autocomplete="off" aria-label="STUN server address" />
{/if}
<label for="s-relay">Relay <span class="opt">(optional)</span></label>
<input id="s-relay" bind:value={s.relayServer} onchange={() => { s.relayServer = s.relayServer.trim(); store.saveSettings(); }}
placeholder={isWeb ? 'this site (automatic)' : 'https://voice.example.com'} autocomplete="off" />
<p class="help">The address of a mumh5 web version. When a direct connection is not possible (mobile data, strict company networks), the stream goes through that server instead, by itself. {isWeb ? 'Empty uses the site you are on.' : 'Its operator sees your IP address and carries the stream, but cannot watch it.'}</p>
<p class="help">Screen sharing connects you directly to the people watching. Across the internet both sides need a STUN server to find a route; it learns your IP address but never sees the stream. {isWeb ? 'By default the site you are on provides it.' : 'With none, no outside server is contacted and streams only work on the same network.'} Networks that block direct connections, like many mobile ones, need more than STUN.</p>
{:else if tab === 'sounds'} {:else if tab === 'sounds'}
<SoundSettings /> <SoundSettings />
{:else if tab === 'appearance'} {:else if tab === 'appearance'}
<AppearanceSettings /> <AppearanceSettings />
{:else if tab === 'accessibility'}
<label class="check"><input type="checkbox" bind:checked={s.hints} onchange={() => store.saveSettings()} /> Show hints when the pointer rests on buttons and icons</label>
<p class="help">Hints are the small labels that appear on hover. Screen readers get the names either way.</p>
{:else if tab === 'files'} {:else if tab === 'files'}
<p class="help">Files go to a f0ckm instance and are posted as links. Other mumh5 users see them inline; regular Mumble users get a link and a small preview. Without an upload host you can still send images, inline and scaled to the server's limit.</p> <p class="help">Files go to a f0ckm instance and are posted as links. Other mumh5 users see them inline; regular Mumble users get a link and a small preview. Without an upload host you can still send images, inline and scaled to the server's limit.</p>
<label for="s-host">Upload host</label> <label for="s-host">Upload host</label>
@@ -65,39 +103,39 @@
<label for="s-key">Upload key</label> <label for="s-key">Upload key</label>
<input id="s-key" type="password" bind:value={s.uploadKey} onchange={saveFiles} placeholder="cu_..." autocomplete="off" /> <input id="s-key" type="password" bind:value={s.uploadKey} onchange={saveFiles} placeholder="cu_..." autocomplete="off" />
<div class="row"> <div class="row">
<div> <div title={needsUpload}>
<label for="s-exp">Files expire after</label> <label for="s-exp" class:off={!hasUpload}>Files expire after</label>
<select id="s-exp" bind:value={s.uploadExpiry} onchange={saveFiles}> <select id="s-exp" bind:value={s.uploadExpiry} onchange={saveFiles} disabled={!hasUpload}>
<option value="1h">1 hour</option> <option value="1h">1 hour</option>
<option value="24h">24 hours</option> <option value="24h">24 hours</option>
<option value="7d">7 days</option> <option value="7d">7 days</option>
<option value="30d">30 days</option> <option value="30d">30 days</option>
</select> </select>
</div> </div>
<button class="btn" disabled={!s.uploadHost || !s.uploadKey} onclick={test}>Test key</button> <button class="btn" disabled={!hasUpload} title={needsUpload} onclick={test}>Test key</button>
</div> </div>
{#if testResult}<p class="help">{testResult}</p>{/if} {#if testResult}<p class="help">{testResult}</p>{/if}
<label class="check"><input type="checkbox" bind:checked={s.sendThumbnails} onchange={saveFiles} /> Send image previews for regular Mumble clients</label> <label class="check" class:off={!hasUpload} title={needsUpload}><input type="checkbox" bind:checked={s.sendThumbnails} onchange={saveFiles} disabled={!hasUpload} /> Send image previews for regular Mumble clients</label>
<h3>Embeds</h3> <h3>Embeds</h3>
<label for="s-embed">Also show media inline from these hosts</label> <label for="s-embed">Also show media inline from these hosts</label>
<textarea id="s-embed" bind:value={embedHosts} onchange={saveFiles} rows="3" placeholder="one host per line, e.g. i.example.com"></textarea> <textarea id="s-embed" bind:value={embedHosts} onchange={saveFiles} rows="3" placeholder="one host per line, e.g. i.example.com"></textarea>
<p class="help">Links from other hosts stay plain links, so opening a chat never contacts servers you did not choose.</p> <p class="help">Links from other hosts stay plain links, so opening a chat never contacts servers you did not choose.</p>
<label for="s-lp">Link previews</label> <label for="s-lp">Link previews</label>
<select id="s-lp" bind:value={s.linkPreviews} onchange={saveFiles}> <!-- Shows the mode in effect; a stored "upload host" choice comes back once the key is set -->
<option value="host">Fetched by the upload host (keeps your IP private)</option> <select id="s-lp" value={previewMode()} onchange={e => { s.linkPreviews = e.currentTarget.value as typeof s.linkPreviews; saveFiles(); }}>
<option value="direct">Fetched by this computer (linked sites see your IP)</option> <option value="host" disabled={!hasUpload}>Fetched by the upload host (keeps your IP private){hasUpload ? '' : ' - needs an upload host and key'}</option>
<option value="direct" disabled={!desktop}>Fetched by this computer (linked sites see your IP){desktop ? '' : ' - needs the desktop app'}</option>
<option value="off">Off</option> <option value="off">Off</option>
</select> </select>
{#if s.linkPreviews === 'host' && !(s.uploadHost && s.uploadKey)}<p class="help">Needs an upload host and key above; until then no previews are shown.</p>{/if}
<label for="s-yt">YouTube links</label> <label for="s-yt">YouTube links</label>
<select id="s-yt" bind:value={s.youtube} onchange={saveFiles}> <select id="s-yt" bind:value={s.youtube} onchange={saveFiles}>
<option value="off">Plain links</option>
<option value="click">Player card, loads from YouTube when clicked</option> <option value="click">Player card, loads from YouTube when clicked</option>
<option value="thumbnail">Player card with thumbnail (loads the thumbnail from YouTube)</option> <option value="thumbnail">Player card with thumbnail (loads the thumbnail from YouTube)</option>
<option value="off">Plain links</option>
</select> </select>
{:else} {:else}
<p class="help">Servers recognize you by your certificate. The default is used unless a server is set to another one.</p> <p class="help">Servers recognize you by your certificate. The default is used unless a server is set to another one.{isWeb ? ' Certificates are kept in this browser and passed to the proxy when you connect.' : ''}</p>
<IdentityList /> <IdentityList />
{/if} {/if}
@@ -111,10 +149,14 @@
.tabs button { padding: 8px 14px; color: var(--text-dim); font-weight: 600; white-space: nowrap; } .tabs button { padding: 8px 14px; color: var(--text-dim); font-weight: 600; white-space: nowrap; }
.tabs button.active { color: var(--text); box-shadow: inset 0 -2px 0 var(--accent); } .tabs button.active { color: var(--text); box-shadow: inset 0 -2px 0 var(--accent); }
h3 { margin: 22px 0 0; font-size: 14px; } h3 { margin: 22px 0 0; font-size: 14px; }
.stun-custom { margin-top: 6px; }
.opt { text-transform: none; font-weight: 400; letter-spacing: 0; color: var(--text-faint); }
.help { margin: 10px 0 0; font-size: 13px; color: var(--text-dim); } .help { margin: 10px 0 0; font-size: 13px; color: var(--text-dim); }
.row { display: flex; align-items: flex-end; gap: 8px; } .row { display: flex; align-items: flex-end; gap: 8px; }
.row > div { flex: 1; } .row > div { flex: 1; }
.check { display: flex; align-items: center; gap: 8px; text-transform: none; font-weight: 400; letter-spacing: 0; font-size: 14px; color: var(--text); margin-top: 14px; } .check { display: flex; align-items: center; gap: 8px; text-transform: none; font-weight: 400; letter-spacing: 0; font-size: 14px; color: var(--text); margin-top: 14px; }
.check input { width: auto; } .check input { width: auto; }
.off { color: var(--text-faint); }
select:disabled, input:disabled { opacity: 0.5; }
textarea { resize: vertical; } textarea { resize: vertical; }
</style> </style>
+290
View File
@@ -0,0 +1,290 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import Modal from './Modal.svelte';
import { share, QUALITY, type ShareKind } from '../lib/share.svelte.ts';
import { desktop, type ScreenSource } from '../lib/native.ts';
import { store } from '../lib/settings.svelte.ts';
// Everything is chosen here, with a preview, before anything is sent: what to show, which
// sound goes with it and the quality. In a browser, and on Wayland, the system's own dialog
// picks the screen or window.
let { onclose }: { onclose: () => void } = $props();
const target = share.dialog!;
let info = $state<{ portal: boolean; audio: 'pipewire' | 'loopback' | 'browser' | 'none'; micLabel: string }>({ portal: true, audio: desktop ? 'none' : 'browser', micLabel: '' });
// What to show: the screen (or a window), or a camera
// Phones have no screen capture in the browser; there the camera is the only choice
const canScreen = !!navigator.mediaDevices?.getDisplayMedia;
let kind = $state<ShareKind>(canScreen ? 'screen' : 'camera');
let cameras = $state<MediaDeviceInfo[]>([]);
let camera = $state('');
let sources = $state<ScreenSource[]>([]);
let selected = $state('');
let preview = $state.raw<MediaStream | null>(null);
let apps = $state<string[]>([]);
// '' none, 'all' everything, 'app:<name>' one program
let audio = $state('');
let browserAudio = $state(false);
let busy = $state(false);
let error = $state('');
let handedOver = false;
let video = $state<HTMLVideoElement>();
$effect(() => { if (video) video.srcObject = preview; });
const fps = () => QUALITY[store.settings.shareQuality].fps;
let listing = $state(false);
async function loadSources() {
if (!desktop) return;
listing = true;
try {
sources = (await desktop.screenSources(false)).sources;
} finally {
listing = false;
}
// Pictures follow when they are ready
const pictures = new Map((await desktop.screenSources(true)).sources.map(s => [s.id, s.thumbnail]));
sources = sources.map(s => ({ ...s, thumbnail: pictures.get(s.id) ?? s.thumbnail }));
}
const loadApps = async () => { if (desktop && info.audio === 'pipewire') apps = await desktop.shareAudioApps(); };
if (desktop) {
desktop.shareInfo().then(async i => {
info = i;
loadApps();
if (!i.portal) await loadSources();
});
}
function dropPreview() {
preview?.getTracks().forEach(t => t.stop());
preview = null;
}
async function show(id: string) {
error = '';
dropPreview();
selected = id;
try {
desktop!.pickScreenSource(id);
preview = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: fps() }, audio: false });
} catch (e) {
error = `Could not capture this: ${(e as Error).message}`;
}
}
async function showCamera(deviceId = camera) {
error = '';
dropPreview();
const wanted: MediaTrackConstraints = { width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: fps() } };
if (deviceId) wanted.deviceId = { exact: deviceId };
else wanted.facingMode = 'user';
try {
try {
preview = await navigator.mediaDevices.getUserMedia({ video: wanted });
} catch (e) {
// A refusal or a missing camera is final; anything else may be a phone camera that
// dislikes the wishes above, so ask once more for whatever it has
if (['NotAllowedError', 'SecurityError', 'NotFoundError'].includes((e as Error).name)) throw e;
preview = await navigator.mediaDevices.getUserMedia({ video: deviceId ? { deviceId: { exact: deviceId } } : true });
}
// Names are only given out once a camera was allowed
cameras = (await navigator.mediaDevices.enumerateDevices()).filter(d => d.kind === 'videoinput');
camera = preview.getVideoTracks()[0]?.getSettings().deviceId ?? deviceId;
} catch (e) {
const { name, message } = e as Error;
error = name === 'NotFoundError' ? 'No camera was found.'
: name === 'NotAllowedError' || name === 'SecurityError'
? 'The camera was refused. If no question appeared, access is blocked: allow the camera for this site (the icon next to the address), and for the browser app in the phone\'s settings, then try again.'
: `Could not open the camera (${name}): ${message}`;
}
}
if (!canScreen) showCamera();
function setKind(next: ShareKind) {
if (kind === next) return;
kind = next;
selected = '';
dropPreview();
error = '';
if (next === 'camera') showCamera();
}
// The system's or the browser's picker
async function chooseElsewhere() {
error = '';
try {
if (desktop) {
const { sources: picked } = await desktop.screenSources(false);
if (!picked.length) return;
sources = picked;
await show(picked[0].id);
} else {
dropPreview();
preview = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: fps() }, audio: browserAudio });
}
} catch (e) {
if ((e as Error).name !== 'NotAllowedError') error = `Could not capture the screen: ${(e as Error).message}`;
}
}
// The virtual microphone shows up as a recording device a moment after it is created
async function virtualMic(): Promise<MediaStreamTrack> {
const find = async () => (await navigator.mediaDevices.enumerateDevices()).find(d => d.kind === 'audioinput' && d.label.includes(info.micLabel));
let device = await find();
for (let i = 0; i < 20 && !device; i++) {
await new Promise(r => setTimeout(r, 150));
device = await find();
}
if (!device) throw new Error('The audio capture device did not appear.');
const s = await navigator.mediaDevices.getUserMedia({
audio: { deviceId: { exact: device.deviceId }, echoCancellation: false, autoGainControl: false, noiseSuppression: false, channelCount: 2 }
});
return s.getAudioTracks()[0];
}
async function start() {
if (!preview) return;
busy = true;
error = '';
let usesVirtualMic = false;
try {
// A camera comes without extra sound: your voice already goes through the voice chat
if (kind === 'camera') { /* nothing to add */ }
else if (audio && info.audio === 'pipewire') {
usesVirtualMic = true;
await desktop!.shareAudioStart(audio === 'all' ? { kind: 'all' } : { kind: 'app', name: audio.slice(4) });
preview.addTrack(await virtualMic());
} else if (audio && info.audio === 'loopback') {
// System sound comes with a capture request; only its audio track is kept
desktop!.pickScreenSource(selected);
const extra = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true });
extra.getVideoTracks().forEach(t => t.stop());
const track = extra.getAudioTracks()[0];
if (track) preview.addTrack(track);
}
} catch (e) {
if (usesVirtualMic) desktop!.shareAudioStop();
error = `Could not capture the sound: ${(e as Error).message}`;
busy = false;
return;
}
handedOver = true;
share.go(target, preview, usesVirtualMic, kind);
onclose();
}
onDestroy(() => { if (!handedOver) dropPreview(); });
</script>
<Modal title="Share your screen" {onclose} width={760}>
<p class="help">mumh5 users in your channel can watch. Nothing is sent until you press Start sharing.</p>
<div class="kinds" role="tablist" aria-label="What to share">
<button role="tab" aria-selected={kind === 'screen'} class:active={kind === 'screen'} disabled={!canScreen}
title={canScreen ? undefined : 'This browser cannot capture the screen'} onclick={() => setKind('screen')}>Screen or window</button>
<button role="tab" aria-selected={kind === 'camera'} class:active={kind === 'camera'} onclick={() => setKind('camera')}>Camera</button>
</div>
{#if !canScreen}<p class="help">This browser cannot capture the screen (no browser on Android or iOS can), so only a camera can be shared from here. Watching other people's screens works.</p>{/if}
{#if kind === 'camera'}
<label for="sh-cam">Camera</label>
<select id="sh-cam" value={camera} onchange={e => showCamera(e.currentTarget.value)} disabled={!cameras.length}>
{#each cameras as c, i (c.deviceId)}<option value={c.deviceId}>{c.label || `Camera ${i + 1}`}</option>{:else}<option value="">{error ? 'None' : 'Asking for the camera...'}</option>{/each}
</select>
{:else if !desktop && info.audio === 'browser'}
<label class="check"><input type="checkbox" bind:checked={browserAudio} disabled={!!preview} /> Include sound (the browser offers what it can share, usually a tab's sound)</label>
{/if}
{#if kind === 'camera'}
<!-- chosen above -->
{:else if desktop && !info.portal}
<div class="row head"><label for="sh-src">Screen or window</label><button class="link" onclick={loadSources}>Refresh</button></div>
<ul class="sources" id="sh-src">
{#each sources as s (s.id)}
<li>
<button class:active={selected === s.id} aria-pressed={selected === s.id} onclick={() => show(s.id)}>
{#if s.thumbnail}<img src={s.thumbnail} alt="" />{:else}<span class="blank"></span>{/if}
<span class="name">{s.name}</span>
</button>
</li>
{:else}
<li class="dim">{listing ? 'Looking for screens and windows...' : 'Nothing found. Try Refresh.'}</li>
{/each}
</ul>
{:else}
<div class="row"><button class="btn" onclick={chooseElsewhere}>{preview ? 'Choose another screen or window...' : 'Choose a screen or window...'}</button></div>
{/if}
<span class="label">Preview</span>
<div class="preview">
<!-- svelte-ignore a11y_media_has_caption -->
<video bind:this={video} class:mirror={kind === 'camera' && preview?.getVideoTracks()[0]?.getSettings().facingMode !== 'environment'} autoplay playsinline muted></video>
{#if !preview}<p>Nothing chosen yet</p>{/if}
</div>
<div class="row two">
{#if desktop && kind === 'screen'}
<div>
<label for="sh-audio">Sound</label>
{#if info.audio === 'pipewire'}
<select id="sh-audio" bind:value={audio} onfocus={loadApps}>
<option value="">No sound</option>
<option value="all">Everything except mumh5</option>
{#each apps as a (a)}<option value={`app:${a}`}>{a}</option>{/each}
</select>
{:else if info.audio === 'loopback'}
<select id="sh-audio" bind:value={audio}>
<option value="">No sound</option>
<option value="all">Whole system (viewers also hear the voice chat again)</option>
</select>
{:else}
<select id="sh-audio" disabled><option>Not available on this system</option></select>
{/if}
</div>
{/if}
<div>
<label for="sh-quality">Quality</label>
<select id="sh-quality" bind:value={store.settings.shareQuality} onchange={() => store.saveSettings()}>
{#each Object.entries(QUALITY) as [id, q] (id)}<option value={id}>{q.label}</option>{/each}
</select>
</div>
</div>
{#if error}<p class="err">{error}</p>{/if}
{#if kind === 'camera' && error}<div class="row"><button class="btn" onclick={() => showCamera()}>Try the camera again</button></div>{/if}
{#snippet footer()}
<button class="btn" onclick={onclose}>Cancel</button>
<button class="btn primary" disabled={!preview || busy} onclick={start}>{busy ? 'Starting...' : 'Start sharing'}</button>
{/snippet}
</Modal>
<style>
.help { margin: 8px 0 0; font-size: 13px; color: var(--text-dim); }
.kinds { display: flex; gap: 2px; margin-top: 12px; border-bottom: 1px solid var(--line); }
.kinds button { padding: 6px 14px; color: var(--text-dim); font-weight: 600; }
.kinds button.active { color: var(--text); box-shadow: inset 0 -2px 0 var(--accent); }
.row { display: flex; align-items: center; gap: 8px; margin-top: 12px; }
.row.head { justify-content: space-between; margin-top: 0; }
.row.two { align-items: flex-end; gap: 12px; }
.row.two > div { flex: 1; min-width: 0; }
.label { display: block; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-dim); margin: 14px 0 6px; }
.link { color: var(--link); font-size: 13px; margin-top: 8px; }
.sources { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px; max-height: 190px; overflow-y: auto; }
.sources button { width: 100%; display: flex; flex-direction: column; gap: 4px; padding: 6px; text-align: left; border: 1px solid var(--line); background: var(--bg-0); }
.sources button:hover { border-color: var(--text-faint); }
.sources button.active { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
img, .blank { width: 100%; aspect-ratio: 16 / 9; object-fit: contain; background: #000; display: block; }
.name { font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.preview { position: relative; background: #000; aspect-ratio: 16 / 9; max-height: 240px; margin: 0 auto; width: 100%; }
.preview video { width: 100%; height: 100%; object-fit: contain; display: block; }
/* Your own camera as a mirror shows you; a camera facing away is left as it is */
.preview video.mirror { transform: scaleX(-1); }
.preview p { position: absolute; inset: 0; margin: 0; display: flex; align-items: center; justify-content: center; color: #aaa; font-size: 13px; }
.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: 12px; }
.check input { width: auto; }
.dim { color: var(--text-dim); font-size: 13px; }
.err { color: var(--danger); font-size: 13px; margin: 10px 0 0; }
</style>
+119
View File
@@ -0,0 +1,119 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { share } from '../lib/share.svelte.ts';
import { session } from '../lib/session.svelte.ts';
import { store } from '../lib/settings.svelte.ts';
// "Stats for nerds" over the stream: codecs, picture, bitrate and the network path, read
// from the WebRTC connection once a second. own: our stream (one block per viewer).
// session: whose stream, when it is one we watch
let { own, session: watched = null, onclose }: { own: boolean; session?: number | null; onclose: () => void } = $props();
type Row = [string, string];
let blocks = $state<{ title: string; rows: Row[] }[]>([]);
// Byte counters from the previous reading, for the bitrate
const last = new Map<string, { bytes: number; at: number }>();
const kbit = (key: string, bytes: number | undefined, at: number): string => {
if (bytes == null) return 'n/a';
const prev = last.get(key);
last.set(key, { bytes, at });
if (!prev || at <= prev.at) return '...';
return `${Math.round(((bytes - prev.bytes) * 8) / (at - prev.at))} kbit/s`;
};
const ms = (seconds: number | undefined) => seconds == null ? 'n/a' : `${Math.round(seconds * 1000)} ms`;
const codecName = (report: RTCStatsReport, id?: string): string => {
const c = id ? report.get(id) : null;
if (!c) return 'n/a';
const profile = /profile-level-id=([0-9a-f]+)/i.exec(c.sdpFmtpLine ?? '')?.[1];
const stereo = /stereo=1/.test(c.sdpFmtpLine ?? '') ? ', stereo' : '';
return `${String(c.mimeType).split('/')[1]}${profile ? ` (${profile})` : ''}${c.clockRate && c.mimeType.startsWith('audio') ? `, ${c.clockRate / 1000} kHz${stereo}` : ''}`;
};
const KIND: Record<string, string> = { host: 'direct', srflx: 'direct, through a router (STUN)', prflx: 'direct, through a router', relay: 'relayed (TURN)' };
function describe(report: RTCStatsReport, key: string): Row[] {
const all = [...report.values()];
const rows: Row[] = [];
const rtp = (type: string, kind: string) => all.find(s => s.type === type && s.kind === kind);
const video = rtp(own ? 'outbound-rtp' : 'inbound-rtp', 'video');
const audio = rtp(own ? 'outbound-rtp' : 'inbound-rtp', 'audio');
if (video) {
rows.push(['Video codec', codecName(report, video.codecId)]);
if (video.frameWidth) rows.push(['Picture', `${video.frameWidth}x${video.frameHeight}${video.framesPerSecond != null ? `, ${Math.round(video.framesPerSecond)} fps` : ''}`]);
rows.push(['Video bitrate', kbit(`${key}v`, own ? video.bytesSent : video.bytesReceived, video.timestamp)]);
if (own) {
if (video.encoderImplementation) rows.push(['Encoder', video.encoderImplementation]);
if (video.qualityLimitationReason && video.qualityLimitationReason !== 'none') rows.push(['Held back by', video.qualityLimitationReason]);
} else {
if (video.decoderImplementation) rows.push(['Decoder', video.decoderImplementation]);
rows.push(['Frames dropped', `${video.framesDropped ?? 0} of ${video.framesReceived ?? 0}`]);
rows.push(['Video packets lost', `${video.packetsLost ?? 0} of ${(video.packetsReceived ?? 0) + (video.packetsLost ?? 0)}`]);
rows.push(['Video jitter', ms(video.jitter)]);
}
}
if (audio) {
rows.push(['Audio codec', codecName(report, audio.codecId)]);
rows.push(['Audio bitrate', kbit(`${key}a`, own ? audio.bytesSent : audio.bytesReceived, audio.timestamp)]);
if (!own) rows.push(['Audio packets lost', String(audio.packetsLost ?? 0)]);
} else rows.push(['Audio', 'none']);
if (own) {
// What the viewer reports back about our packets
const back = all.find(s => s.type === 'remote-inbound-rtp' && s.kind === 'video');
if (back) rows.push(['Lost on the way', `${Math.round((back.fractionLost ?? 0) * 100)}%`]);
}
const transport = all.find(s => s.type === 'transport');
const pair = (transport?.selectedCandidatePairId && report.get(transport.selectedCandidatePairId)) || all.find(s => s.type === 'candidate-pair' && s.nominated && s.state === 'succeeded');
if (pair) {
const local = report.get(pair.localCandidateId), remote = report.get(pair.remoteCandidateId);
// What this side had to offer. A route discovered during the checks (prflx) is still a
// relayed one when relays were all we had.
const mine = all.filter(s => s.type === 'local-candidate' && s.candidateType !== 'prflx');
const relays = mine.filter(s => s.candidateType === 'relay');
const relayed = local?.candidateType === 'relay' || (local?.candidateType === 'prflx' && relays.length > 0 && relays.length === mine.length);
const relayVia = (local?.relayProtocol ?? relays[0]?.relayProtocol) as string | undefined;
// A relayed route is UDP beyond the relay; what matters is how we reach the relay
rows.push(['Connection', relayed ? `${KIND.relay}${relayVia ? `, reached over ${relayVia.toUpperCase()}` : ''}`
: `${KIND[local?.candidateType] ?? local?.candidateType ?? 'n/a'}, ${String(local?.protocol ?? '').toUpperCase()}`]);
const count = (type: string) => mine.filter(s => s.candidateType === type).length;
rows.push(['Routes offered', `${count('host')} direct, ${count('srflx')} through STUN, ${relays.length} relayed`]);
if (remote?.candidateType) rows.push(['Other side', KIND[remote.candidateType] ?? remote.candidateType]);
rows.push(['Round trip', ms(pair.currentRoundTripTime)]);
if (pair.availableOutgoingBitrate) rows.push(['Estimated capacity', `${Math.round(pair.availableOutgoingBitrate / 1000)} kbit/s`]);
} else rows.push(['Connection', 'not established']);
return rows;
}
async function read() {
const reports = await share.reports(own, watched ?? undefined);
blocks = reports.map(r => {
const user = r.viewer != null ? session.user(r.viewer) : undefined;
return { title: r.viewer == null ? '' : `To ${user ? store.displayName(user) : `#${r.viewer}`}`, rows: describe(r.report, String(r.viewer)) };
});
}
read();
const timer = setInterval(read, 1000);
onDestroy(() => clearInterval(timer));
</script>
<div class="nerd" role="status" aria-label="Stream statistics">
<button class="close" onclick={onclose} aria-label="Close statistics">x</button>
{#each blocks as b (b.title)}
{#if b.title}<strong>{b.title}</strong>{/if}
<dl>{#each b.rows as [k, v] (k)}<dt>{k}</dt><dd>{v}</dd>{/each}</dl>
{:else}
<p>{own ? 'Nobody is watching yet.' : 'Not connected.'}</p>
{/each}
</div>
<style>
.nerd { position: absolute; top: 8px; left: 8px; z-index: 2; max-width: calc(100% - 16px); max-height: calc(100% - 16px); overflow: auto; padding: 8px 28px 8px 10px;
background: rgb(0 0 0 / 0.78); color: #e8e8e8; font: 11px/1.5 var(--mono); user-select: text; }
dl { display: grid; grid-template-columns: auto 1fr; gap: 0 12px; margin: 0; }
dt { color: #9aa; white-space: nowrap; }
dd { margin: 0; overflow-wrap: anywhere; }
strong { display: block; margin: 6px 0 2px; color: #fff; }
strong:first-of-type { margin-top: 0; }
p { margin: 0; }
.close { position: absolute; top: 2px; right: 4px; padding: 2px 6px; color: #ccc; font: inherit; }
.close:hover { color: #fff; }
</style>
+79 -16
View File
@@ -9,6 +9,7 @@
import { PERM } from '../core/client.ts'; import { PERM } from '../core/client.ts';
import ResizeHandle from './ResizeHandle.svelte'; import ResizeHandle from './ResizeHandle.svelte';
import { voice } from '../lib/audio/voice.svelte.ts'; import { voice } from '../lib/audio/voice.svelte.ts';
import { share } from '../lib/share.svelte.ts';
// resizable: false in the phone drawer, which keeps a fixed width // resizable: false in the phone drawer, which keeps a fixed width
// handleEdge: which side the resize handle sits on (left when the list is on the right, classic layout) // handleEdge: which side the resize handle sits on (left when the list is on the right, classic layout)
@@ -22,6 +23,9 @@
const roots = $derived(session.childrenOf(null)); const roots = $derived(session.childrenOf(null));
const self = $derived(session.self); const self = $derived(session.self);
// Without a connection the buttons show the remembered choice, applied at the next connect
const muted = $derived(self ? self.selfMute : sessions.mute);
const deafened = $derived(self ? self.selfDeaf : sessions.deaf);
const cert = $derived(session.serverCert); const cert = $derived(session.serverCert);
const trusted = $derived(!!cert?.authorized); const trusted = $derived(!!cert?.authorized);
@@ -40,6 +44,28 @@
]); ]);
} }
function editDescription() {
if (!self) return;
session.showUser(self.session);
ui.editDescription = true;
ui.panelOpen = true;
}
// Sharing runs on one server at a time; the button stops it from anywhere
const sharingHere = $derived(!!share.stream);
const canShare = $derived(session.status === 'connected' && share.supported(sessions.active));
function toggleShare() {
if (share.stream) share.stop();
else share.start(sessions.active);
}
// One button for the whole tree: open everything, then close everything
let allOpen = $state(false);
function toggleAll() {
allOpen = !allOpen;
session.setAllExpanded(allOpen);
}
function openQuick(e: MouseEvent, kind: 'input' | 'output') { function openQuick(e: MouseEvent, kind: 'input' | 'output') {
e.preventDefault(); e.preventDefault();
const r = (e.currentTarget as HTMLElement).getBoundingClientRect(); const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
@@ -111,8 +137,8 @@
<button class="btn" onclick={() => sessions.setVoice(sessions.active)}>Talk here</button> <button class="btn" onclick={() => sessions.setVoice(sessions.active)}>Talk here</button>
</div> </div>
{/if} {/if}
{#if self} <footer class="me">
<footer class="me"> {#if self}
<button class="profile" title="Your profile" onclick={() => { session.showUser(self.session); ui.panelOpen = true; }} <button class="profile" title="Your profile" onclick={() => { session.showUser(self.session); ui.panelOpen = true; }}
oncontextmenu={e => menu.show(e, userMenu(self))}> oncontextmenu={e => menu.show(e, userMenu(self))}>
<Avatar name={self.name} size={32} speaking={voice.transmitting} /> <Avatar name={self.name} size={32} speaking={voice.transmitting} />
@@ -121,18 +147,46 @@
<span>{voice.error ? 'Voice unavailable' : voice.settings.mode === 'ptt' && !self.selfMute ? (voice.pttDown ? 'Talking' : 'Push to talk') : session.channel(self.channelId)?.name || 'Root'}</span> <span>{voice.error ? 'Voice unavailable' : voice.settings.mode === 'ptt' && !self.selfMute ? (voice.pttDown ? 'Talking' : 'Push to talk') : session.channel(self.channelId)?.name || 'Root'}</span>
</span> </span>
</button> </button>
<button class="icon-btn" class:on={self.selfMute} title={self.selfMute ? 'Unmute (right-click for microphone options)' : 'Mute (right-click for microphone options)'} {:else}
aria-label={self.selfMute ? 'Unmute' : 'Mute'} <span class="who offline-who" title="Mute and deafen apply when you connect">
onclick={() => session.setSelfMute(!self.selfMute)} oncontextmenu={e => openQuick(e, 'input')}> <strong>{session.status === 'connecting' ? 'Connecting...' : 'Not connected'}</strong>
<Icon name={self.selfMute ? 'mic-off' : 'mic'} size={18} /> <span>{deafened ? 'Deafened' : muted ? 'Muted' : 'Microphone on'}</span>
</button> </span>
<button class="icon-btn" class:on={self.selfDeaf} title={self.selfDeaf ? 'Undeafen (right-click for output options)' : 'Deafen (right-click for output options)'} {/if}
aria-label={self.selfDeaf ? 'Undeafen' : 'Deafen'} <div class="tools">
onclick={() => session.setSelfDeaf(!self.selfDeaf)} oncontextmenu={e => openQuick(e, 'output')}> <button class="icon-btn info" title="Server information" aria-label="Server information"
<Icon name={self.selfDeaf ? 'headphones-off' : 'headphones'} size={18} /> disabled={session.status !== 'connected'} onclick={() => (ui.serverInfo = true)}>
</button> <Icon name="info" size={18} />
</footer> </button>
{/if} <button class="icon-btn" class:on={muted} title={muted ? 'Unmute (right-click for microphone options)' : 'Mute (right-click for microphone options)'}
aria-label={muted ? 'Unmute' : 'Mute'}
onclick={() => session.setSelfMute(!muted)} oncontextmenu={e => openQuick(e, 'input')}>
<Icon name={muted ? 'mic-off' : 'mic'} size={18} />
</button>
<button class="icon-btn" class:on={deafened} title={deafened ? 'Undeafen (right-click for output options)' : 'Deafen (right-click for output options)'}
aria-label={deafened ? 'Undeafen' : 'Deafen'}
onclick={() => session.setSelfDeaf(!deafened)} oncontextmenu={e => openQuick(e, 'output')}>
<Icon name={deafened ? 'headphones-off' : 'headphones'} size={18} />
</button>
<button class="icon-btn" class:live={sharingHere} title={sharingHere ? `Stop sharing your ${share.kind}` : 'Share your screen or camera with your channel'}
aria-label={sharingHere ? `Stop sharing your ${share.kind}` : 'Share your screen or camera'} disabled={!sharingHere && !canShare} onclick={toggleShare}>
<Icon name={sharingHere ? share.kind : 'screen'} size={18} />
</button>
<span class="sep"></span>
<button class="icon-btn" title="Change your description" aria-label="Change your description" disabled={!self} onclick={editDescription}>
<Icon name="message" size={18} />
</button>
<span class="sep"></span>
<button class="icon-btn" title="Configure" aria-label="Configure" onclick={() => (ui.settingsOpen = true)}>
<Icon name="settings" size={18} />
</button>
<span class="sep"></span>
<button class="icon-btn" title={allOpen ? 'Collapse all channels' : 'Expand all channels'} aria-label={allOpen ? 'Collapse all channels' : 'Expand all channels'}
disabled={session.status !== 'connected'} onclick={toggleAll}>
<Icon name="filter" size={18} />
</button>
</div>
</footer>
</aside> </aside>
<style> <style>
@@ -162,10 +216,19 @@
.elsewhere { flex: none; display: flex; align-items: center; gap: 8px; padding: 8px 10px; font-size: 12px; color: var(--text-dim); background: var(--bg-0); border-top: 1px solid var(--line); } .elsewhere { flex: none; display: flex; align-items: center; gap: 8px; padding: 8px 10px; font-size: 12px; color: var(--text-dim); background: var(--bg-0); border-top: 1px solid var(--line); }
.elsewhere span { flex: 1; min-width: 0; } .elsewhere span { flex: 1; min-width: 0; }
.elsewhere .btn { padding: 4px 10px; font-size: 12px; } .elsewhere .btn { padding: 4px 10px; font-size: 12px; }
.me { flex: none; height: var(--footer-h); display: flex; align-items: center; gap: 8px; padding: 0 8px; background: var(--bg-0); } /* One row when the sidebar is wide enough, otherwise the buttons go under the profile */
.profile { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; padding: 2px; text-align: left; } .me { flex: none; min-height: var(--footer-h); display: flex; flex-wrap: wrap; align-items: center; gap: 2px 8px; padding: 4px 8px; background: var(--bg-0); }
.tools { flex: none; max-width: 100%; display: flex; flex-wrap: wrap; align-items: center; gap: 2px; }
.sep { width: 1px; height: 20px; margin: 0 4px; background: var(--line); }
/* Stacked layout: the bar leads the stack, like the desktop client's toolbar */
.sidebar.fill .me { order: -1; border-bottom: 1px solid var(--line); }
.sidebar.fill .me .profile, .sidebar.fill .me .offline-who { display: none; }
.profile { flex: 1 1 110px; min-width: 0; display: flex; align-items: center; gap: 8px; padding: 2px; text-align: left; }
.profile:hover { background: var(--bg-hover); } .profile:hover { background: var(--bg-hover); }
.who { flex: 1; min-width: 0; display: flex; flex-direction: column; line-height: 1.2; } .who { flex: 1; min-width: 0; display: flex; flex-direction: column; line-height: 1.2; }
.who strong, .who span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .who strong, .who span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.who span { font-size: 12px; color: var(--text-dim); } .who span { font-size: 12px; color: var(--text-dim); }
.info, .info:hover { color: var(--warn); }
.live, .live:hover { color: var(--speaking); }
.offline-who { flex: 1 1 110px; padding: 2px 2px 2px 6px; color: var(--text-dim); }
</style> </style>
+12 -1
View File
@@ -23,6 +23,11 @@
const client = session.client; const client = session.client;
const user = $derived(session.user(who)); const user = $derived(session.user(who));
const isSelf = who === session.self?.session;
// Browser build: our address as the web proxy saw it
// A proxy on this same machine connects from our own address, so what the server sees is ours
const viaProxy = session.serverCert?.clientAddress ?? '';
const ownAddress = /^(127\.|::1$|localhost$)/.test(viaProxy) ? '' : viaProxy;
const off = client?.on('userStats', msg => { const off = client?.on('userStats', msg => {
if (msg.session !== who) return; if (msg.session !== who) return;
@@ -84,7 +89,13 @@
<dt>Ping (UDP)</dt><dd>{stats.udp_packets ? ping(stats.udp_ping_avg, stats.udp_ping_var) : 'n/a'}</dd> <dt>Ping (UDP)</dt><dd>{stats.udp_packets ? ping(stats.udp_ping_avg, stats.udp_ping_var) : 'n/a'}</dd>
<dt>Pings received</dt><dd>{stats.tcp_packets ?? 0} over TCP, {stats.udp_packets ?? 0} over UDP</dd> <dt>Pings received</dt><dd>{stats.tcp_packets ?? 0} over TCP, {stats.udp_packets ?? 0} over UDP</dd>
{#if stats.bandwidth != null}<dt>Bandwidth</dt><dd>{formatBytes(stats.bandwidth)}/s</dd>{/if} {#if stats.bandwidth != null}<dt>Bandwidth</dt><dd>{formatBytes(stats.bandwidth)}/s</dd>{/if}
{#if stats.address?.length}<dt>Address</dt><dd class="mono">{address(stats.address)}</dd>{/if} {#if isSelf && ownAddress}
<!-- Through the web proxy the server only knows the proxy's address -->
<dt>Address</dt><dd class="mono">{ownAddress}</dd>
{#if stats.address?.length && address(stats.address) !== ownAddress}<dt>Seen by the server</dt><dd><span class="mono">{address(stats.address)}</span> (the web proxy)</dd>{/if}
{:else if stats.address?.length}
<dt>Address</dt><dd class="mono">{address(stats.address)}</dd>
{/if}
</dl> </dl>
{#if stats.version} {#if stats.version}
+2
View File
@@ -114,6 +114,8 @@
</div> </div>
<h3>Processing</h3> <h3>Processing</h3>
<label class="check"><input type="checkbox" checked={s.releaseMicMuted} onchange={e => voice.update({ releaseMicMuted: e.currentTarget.checked })} /> Give the microphone back while muted</label>
<p class="help">For Bluetooth headsets: with the microphone open, a headset switches to its telephone mode and everything sounds like a phone call. Muted, you then hear in full quality. To talk in full quality too, choose the phone's own microphone above instead of the headset's. No reminder when you speak while muted.{#if s.releaseMicMuted && voice.running} <strong>{voice.micOpen ? 'The microphone is open now.' : 'The microphone is given back now.'}</strong>{/if}</p>
<label class="check"><input type="checkbox" checked={s.noiseSuppression} onchange={e => voice.update({ noiseSuppression: e.currentTarget.checked })} /> Noise suppression</label> <label class="check"><input type="checkbox" checked={s.noiseSuppression} onchange={e => voice.update({ noiseSuppression: e.currentTarget.checked })} /> Noise suppression</label>
<label class="check"><input type="checkbox" checked={s.echoCancellation} onchange={e => voice.update({ echoCancellation: e.currentTarget.checked })} /> Echo cancellation (for speakers instead of headphones)</label> <label class="check"><input type="checkbox" checked={s.echoCancellation} onchange={e => voice.update({ echoCancellation: e.currentTarget.checked })} /> Echo cancellation (for speakers instead of headphones)</label>
<label class="check"><input type="checkbox" checked={s.autoGainControl} onchange={e => voice.update({ autoGainControl: e.currentTarget.checked })} /> Automatic gain control</label> <label class="check"><input type="checkbox" checked={s.autoGainControl} onchange={e => voice.update({ autoGainControl: e.currentTarget.checked })} /> Automatic gain control</label>
+417 -34
View File
@@ -7,26 +7,360 @@
import { ui } from '../lib/ui.svelte.ts'; import { ui } from '../lib/ui.svelte.ts';
import { userMenu } from '../lib/actions.ts'; import { userMenu } from '../lib/actions.ts';
import { voice } from '../lib/audio/voice.svelte.ts'; import { voice } from '../lib/audio/voice.svelte.ts';
import { share } from '../lib/share.svelte.ts';
import { sessions } from '../lib/session.svelte.ts';
import ResizeHandle from './ResizeHandle.svelte';
import ShareStats from './ShareStats.svelte';
// People in your voice channel as tiles. Shown on windows too narrow for the member list
// (always), and on any window while someone in the channel shares something. A screen is shown
// large above the tiles, which become a strip; cameras play in the tiles themselves, as many
// as are on, and a click enlarges one.
// videoOnly: just the picture, as wide as the chat (stacked layout, where the channel list
// above already shows who is here)
// fill: take all the room the parent gives, without a height of its own (the voice screen)
let { always = false, videoOnly = false, fill = false }: { always?: boolean; videoOnly?: boolean; fill?: boolean } = $props();
// People in your voice channel as tiles, for windows too narrow for the member list
const self = $derived(session.self); const self = $derived(session.self);
const channel = $derived(self ? session.channel(self.channelId) : undefined); const channel = $derived(self ? session.channel(self.channelId) : undefined);
const people = $derived(self ? session.usersIn(self.channelId) : []); const inChannel = $derived(self ? session.usersIn(self.channelId) : []);
const here = (s: { server?: { id: string } | null } | null | undefined) => !!s && s.server?.id === session.server?.id;
let collapsed = $state(load()); const watches = $derived(share.watches.filter(w => here(w.host)));
// The screen being watched; cameras are watched next to it
const watching = $derived(watches.find(w => w.kind === 'screen') ?? null);
const watchOf = (s: number) => watches.find(w => w.session === s);
const mine = $derived(share.stream && here(share.host) ? share.stream : null);
const anySharing = $derived(inChannel.some(u => session.sharing[u.session]));
const anyCamera = $derived(inChannel.some(u => session.sharing[u.session] === 'camera' && u.session !== self?.session));
const visible = $derived(!!self && (videoOnly ? watches.length > 0 || !!mine : always || anySharing || watches.length > 0 || !!mine));
// The picture we have of someone: our own stream, or one we are watching
function streamOf(s: number): MediaStream | null {
if (s === self?.session) return mine;
return watchOf(s)?.stream ?? null;
}
// A camera plays in the person's tile; a screen never does
const camOf = (s: number) => session.sharing[s] === 'camera' ? streamOf(s) : null;
// People with a stream we can show come first, then others who share
const rank = (s: number) => streamOf(s) ? 0 : session.sharing[s] ? 1 : 2;
const people = $derived([...inChannel].sort((a, b) => rank(a.session) - rank(b.session)));
// Who gets a tile: everyone, or in the stacked layout only the people whose camera we see
const shown = $derived(videoOnly ? people.filter(u => camOf(u.session)) : people);
// Tile on the large stage
let focus = $state<number | null>(null);
const focused = $derived(focus != null ? streamOf(focus) : null);
const focusUser = $derived(focus != null ? session.user(focus) : undefined);
// Screen or camera, for the wording and the icons
const focusKind = $derived((focus != null && session.sharing[focus]) || 'screen');
// A stream we just started watching goes to the stage; it leaves when the stream ends
let lastWatch: number | null = null;
$effect(() => {
const now = watching?.session ?? null;
if (now !== lastWatch) {
if (now != null) focus = now;
else if (focus === lastWatch) focus = null;
lastWatch = now;
}
});
// Our own stream opens large when it starts, unless another one is on the stage
let hadMine = false;
$effect(() => {
const has = !!mine;
if (has && !hadMine && focus == null && self && share.kind === 'screen') focus = self.session;
else if (!has && focus === self?.session) focus = null;
hadMine = has;
});
// Without tiles there is nothing to choose: the watched stream, else our own
let lastSolo: number | null = null;
$effect(() => {
if (!videoOnly) return;
const solo = watching ? watching.session : mine && share.kind === 'screen' ? self?.session ?? null : null;
if (solo !== lastSolo) { focus = solo; lastSolo = solo; }
});
// The stage is as tall as the user drags it; the tiles grow and shrink to fill it. The
// meeting view (one picture large) remembers its own height.
const stored = (key: string, fallback: number) => { try { return Number(localStorage.getItem(key)) || fallback; } catch { return fallback; } };
let tilesHeight = $state(stored('mumh5.stageHeight', 210));
let meetingHeight = $state(stored('mumh5.stageHeightMeeting', 460));
const maxHeight = () => Math.max(160, window.innerHeight - 220);
// The tiles fill the stage completely, like a meeting's tile view: rows of equal tiles, with
// the number of columns chosen so the tiles come closest to a 16:9 shape. A last row with
// fewer people stretches, so no space is left over. Measured live, so it follows every resize.
let gridW = $state(0), gridH = $state(0);
const GAP = 8, PAD = 8;
const cols = $derived.by(() => {
const n = shown.length;
const w = gridW - PAD * 2, h = gridH - PAD * 2;
if (n < 2 || w <= 0 || h <= 0) return 1;
let best = 1, bestSize = 0;
for (let c = 1; c <= n; c++) {
const r = Math.ceil(n / c);
// Width of the largest 16:9 picture that fits one cell
const size = Math.min((w - GAP * (c - 1)) / c, ((h - GAP * (r - 1)) / r) * 16 / 9);
if (size > bestSize + 0.5) { best = c; bestSize = size; }
}
return best;
});
// In the meeting view everyone sits in one row under the picture
const rows = $derived.by(() => {
if (focused) return [shown];
const out: (typeof shown)[] = [];
for (let i = 0; i < shown.length; i += cols) out.push(shown.slice(i, i + cols));
return out;
});
const avatar = $derived.by(() => {
if (focused) return 36;
const cellW = (gridW - PAD * 2 - GAP * (cols - 1)) / cols;
const cellH = (gridH - PAD * 2 - GAP * (rows.length - 1)) / Math.max(1, rows.length);
return Math.max(24, Math.min(128, Math.round(Math.min(cellH * 0.5, cellW * 0.34))));
});
let folded = $state(load());
const collapsed = $derived(folded && !fill);
function load(): boolean { function load(): boolean {
try { return localStorage.getItem('mumh5.stageCollapsed') === '1'; } catch { return false; } try { return localStorage.getItem('mumh5.stageCollapsed') === '1'; } catch { return false; }
} }
function toggle() { function toggle() {
collapsed = !collapsed; if (fill) return;
try { localStorage.setItem('mumh5.stageCollapsed', collapsed ? '1' : '0'); } catch { /* storage unavailable */ } folded = !folded;
try { localStorage.setItem('mumh5.stageCollapsed', folded ? '1' : '0'); } catch { /* storage unavailable */ }
} }
const talking = (s: number) => !!voice.talking[s] || (s === self?.session && voice.transmitting); const talking = (s: number) => !!voice.talking[s] || (s === self?.session && voice.transmitting);
// Mobile browsers do not always honour autoplay for a stream attached later, so play is asked for too
function media(node: HTMLMediaElement, stream: MediaStream | null) {
const attach = (s: MediaStream | null) => {
node.srcObject = s;
if (s) node.play().catch(() => {});
};
attach(stream);
return { update(next: MediaStream | null) { if (node.srcObject !== next) attach(next); } };
}
// Sound comes from hidden players, one per stream that has any, so it plays once however
// many pictures are shown. They all follow the stream volume.
const withSound = $derived(watches.filter(w => w.stream?.getAudioTracks().length));
const hasSound = $derived(focus != null && withSound.some(w => w.session === focus));
function loud(node: HTMLMediaElement) {
$effect(() => {
node.volume = store.settings.shareVolume;
node.muted = store.settings.shareVolume === 0;
});
}
const focusWatch = $derived(focus != null ? watchOf(focus) : undefined);
function tileClick(session_: number) {
if (streamOf(session_)) focus = focus === session_ ? null : session_;
else if (session.sharing[session_] && session_ !== self?.session) share.watch(sessions.active, session_);
else { session.showUser(session_); ui.panelOpen = true; }
}
// Right-click on the picture
let showStats = $state(false);
const streamItems = () => [
{ label: 'Open in a separate window', action: popOut },
{ label: 'Full screen', action: fullscreen },
{ separator: true, label: '' },
{ label: 'Stats for nerds', checked: showStats, action: () => (showStats = !showStats) }
];
const streamMenu = (e: MouseEvent) => menu.show(e, streamItems());
// Touch screens have no right-click: holding a finger on the picture opens the same menu
let hold: ReturnType<typeof setTimeout> | undefined;
function touchStart(e: TouchEvent) {
const touch = e.touches[0];
clearTimeout(hold);
if (e.touches.length !== 1) return;
const { clientX, clientY } = touch;
hold = setTimeout(() => menu.showAt(clientX, clientY, streamItems()), 550);
}
const touchEnd = () => clearTimeout(hold);
let frame = $state<HTMLElement>();
function fullscreen() {
if (document.fullscreenElement) document.exitFullscreen();
else frame?.requestFullscreen().catch(() => {});
}
// The large picture in a window of its own, to put on another screen. The window is an
// empty page that gets the same stream; sound keeps coming from the main window.
let popup: Window | null = null;
let popVideo: HTMLVideoElement | null = null;
let spotVideo = $state<HTMLVideoElement>();
let popped = $state(false);
function popOut() {
if (popup && !popup.closed) return popup.focus();
const w = window.open('', 'mumh5-stream', 'popup,width=960,height=600');
if (!w) {
// Refused (a pop-up blocker, or an app build that predates this). The floating
// picture-in-picture window needs no permission where the browser has it.
if (document.pictureInPictureEnabled && spotVideo) {
spotVideo.requestPictureInPicture().catch(() => { share.error = 'The stream could not be opened in a separate window.'; });
} else {
share.error = 'The stream could not be opened in a separate window. If your browser blocked a pop-up, allow it for this page and try again.';
}
return;
}
popup = w;
popped = true;
w.document.body.style.cssText = 'margin:0;background:#000;overflow:hidden';
const v = w.document.createElement('video');
v.autoplay = true;
v.muted = true;
v.playsInline = true;
v.style.cssText = 'width:100vw;height:100vh;object-fit:contain;display:block';
v.ondblclick = () => { if (w.document.fullscreenElement) w.document.exitFullscreen(); else v.requestFullscreen().catch(() => {}); };
w.document.body.replaceChildren(v);
popVideo = v;
w.addEventListener('pagehide', () => { if (popup === w) { popup = null; popVideo = null; popped = false; } });
}
function popIn() {
popup?.close();
popup = null;
popVideo = null;
popped = false;
}
// The window follows the picture on the stage and closes with it
$effect(() => {
const stream = focused;
const title = focus === self?.session ? `Your ${focusKind}` : focusUser ? store.displayName(focusUser) : 'Screen share';
if (!popped) return;
if (!stream) return popIn();
if (popVideo && popVideo.srcObject !== stream) popVideo.srcObject = stream;
if (popup) popup.document.title = `${title} - mumh5`;
});
$effect(() => () => popup?.close());
</script> </script>
{#if self} {#if voice.error && session.status === 'connected'}<div class="bar bad" role="alert"><span>Voice is not working: {voice.error}</span></div>{/if}
<section class="stage" aria-label="Voice channel"> {#if share.error}<div class="bar bad" role="alert"><span>{share.error}</span><button class="btn" onclick={() => (share.error = '')}>Dismiss</button></div>{/if}
{#snippet switcher()}
{#if mine && share.kind === 'camera' && share.cameras > 1}
<button class="icon-btn" title="Switch to the next camera" aria-label="Switch camera" disabled={share.switching} onclick={() => share.switchCamera()}>
<Icon name="switch" size={18} />
</button>
{/if}
{/snippet}
{#snippet spot()}
{#if self && focus != null && focused}
<div class="spot" role="region" aria-label="Screen share" bind:this={frame}>
<div class="spot-head">
<Icon name={focusKind} size={16} />
<span class="spot-who">{focus === self.session ? `Your ${focusKind}` : focusUser ? store.displayName(focusUser) : 'Screen share'}</span>
{#if focus !== self.session && hasSound}
<Icon name={store.settings.shareVolume === 0 ? 'headphones-off' : 'headphones'} size={16} />
<input class="vol" type="range" min="0" max="1" step="0.01" bind:value={store.settings.shareVolume} onchange={() => store.saveSettings()}
aria-label="Stream volume" title="Stream volume" />
{/if}
{#if popped}
<button class="btn small" onclick={popIn}>Bring back</button>
{:else}
<button class="icon-btn" title="Open in a separate window" aria-label="Open in a separate window" onclick={popOut}><Icon name="popout" size={16} /></button>
<button class="icon-btn" title="Full screen" aria-label="Full screen" onclick={fullscreen}><Icon name="expand" size={16} /></button>
{/if}
{#if focus !== self.session}<button class="btn small" onclick={() => { if (focus != null) share.unwatch(focus); }}>Stop watching</button>
{:else if videoOnly}
{@render switcher()}
<span class="status" role="status" title="People watching your {share.kind}">{share.viewers} watching</span>
<button class="btn small" onclick={() => share.stop()}>Stop sharing</button>
{/if}
{#if !videoOnly || shown.length}<button class="icon-btn" title="Back to tiles" aria-label="Back to tiles" onclick={() => (focus = null)}><Icon name="x" size={16} /></button>{/if}
</div>
<!-- svelte-ignore a11y_media_has_caption -->
{#if popped}
<p class="away">Shown in a separate window.</p>
{:else}
<div class="pic" role="presentation" oncontextmenu={streamMenu}
ontouchstart={touchStart} ontouchend={touchEnd} ontouchmove={touchEnd} ontouchcancel={touchEnd}>
<!-- Large, your own camera is shown as the others see it; only the small tile is a mirror -->
<video bind:this={spotVideo} use:media={focused} autoplay playsinline muted ondblclick={fullscreen}></video>
{#if focus !== self.session && focusWatch && focusWatch.state !== 'live'}
<p class="pending" class:bad={focusWatch.state === 'failed'}>{focusWatch.state === 'failed'
? 'Could not connect to the stream. Across the internet both of you need a STUN server; networks that block direct connections need a relay. Both are in Settings, Voice.'
: 'Connecting to the stream...'}</p>
{/if}
{#if showStats}<ShareStats own={focus === self.session} session={focus} onclose={() => (showStats = false)} />{/if}
</div>
{/if}
</div>
{/if}
{/snippet}
{#snippet tiles()}
<div class="tiles" class:strip={!!focused} role="group" aria-label="People in this channel" bind:clientWidth={gridW} bind:clientHeight={gridH}>
{#each rows as row, r (r)}
<div class="row" role="presentation">
{#each row as u (u.session)}
{@const stream = streamOf(u.session)}
{@const sharing = session.sharing[u.session]}
{@const isSelf = u.session === self?.session}
<!-- While a camera is shown large, its tile goes back to the avatar: one picture, not two -->
{@const cam = focus === u.session ? null : camOf(u.session)}
{@const w = watchOf(u.session)}
<button class="tile" class:talking={talking(u.session)} class:self={isSelf} class:focused={focus === u.session}
onclick={() => tileClick(u.session)}
oncontextmenu={e => menu.show(e, userMenu(u))}
title={stream ? (focus === u.session ? 'Hide the stream' : 'Show the stream') : sharing && !isSelf ? `Sharing their ${sharing}. Click to watch` : store.displayName(u)}
aria-label={stream ? `${focus === u.session ? 'Hide' : 'Show'} the ${sharing || 'stream'} of ${store.displayName(u)}` : sharing && !isSelf ? `Watch the ${sharing} of ${store.displayName(u)}` : store.displayName(u)}>
{#if cam}
<!-- svelte-ignore a11y_media_has_caption -->
<video class:mirror={isSelf && share.mirror} use:media={cam} autoplay playsinline muted></video>
{:else}
<Avatar name={u.name} size={avatar} speaking={talking(u.session)} />
{/if}
<span class="who">{store.displayName(u)}</span>
{#if w && w.state !== 'live'}
<span class="note">{w.state === 'connecting' ? 'Connecting...' : 'Could not connect'}</span>
{/if}
{#if sharing}<span class="badge live"><Icon name={sharing} size={13} /></span>
{:else if u.deaf || u.selfDeaf}
<span class="badge" class:admin={u.deaf}><Icon name="headphones-off" size={13} /></span>
{:else if u.mute || u.selfMute || u.suppress}
<span class="badge" class:admin={u.mute || u.suppress}><Icon name="mic-off" size={13} /></span>
{/if}
</button>
{/each}
</div>
{/each}
</div>
{/snippet}
{#if visible && self && videoOnly}
<section class="stage solo" aria-label="Voice channel">
{#each withSound as w (w.session)}<audio use:media={w.stream} use:loud autoplay></audio>{/each}
{#if focused}
{@render spot()}
{:else if watching}
<p class="wait" class:bad={watching.state === 'failed'}>
<span>{watching.state === 'failed' ? 'Could not connect to the stream. Across the internet both of you need a STUN server set in Settings, Voice.' : 'Connecting to the stream...'}</span>
<button class="btn small" onclick={() => share.unwatch(watching.session)}>{watching.state === 'failed' ? 'Close' : 'Cancel'}</button>
</p>
{/if}
<!-- Cameras: a grid of just the pictures -->
{#if shown.length && !focused}
<div class="camgrid" style:height="{tilesHeight}px">
<ResizeHandle bind:width={tilesHeight} edge="bottom" min={110} max={maxHeight} initial={210} storageKey="mumh5.stageHeight" label="Resize the tiles" />
{@render tiles()}
</div>
{/if}
</section>
{:else if visible && self}
<section class="stage" class:meeting={!!focused} class:collapsed class:fill aria-label="Voice channel"
style:height={collapsed || fill ? undefined : `${focused ? meetingHeight : tilesHeight}px`}>
{#if !collapsed && !fill}
{#if focused}
<ResizeHandle bind:width={meetingHeight} edge="bottom" min={240} max={maxHeight} initial={460} storageKey="mumh5.stageHeightMeeting" label="Resize the meeting view" />
{:else}
<ResizeHandle bind:width={tilesHeight} edge="bottom" min={110} max={maxHeight} initial={210} storageKey="mumh5.stageHeight" label="Resize the tiles" />
{/if}
{/if}
<header> <header>
<button class="title" onclick={toggle} aria-expanded={!collapsed}> <button class="title" onclick={toggle} aria-expanded={!collapsed}>
<svg class="caret" class:collapsed width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><path d="M2 3l3 4 3-4" fill="none" stroke="currentColor" stroke-width="1.6" /></svg> <svg class="caret" class:collapsed width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><path d="M2 3l3 4 3-4" fill="none" stroke="currentColor" stroke-width="1.6" /></svg>
@@ -38,6 +372,17 @@
</span> </span>
{/if} {/if}
</button> </button>
{#if anyCamera || share.cams}
<button class="icon-btn" class:live={share.cams} aria-pressed={share.cams} title={share.cams ? 'Hide everyone\'s camera' : 'Show everyone\'s camera'}
aria-label={share.cams ? 'Hide all cameras' : 'Show all cameras'} onclick={() => share.setCams(sessions.active, !share.cams)}>
<Icon name="camera" size={18} />
</button>
{/if}
{#if mine}
{@render switcher()}
<span class="status" role="status" title="People watching your {share.kind}"><Icon name={share.kind} size={14} />{share.viewers} watching</span>
<button class="btn small" onclick={() => share.stop()}>Stop sharing</button>
{/if}
<button class="icon-btn" class:on={self.selfMute} aria-label={self.selfMute ? 'Unmute' : 'Mute'} title={self.selfMute ? 'Unmute' : 'Mute'} <button class="icon-btn" class:on={self.selfMute} aria-label={self.selfMute ? 'Unmute' : 'Mute'} title={self.selfMute ? 'Unmute' : 'Mute'}
onclick={() => session.setSelfMute(!self.selfMute)}> onclick={() => session.setSelfMute(!self.selfMute)}>
<Icon name={self.selfMute ? 'mic-off' : 'mic'} size={18} /> <Icon name={self.selfMute ? 'mic-off' : 'mic'} size={18} />
@@ -47,31 +392,24 @@
<Icon name={self.selfDeaf ? 'headphones-off' : 'headphones'} size={18} /> <Icon name={self.selfDeaf ? 'headphones-off' : 'headphones'} size={18} />
</button> </button>
</header> </header>
{#each withSound as w (w.session)}<audio use:media={w.stream} use:loud autoplay></audio>{/each}
{#if focused && focus != null && !collapsed}{@render spot()}{/if}
{#if !collapsed} {#if !collapsed}
<ul class="tiles"> {@render tiles()}
{#each people as u (u.session)} {#if watches.some(w => w.state === 'failed')}
<li> <p class="failed">Could not connect to the stream. Across the internet both of you need a STUN server, chosen in Settings, Voice (the person sharing has to restart the stream afterwards).</p>
<button class="tile" class:talking={talking(u.session)} class:self={u.session === self.session} {/if}
onclick={() => { session.showUser(u.session); ui.panelOpen = true; }}
oncontextmenu={e => menu.show(e, userMenu(u))} title={store.displayName(u)}>
<Avatar name={u.name} size={44} speaking={talking(u.session)} />
<span class="who">{store.displayName(u)}</span>
{#if u.deaf || u.selfDeaf}
<span class="badge" class:admin={u.deaf}><Icon name="headphones-off" size={13} /></span>
{:else if u.mute || u.selfMute || u.suppress}
<span class="badge" class:admin={u.mute || u.suppress}><Icon name="mic-off" size={13} /></span>
{/if}
</button>
</li>
{/each}
</ul>
{/if} {/if}
</section> </section>
{/if} {/if}
<style> <style>
.stage { flex: none; border-bottom: 1px solid var(--line); background: var(--bg-1); } .stage.fill { flex: 1; border-bottom: 0; }
header { display: flex; align-items: center; gap: 2px; padding: 4px 8px 4px 4px; } .stage { position: relative; flex: none; display: flex; flex-direction: column; min-height: 0; border-bottom: 1px solid var(--line); background: var(--bg-0); }
header { flex: none; display: flex; align-items: center; gap: 2px; padding: 4px 8px 4px 4px; background: var(--bg-1); }
.title { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; padding: 6px 8px; text-align: left; } .title { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; padding: 6px 8px; text-align: left; }
.title:hover { background: var(--bg-hover); } .title:hover { background: var(--bg-hover); }
.caret { flex: none; color: var(--text-faint); } .caret { flex: none; color: var(--text-faint); }
@@ -79,14 +417,59 @@
.name { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .name { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.count { font-size: 12px; color: var(--text-faint); } .count { font-size: 12px; color: var(--text-faint); }
.mini { display: flex; gap: 3px; min-width: 0; overflow: hidden; margin-left: 4px; } .mini { display: flex; gap: 3px; min-width: 0; overflow: hidden; margin-left: 4px; }
.tiles { list-style: none; margin: 0; padding: 0 10px 10px; display: grid; grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); gap: 6px;
max-height: 40vh; overflow-y: auto; } /* Tile view: equal 16:9 tiles, centered, sized by the script to fill the stage */
.tile { position: relative; width: 100%; display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 10px 4px 8px; .tiles { flex: 1; min-height: 0; padding: 8px; display: flex; flex-direction: column; gap: 8px; overflow: hidden; }
background: var(--bg-2); border: 1px solid var(--line); transition: border-color 80ms, box-shadow 80ms; } .row { flex: 1 1 0; min-height: 0; display: flex; gap: 8px; }
.tile { position: relative; flex: 1 1 0; min-width: 0; min-height: 0; display: flex; align-items: center; justify-content: center; overflow: hidden;
background: var(--bg-2); border: 2px solid transparent; transition: border-color 80ms; }
.tile:hover { background: var(--bg-hover); } .tile:hover { background: var(--bg-hover); }
.tile.talking { border-color: var(--speaking); box-shadow: inset 0 0 0 1px var(--speaking); } .tile.talking { border-color: var(--speaking); box-shadow: 0 0 0 1px var(--speaking), 0 0 18px -4px var(--speaking); }
.who { max-width: 100%; font-size: 12px; color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .tile.focused { border-color: var(--accent); }
.tile.self .who { color: var(--text); font-weight: 600; } /* A camera keeps its own shape inside the tile, whatever size the tile is: nothing is cut
.badge { position: absolute; top: 4px; right: 4px; display: inline-flex; color: var(--text-faint); } off, a phone held upright gets bars at the sides. Your own is mirrored, like a mirror. */
.tile video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; background: #000; }
.tile video.mirror { transform: scaleX(-1); }
.icon-btn.live, .icon-btn.live:hover { color: var(--speaking); }
.camgrid { position: relative; display: flex; flex-direction: column; flex: none; }
/* Name tag over the lower left corner */
.who { position: absolute; left: 4px; bottom: 4px; max-width: calc(100% - 8px); padding: 1px 6px; font-size: 12px; color: #fff; background: rgb(0 0 0 / 0.6);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tile.self .who { font-weight: 600; }
.note { position: absolute; left: 0; right: 0; top: 4px; text-align: center; font-size: 11px; color: var(--text-dim); }
.badge { position: absolute; top: 4px; right: 4px; display: inline-flex; padding: 2px; color: #fff; background: rgb(0 0 0 / 0.6); }
.badge.admin { color: var(--danger); } .badge.admin { color: var(--danger); }
.badge.live { color: var(--speaking); }
.failed { flex: none; margin: 0; padding: 0 12px 8px; font-size: 12px; color: var(--danger); }
.bar { flex: none; display: flex; align-items: center; gap: 8px; padding: 6px 12px; font-size: 13px; background: var(--bg-1); border-bottom: 1px solid var(--line); }
.bar span { flex: 1; min-width: 0; }
.bar.bad { color: var(--danger); }
.btn.small, .bar .btn { padding: 4px 10px; font-size: 12px; flex: none; }
.status { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--speaking); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; margin-right: 4px; }
/* Meeting view: the chosen picture large, everyone in one row below */
.spot { flex: 1; min-height: 0; display: flex; flex-direction: column; background: #000; }
.spot-head { flex: none; display: flex; align-items: center; gap: 8px; padding: 2px 6px 2px 12px; background: var(--bg-0); color: var(--text-dim); font-size: 13px; }
.spot-who { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); font-weight: 600; }
.pic { position: relative; flex: 1; min-height: 0; display: flex; -webkit-touch-callout: none; user-select: none; }
.pending { position: absolute; inset: 0; margin: 0; display: flex; align-items: center; justify-content: center; text-align: center; padding: 16px;
color: #ddd; font-size: 13px; background: rgb(0 0 0 / 0.6); }
.pending.bad { color: #ffb3ad; }
.spot video { flex: 1; min-width: 0; min-height: 0; width: 100%; height: 100%; object-fit: contain; background: #000; display: block; }
.away { flex: 1; margin: 0; display: flex; align-items: center; justify-content: center; color: #aaa; font-size: 13px; }
.vol { width: 110px; flex: none; padding: 0; }
/* Video only: the picture as wide as the chat, as tall as its shape asks */
/* It gives way when the window is short, so the chat below keeps its last lines in view */
.stage.solo { flex: 0 1 auto; background: #000; }
.stage.solo .spot { flex: 0 1 auto; }
.stage.solo .pic { flex: 0 1 auto; flex-direction: column; }
.stage.solo .spot video { flex: 0 1 auto; height: auto; max-height: 62vh; }
.stage.solo .away { flex: none; padding: 24px; }
.wait { margin: 0; display: flex; align-items: center; gap: 8px; padding: 8px 12px; font-size: 13px; color: var(--text-dim); background: var(--bg-1); }
.wait span { flex: 1; min-width: 0; }
.wait.bad { color: var(--danger); }
/* One row of a fixed height; tiles share the width and scroll sideways when there are many */
.tiles.strip { flex: none; height: 92px; padding: 6px 8px; overflow-x: auto; overflow-y: hidden; }
.tiles.strip .tile { flex: 1 0 120px; max-width: 220px; }
</style> </style>
+72
View File
@@ -0,0 +1,72 @@
<script lang="ts">
import Icon from '../ui/Icon.svelte';
import ChatLog from '../ui/ChatLog.svelte';
import Composer from '../ui/Composer.svelte';
import { session } from '../lib/session.svelte.ts';
import { menu } from '../lib/menu.svelte.ts';
import { channelMenu, userMenu } from '../lib/actions.ts';
import { stripMark } from '../core/text-signal.ts';
// back: shown where the rooms are a screen of their own
let { back, onback }: { back: boolean; onback: () => void } = $props();
const connected = $derived(session.status === 'connected');
const isDm = $derived(session.view.startsWith('dm:'));
const id = $derived(Number(session.view.slice(3)));
const isText = $derived(!isDm && session.isText(id));
const mine = $derived(!isDm && session.self?.channelId === id);
const title = $derived(connected ? session.viewTitle() : 'Chat');
const topic = $derived(isDm || !connected ? '' : stripMark(session.channel(id)?.description ?? '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim());
const kind = $derived(isDm ? 'Direct message' : isText ? 'Text channel' : mine ? 'Your room' : 'Room you are not in');
function more(e: MouseEvent) {
if (isDm) {
const u = session.user(id);
if (u) menu.show(e, userMenu(u));
} else {
const c = session.channel(id);
if (c) menu.show(e, channelMenu(c));
}
}
</script>
<section class="chat2" aria-label="Chat">
<header>
{#if back}<button class="icon-btn" aria-label="Back to rooms" onclick={onback}><Icon name="back" /></button>{/if}
<div class="title">
<h1><Icon name={isDm ? 'message' : 'hash'} size={18} />{title}</h1>
{#if connected}<span title={topic}>{kind}{topic ? `. ${topic}` : ''}</span>{/if}
</div>
{#if connected}<button class="icon-btn" aria-label="More for {title}" onclick={more}><Icon name="more" /></button>{/if}
</header>
<ChatLog key={session.view}>
{#snippet empty()}
<div class="empty">
{#if connected}
<h2>{isDm ? title : `#${title}`}</h2>
<p>{isDm ? 'Messages here are only visible to the two of you.'
: isText ? 'A text channel: everyone on this server who uses mumh5 can read and write here, from any room. Other Mumble clients do not see these messages.'
: mine ? 'Messages go to everyone in this room.'
: 'You are not in this room. Your messages reach the people in it, but you only see theirs after joining.'}</p>
{:else}
<h2>No chat open</h2>
<p>Connect to a server and choose a room.</p>
{/if}
</div>
{/snippet}
</ChatLog>
{#if connected}<Composer target={session.view} placeholder={`Message ${isDm ? '@' : '#'}${title}`} />{/if}
</section>
<style>
.chat2 { height: 100%; min-height: 0; min-width: 0; display: flex; flex-direction: column; background: var(--bg-2); }
header { flex: none; min-height: 60px; display: flex; align-items: center; gap: 4px; padding: 6px 8px; border-bottom: 1px solid var(--line); }
.title { flex: 1; min-width: 0; padding-left: 8px; }
h1 { margin: 0; display: flex; align-items: center; gap: 6px; font-size: 17px; line-height: 1.3; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
h1 :global(svg) { flex: none; color: var(--text-dim); }
.title span { display: block; font-size: 13px; color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.icon-btn { flex: none; width: 40px; height: 40px; }
.empty { padding: 32px 16px 8px; }
.empty h2 { margin: 0 0 4px; font-size: 24px; letter-spacing: -0.01em; }
.empty p { margin: 0; color: var(--text-dim); }
</style>
+50
View File
@@ -0,0 +1,50 @@
<script lang="ts">
import Avatar from '../ui/Avatar.svelte';
import VoiceControls from './VoiceControls.svelte';
import { session, sessions } from '../lib/session.svelte.ts';
import { voice } from '../lib/audio/voice.svelte.ts';
import { store } from '../lib/settings.svelte.ts';
// Always in reach: the room you are in, who is talking, and the two buttons that matter
let { onopen }: { onopen: () => void } = $props();
const self = $derived(session.self);
const elsewhere = $derived(!!self && !!sessions.voiceSession && sessions.voiceSession !== sessions.active);
const room = $derived(self ? session.channel(self.channelId)?.name || 'Root' : '');
const speakers = $derived(self ? session.usersIn(self.channelId).filter(u => u.session !== self.session && voice.talking[u.session]) : []);
const line = $derived.by(() => {
if (!self) return session.status === 'connecting' ? 'Connecting...' : 'Not connected';
if (voice.error) return 'Voice unavailable';
if (elsewhere) return `Voice is on ${sessions.voiceSession?.server?.label}`;
if (speakers.length) return `${speakers.slice(0, 2).map(u => store.displayName(u)).join(', ')}${speakers.length > 2 ? ` +${speakers.length - 2}` : ''} talking`;
if (voice.settings.mode === 'ptt' && !self.selfMute) return voice.pttDown ? 'Talking' : 'Push to talk';
const n = session.usersIn(self.channelId).length;
return n === 1 ? 'Only you here' : `${n} people here`;
});
</script>
<div class="dock" class:talking={voice.transmitting}>
<button class="where" onclick={onopen} disabled={!self} title="Open the voice screen">
{#if self}<Avatar name={self.name} size={36} speaking={voice.transmitting} />{/if}
<span class="text">
<strong>{self ? room : 'mumh5'}</strong>
<span>{line}</span>
</span>
</button>
{#if elsewhere}
<button class="btn" onclick={() => sessions.setVoice(sessions.active)}>Talk here</button>
{:else}
<VoiceControls />
{/if}
</div>
<style>
.dock { flex: none; display: flex; align-items: center; gap: 8px; min-height: 60px; padding: 8px 12px; background: var(--bg-0); border-top: 1px solid var(--line); box-shadow: inset 0 2px 0 transparent; }
.dock.talking { box-shadow: inset 0 2px 0 var(--speaking); }
.where { flex: 1; min-width: 0; display: flex; align-items: center; gap: 12px; text-align: left; }
.where:disabled { opacity: 1; }
.text { min-width: 0; display: flex; flex-direction: column; line-height: 1.25; }
.text strong, .text span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.text span { font-size: 13px; color: var(--text-dim); }
.btn { flex: none; }
</style>
+91
View File
@@ -0,0 +1,91 @@
<script lang="ts">
import Icon from '../ui/Icon.svelte';
import Avatar from '../ui/Avatar.svelte';
import { session } from '../lib/session.svelte.ts';
import { store } from '../lib/settings.svelte.ts';
import { menu } from '../lib/menu.svelte.ts';
import { ui } from '../lib/ui.svelte.ts';
import { userMenu } from '../lib/actions.ts';
import { voice } from '../lib/audio/voice.svelte.ts';
// Everyone on the server, by room; yours first
let { onchat }: { onchat: () => void } = $props();
let query = $state('');
const q = $derived(query.trim().toLowerCase());
const self = $derived(session.self);
const groups = $derived.by(() => {
const byRoom = new Map<number, ReturnType<typeof session.allUsers>>();
for (const u of session.allUsers()) {
if (q && !store.displayName(u).toLowerCase().includes(q) && !u.name.toLowerCase().includes(q)) continue;
if (!byRoom.has(u.channelId)) byRoom.set(u.channelId, []);
byRoom.get(u.channelId)!.push(u);
}
return [...byRoom].map(([id, users]) => ({ id, name: session.channel(id)?.name || 'Root', users }))
.sort((a, b) => Number(b.id === self?.channelId) - Number(a.id === self?.channelId) || a.name.localeCompare(b.name));
});
const total = $derived(session.allUsers().length);
const talking = (s: number) => !!voice.talking[s] || (s === self?.session && voice.transmitting);
</script>
<section class="people" aria-label="People">
<header>
<h1>People</h1>
<span>{session.status === 'connected' ? `${total} online` : 'Not connected'}</span>
</header>
{#if session.status === 'connected'}
<label class="find">
<Icon name="search" size={16} />
<input type="search" placeholder="Find a person" aria-label="Find a person" bind:value={query} />
</label>
<div class="scroll">
{#each groups as g (g.id)}
<h2>{g.name}{#if g.id === self?.channelId} <em>your room</em>{/if} <span>{g.users.length}</span></h2>
<ul>
{#each g.users as u (u.session)}
{@const me = u.session === self?.session}
<li class="entry" role="presentation" oncontextmenu={e => menu.show(e, userMenu(u))}>
<button class="main" onclick={() => { session.showUser(u.session); ui.panelOpen = true; }}>
<Avatar name={u.name} size={40} speaking={talking(u.session)} />
<span class="text">
<span class="name" class:talking={talking(u.session)}>{store.displayName(u)}</span>
<span class="sub">{me ? 'You' : u.userId != null ? 'Registered' : 'Guest'}{u.deaf || u.selfDeaf ? ', deafened' : u.mute || u.selfMute || u.suppress ? ', muted' : ''}{session.sharing[u.session] ? `, sharing ${session.sharing[u.session]}` : ''}{u.recording ? ', recording' : ''}</span>
</span>
</button>
{#if !me}
<button class="icon-btn" aria-label="Message {store.displayName(u)}" title="Send a message" onclick={() => { session.selectView(`dm:${u.session}`); onchat(); }}><Icon name="message" size={18} /></button>
{/if}
<button class="icon-btn" aria-label="More for {store.displayName(u)}" onclick={e => menu.show(e, userMenu(u))}><Icon name="more" size={18} /></button>
</li>
{/each}
</ul>
{:else}
<p class="none">Nobody matches "{query}".</p>
{/each}
</div>
{/if}
</section>
<style>
.people { height: 100%; min-height: 0; display: flex; flex-direction: column; background: var(--bg-1); }
header { flex: none; padding: 14px 16px 8px; }
h1 { margin: 0; font-size: 24px; line-height: 1.2; letter-spacing: -0.01em; }
header span { font-size: 13px; color: var(--text-dim); }
.find { flex: none; display: flex; align-items: center; gap: 8px; margin: 4px 16px 8px; padding: 0 12px; height: 44px; background: var(--bg-0); border: 1px solid var(--line); color: var(--text-faint); text-transform: none; letter-spacing: 0; font-weight: 400; }
.find:focus-within { border-color: var(--accent); }
.find input { border: 0; background: none; padding: 0; height: 100%; font-size: 15px; }
.scroll { flex: 1; min-height: 0; overflow-y: auto; padding: 0 8px 16px; }
ul { list-style: none; margin: 0; padding: 0; }
h2 { margin: 18px 8px 6px; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-faint); }
h2 em { font-style: normal; color: var(--speaking); margin-left: 6px; }
h2 span { margin-left: 6px; font-weight: 400; }
.entry { display: flex; align-items: center; gap: 4px; min-height: 56px; padding-right: 4px; }
.entry:hover { background: var(--bg-hover); }
.main { flex: 1; min-width: 0; align-self: stretch; display: flex; align-items: center; gap: 12px; padding: 8px; text-align: left; }
.text { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.name { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.name.talking { color: var(--speaking); }
.sub { font-size: 13px; color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.icon-btn { flex: none; width: 40px; height: 40px; }
.none { padding: 12px 8px; color: var(--text-dim); }
</style>
+368
View File
@@ -0,0 +1,368 @@
<script lang="ts">
import Icon from '../ui/Icon.svelte';
import Avatar from '../ui/Avatar.svelte';
import { session, sessions } from '../lib/session.svelte.ts';
import { store, type SavedServer } from '../lib/settings.svelte.ts';
import { menu, type MenuItem } from '../lib/menu.svelte.ts';
import { ui } from '../lib/ui.svelte.ts';
import { channelMenu, userMenu } from '../lib/actions.ts';
import { voice } from '../lib/audio/voice.svelte.ts';
import { desktop } from '../lib/native.ts';
import { makeServerIcon } from '../lib/upload.ts';
import { PERM, type Channel } from '../core/client.ts';
import { stripMark } from '../core/text-signal.ts';
// The home screen: servers across the top, then every room of the server with the people
// in it, text channels and direct messages. Rooms are a list, not a tree; where a room
// sits is written under its name.
let { onadd, onedit, onbrowse, onchat }:
{ onadd: () => void; onedit: (s: SavedServer) => void; onbrowse: () => void; onchat: () => void } = $props();
const self = $derived(session.self);
const connected = $derived(session.status === 'connected');
const cert = $derived(session.serverCert);
interface Row { channel: Channel; path: string }
const all = $derived.by(() => {
const out: Row[] = [];
const walk = (parent: number | null, path: string) => {
for (const c of session.childrenOf(parent)) {
out.push({ channel: c, path });
walk(c.id, c.id === 0 ? '' : path ? `${path} / ${c.name}` : c.name);
}
};
walk(null, '');
return out;
});
let query = $state('');
const q = $derived(query.trim().toLowerCase());
const matches = (r: Row) => !q || (r.channel.name || 'Root').toLowerCase().includes(q)
|| session.usersIn(r.channel.id).some(u => store.displayName(u).toLowerCase().includes(q));
// Your own room leads the list; the others keep the server's order
const voiceRooms = $derived(all.filter(r => !session.isText(r.channel.id) && matches(r))
.sort((a, b) => Number(b.channel.id === self?.channelId) - Number(a.channel.id === self?.channelId)));
const textRooms = $derived(all.filter(r => session.isText(r.channel.id) && matches(r)));
const dms = $derived(Object.keys(session.logs).filter(k => k.startsWith('dm:'))
.map(k => ({ key: k as `dm:${number}`, user: session.user(Number(k.slice(3))) }))
.filter(d => !q || (d.user?.name ?? '').toLowerCase().includes(q)));
// A room shows its people when opened; the one you are in is open unless you close it
let opened = $state<Record<number, boolean>>({});
const isOpen = (id: number) => opened[id] ?? (id === self?.channelId || !!q);
// Last thing said in a chat, as plain text
function last(key: string): string {
const e = [...(session.logs[key] ?? [])].reverse().find(x => x.kind === 'text');
if (!e) return '';
const text = e.html.replace(/<[^>]*>/g, ' ').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/\s+/g, ' ').trim();
return `${e.self ? 'You' : e.author}: ${text || (e.embeds.length ? 'sent a file' : '')}`;
}
const about = (c: Channel) => stripMark(c.description).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
const talking = (s: number) => !!voice.talking[s] || (s === self?.session && voice.transmitting);
function openChat(key: `ch:${number}` | `dm:${number}`) {
session.selectView(key);
onchat();
}
function listMenu(e: MouseEvent) {
if (!connected) return;
const noMake = session.can(0, PERM.MakeChannel) === false && session.can(0, PERM.MakeTempChannel) === false;
menu.show(e, [{ label: 'Create channel...', disabled: noMake, action: () => (ui.channelDialog = { mode: 'create', parent: 0 }) }]);
}
function showCert() {
if (!cert?.chain.length) return;
ui.cert = {
title: `Certificate of ${session.server?.host}`, chain: cert.chain,
status: cert.authorized
? { ok: true, text: 'Valid certificate, signed by a trusted authority.' }
: { ok: false, text: `Not signed by a trusted authority (${cert.authError ?? 'unknown reason'}). mumh5 pinned this certificate the first time you connected and warns you if it changes.` }
};
}
// ─── Servers ────────────────────────────────────────────────────────────────
const initials = (label: string) => label.split(/[\s._-]+/).filter(Boolean).slice(0, 2).map(w => w[0]).join('').toUpperCase() || '?';
const stateOf = (id: string) => sessions.byServer(id)?.status ?? 'idle';
const unreadOf = (id: string) => {
const s = sessions.byServer(id);
return s && sessions.activeId !== id ? Object.values(s.unread).reduce((a, b) => a + b, 0) : 0;
};
let iconPicker: HTMLInputElement;
let iconFor = '';
async function pickIcon() {
const file = iconPicker.files?.[0];
iconPicker.value = '';
if (file && iconFor) store.setServerIcon(iconFor, await makeServerIcon(file).catch(() => undefined));
}
function serverMenu(e: MouseEvent, s: SavedServer, i: number) {
const state = stateOf(s.id);
const items: MenuItem[] = [
{ header: true, label: s.label },
state === 'idle' ? { label: 'Connect', action: () => sessions.open(s) } : { label: 'Disconnect', action: () => sessions.close(s.id) },
...(state === 'connected' && sessions.voiceId !== s.id ? [{ label: 'Talk here', action: () => { sessions.select(s.id); sessions.setVoice(sessions.byServer(s.id)!); } }] : []),
{ separator: true, label: '' },
{ label: 'Move left', disabled: i === 0, action: () => store.moveServer(s.id, i - 1) },
{ label: 'Move right', disabled: i === store.servers.length - 1, action: () => store.moveServer(s.id, i + 1) },
{ separator: true, label: '' },
{ label: 'Change icon...', action: () => { iconFor = s.id; iconPicker.click(); } },
...(s.icon ? [{ label: 'Remove icon', action: () => store.setServerIcon(s.id, undefined) }] : []),
{ label: 'Edit...', action: () => onedit(s) }
];
menu.show(e, items);
}
</script>
<section class="rooms" aria-label="Rooms">
<nav class="servers" aria-label="Servers">
{#each store.servers as s, i (s.id)}
{@const state = stateOf(s.id)}
{@const unread = unreadOf(s.id)}
<button class="srv" class:active={sessions.activeId === s.id} class:live={state === 'connected'} class:connecting={state === 'connecting'}
class:voice={sessions.voiceId === s.id && state === 'connected'}
title="{s.label} ({s.host}){state === 'connected' ? (sessions.voiceId === s.id ? ', voice' : ', connected') : ''}"
onclick={() => sessions.open(s)} oncontextmenu={e => serverMenu(e, s, i)}>
<span class="pic">{#if s.icon}<img src={s.icon} alt="" draggable="false" />{:else}{initials(s.label)}{/if}</span>
<span class="cap">{s.label}</span>
{#if unread}<span class="count">{unread > 99 ? '99+' : unread}</span>{/if}
</button>
{/each}
<input bind:this={iconPicker} type="file" accept="image/*" hidden onchange={pickIcon} />
<button class="srv add" title="Add a server" onclick={onadd}><span class="pic"><Icon name="plus" /></span><span class="cap">Add</span></button>
<!-- The public list and its pings need the desktop app -->
{#if desktop}<button class="srv add" title="Browse public servers" onclick={onbrowse}><span class="pic"><Icon name="globe" /></span><span class="cap">Browse</span></button>{/if}
</nav>
<header class="top">
<button class="place" onclick={showCert} disabled={!cert} title={cert ? (cert.authorized ? 'Valid certificate. Click to view' : 'Self-signed or untrusted certificate. Click to view') : ''}>
<h1>{session.server?.label ?? 'mumh5'}</h1>
<span class="status">
{#if connected}<span class="dot"></span>Connected{#if session.rtt}, {session.rtt} ms{/if}{#if cert}<Icon name={cert.authorized ? 'lock' : 'shield'} size={12} />{/if}
{:else if session.status === 'connecting'}Connecting...
{:else}Not connected{/if}
</span>
</button>
{#if connected}<button class="icon-btn" title="Server information" aria-label="Server information" onclick={() => (ui.serverInfo = true)}><Icon name="info" size={18} /></button>{/if}
{#if session.status !== 'idle'}
<button class="icon-btn" title="Disconnect" aria-label="Disconnect" onclick={() => session.server && sessions.close(session.server.id)}><Icon name="power" size={18} /></button>
{/if}
</header>
{#if connected}
<label class="find">
<Icon name="search" size={16} />
<input type="search" placeholder="Find a room or a person" aria-label="Find a room or a person" bind:value={query} />
</label>
{/if}
<div class="scroll" role="presentation" oncontextmenu={listMenu}>
{#if connected}
{#if textRooms.length}
<h2>Text channels</h2>
<ul>
{#each textRooms as r (r.channel.id)}
{@const c = r.channel}
{@const key = `ch:${c.id}` as const}
<li class="entry" class:viewing={session.view === key}>
<button class="main" onclick={() => openChat(key)} oncontextmenu={e => menu.show(e, channelMenu(c))}>
<span class="glyph"><Icon name="hash" size={18} /></span>
<span class="text">
<span class="name">{c.name}</span>
<span class="sub">{last(key) || r.path || 'No messages yet'}</span>
</span>
{#if session.unread[key]}<span class="badge">{session.unread[key]}</span>{/if}
</button>
<button class="icon-btn" aria-label="More for {c.name}" onclick={e => menu.show(e, channelMenu(c))}><Icon name="more" size={18} /></button>
</li>
{/each}
</ul>
{/if}
<h2>Rooms</h2>
<ul>
{#each voiceRooms as r (r.channel.id)}
{@const c = r.channel}
{@const users = session.usersIn(c.id)}
{@const here = self?.channelId === c.id}
{@const open = isOpen(c.id)}
{@const key = `ch:${c.id}` as const}
<li class="room" class:here class:open class:empty={!users.length}>
<div class="entry" role="presentation" oncontextmenu={e => menu.show(e, channelMenu(c))}>
<button class="main" aria-expanded={open} onclick={() => (opened[c.id] = !open)}>
<span class="text">
<span class="name">{#if !c.canEnter}<Icon name="lock" size={14} />{/if}{c.name || 'Root'}</span>
{#if r.path}<span class="sub">in {r.path}</span>{/if}
</span>
{#if users.length && !open}
<span class="faces" aria-label="{users.length} {users.length === 1 ? 'person' : 'people'}">
{#each users.slice(0, 4) as u (u.session)}<Avatar name={u.name} size={26} speaking={talking(u.session)} />{/each}
{#if users.length > 4}<span class="extra">+{users.length - 4}</span>{/if}
</span>
{/if}
{#if session.unread[key]}<span class="badge">{session.unread[key]}</span>{/if}
</button>
{#if here}
<span class="tag">You are here</span>
{:else}
<button class="join" disabled={!c.canEnter} aria-label="Join {c.name || 'Root'}" onclick={() => session.joinChannel(c.id)}>{c.canEnter ? 'Join' : 'Locked'}</button>
{/if}
<button class="icon-btn" aria-label="More for {c.name || 'Root'}" onclick={e => menu.show(e, channelMenu(c))}><Icon name="more" size={18} /></button>
</div>
{#if open}
<div class="inside">
{#if about(c)}<p class="about">{about(c)}</p>{/if}
{#if users.length}
<ul class="people">
{#each users as u (u.session)}
{@const me = u.session === self?.session}
<li>
<button class="person" onclick={() => { session.showUser(u.session); ui.panelOpen = true; }} oncontextmenu={e => menu.show(e, userMenu(u))}>
<Avatar name={u.name} size={32} speaking={talking(u.session)} />
<span class="pname" class:talking={talking(u.session)}>{store.displayName(u)}{#if me} <em>you</em>{/if}</span>
{#if session.sharing[u.session]}<span class="mark live" title="Sharing their {session.sharing[u.session]}"><Icon name={session.sharing[u.session]} size={14} /></span>{/if}
{#if u.recording}<span class="rec">REC</span>{/if}
{#if u.deaf || u.selfDeaf}<span class="mark" class:bad={u.deaf} title={u.deaf ? 'Deafened by server' : 'Deafened'}><Icon name="headphones-off" size={14} /></span>
{:else if u.mute || u.selfMute || u.suppress}<span class="mark" class:bad={u.mute || u.suppress} title={u.mute ? 'Muted by server' : u.suppress ? 'Suppressed' : 'Muted'}><Icon name="mic-off" size={14} /></span>{/if}
</button>
</li>
{/each}
</ul>
{:else}
<p class="nobody">Nobody here.</p>
{/if}
<div class="acts">
<button class="btn" onclick={() => openChat(key)}>Chat</button>
<button class="btn" onclick={() => { session.showChannel(c.id, 'info'); ui.panelOpen = true; }}>Details</button>
</div>
</div>
{/if}
</li>
{:else}
<li class="none">Nothing matches "{query}".</li>
{/each}
</ul>
{#if dms.length}
<h2>Direct messages</h2>
<ul>
{#each dms as dm (dm.key)}
<li class="entry" class:viewing={session.view === dm.key}>
<button class="main" onclick={() => openChat(dm.key)}>
<Avatar name={dm.user?.name ?? '?'} size={32} />
<span class="text">
<span class="name" class:gone={!dm.user}>{dm.user ? store.displayName(dm.user) : 'Offline user'}</span>
<span class="sub">{last(dm.key)}</span>
</span>
{#if session.unread[dm.key]}<span class="badge">{session.unread[dm.key]}</span>{/if}
</button>
</li>
{/each}
</ul>
{/if}
{:else if session.status === 'idle' && session.disconnectInfo}
{@const d = session.disconnectInfo}
<div class="ended" class:bad={d.kind === 'kicked' || d.kind === 'banned'}>
<h2>
{#if d.kind === 'kicked'}You were kicked{d.by ? ` by ${d.by}` : ''}
{:else if d.kind === 'banned'}You were banned{d.by ? ` by ${d.by}` : ''}
{:else if d.kind === 'rejected'}The server refused the connection
{:else}Connection lost{/if}
</h2>
{#if d.reason}<p class="reason">{d.reason}</p>{/if}
<button class="btn primary" onclick={() => session.reconnect()}>Reconnect</button>
</div>
{:else if session.status === 'connecting'}
<p class="blank">Connecting...</p>
{:else}
<div class="blank">
{#if session.error}<p class="error">{session.error}</p>{/if}
<p>Choose a server above, or add one.</p>
<div class="acts">
<button class="btn primary" onclick={onadd}>Add a server</button>
{#if desktop}<button class="btn" onclick={onbrowse}>Browse public servers</button>{/if}
<button class="btn" onclick={() => { ui.settingsTab = 'identities'; ui.settingsOpen = true; }}>Manage identities</button>
</div>
</div>
{/if}
</div>
</section>
<style>
.rooms { height: 100%; min-height: 0; display: flex; flex-direction: column; background: var(--bg-1); }
ul { list-style: none; margin: 0; padding: 0; }
.servers { flex: none; display: flex; gap: 4px; padding: 10px 12px 6px; overflow-x: auto; scrollbar-width: none; }
.servers::-webkit-scrollbar { display: none; }
.srv { position: relative; flex: none; width: 64px; display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 4px 0; color: var(--text-dim); }
.pic { position: relative; width: 48px; height: 48px; display: flex; align-items: center; justify-content: center; background: var(--bg-3); font-weight: 700; color: var(--text); }
.pic img { width: 100%; height: 100%; object-fit: cover; display: block; }
.cap { max-width: 100%; font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.srv.active .pic { outline: 2px solid var(--accent); outline-offset: 2px; }
.srv.active .cap { color: var(--text); }
.srv.live .pic::after, .srv.connecting .pic::after { content: ''; position: absolute; right: -3px; bottom: -3px; width: 10px; height: 10px; background: var(--speaking); border: 2px solid var(--bg-1); }
.srv.connecting .pic::after { background: var(--text-faint); }
.srv.voice .pic::after { box-shadow: 0 0 0 2px var(--speaking); }
.srv.add .pic { background: transparent; border: 1px dashed var(--line); color: var(--text-dim); }
.srv.add:hover .pic { color: var(--text); border-color: var(--text-dim); }
.count { position: absolute; top: 0; right: 4px; min-width: 18px; padding: 0 4px; line-height: 16px; font-size: 10px; font-weight: 700; background: var(--danger); color: #fff; }
.top { flex: none; display: flex; align-items: center; gap: 4px; padding: 6px 8px 6px 0; }
.place { flex: 1; min-width: 0; padding: 4px 16px; text-align: left; }
.place:disabled { opacity: 1; }
h1 { margin: 0; font-size: 24px; line-height: 1.2; letter-spacing: -0.01em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.status { display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-dim); }
.dot { width: 8px; height: 8px; background: var(--speaking); }
.find { flex: none; display: flex; align-items: center; gap: 8px; margin: 4px 16px 8px; padding: 0 12px; height: 44px; background: var(--bg-0); border: 1px solid var(--line); color: var(--text-faint); text-transform: none; letter-spacing: 0; font-weight: 400; }
.find:focus-within { border-color: var(--accent); }
.find input { border: 0; background: none; padding: 0; height: 100%; font-size: 15px; }
.scroll { flex: 1; min-height: 0; overflow-y: auto; padding: 0 8px 16px; }
h2 { margin: 18px 8px 6px; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-faint); }
.entry { display: flex; align-items: center; gap: 4px; min-height: 56px; padding-right: 4px; }
.entry:hover, .entry.viewing { background: var(--bg-hover); }
.main { flex: 1; min-width: 0; align-self: stretch; display: flex; align-items: center; gap: 12px; padding: 8px; text-align: left; }
.glyph { flex: none; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; background: var(--bg-3); color: var(--text-dim); }
.text { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.name { display: flex; align-items: center; gap: 6px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.name.gone { color: var(--text-faint); font-style: italic; }
.sub { font-size: 13px; color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.badge { flex: none; min-width: 20px; padding: 0 6px; line-height: 20px; text-align: center; font-size: 12px; font-weight: 700; background: var(--danger); color: #fff; }
.room { margin-bottom: 2px; border-left: 3px solid transparent; }
.room.empty .name { color: var(--text-dim); font-weight: 500; }
.room.here { border-left-color: var(--speaking); background: var(--bg-2); }
.room.here .name { color: var(--text); font-weight: 700; }
.faces { flex: none; display: flex; align-items: center; gap: 4px; }
.extra { font-size: 12px; color: var(--text-dim); padding-left: 2px; }
.join { flex: none; height: 36px; padding: 0 14px; font-weight: 600; font-size: 14px; border: 1px solid var(--line); color: var(--text); }
.join:hover:not(:disabled) { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
.tag { flex: none; padding: 0 6px; font-size: 12px; font-weight: 600; color: var(--speaking); }
.icon-btn { flex: none; width: 40px; height: 40px; }
.inside { padding: 0 8px 12px 8px; }
.about { margin: 0 8px 8px; font-size: 14px; color: var(--text-dim); display: -webkit-box; -webkit-line-clamp: 3; line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.nobody { margin: 0 8px 8px; font-size: 14px; color: var(--text-faint); }
.person { width: 100%; min-height: 44px; display: flex; align-items: center; gap: 12px; padding: 4px 8px; text-align: left; }
.person:hover { background: var(--bg-hover); }
.pname { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.pname.talking { color: var(--speaking); }
.pname em { margin-left: 6px; font-style: normal; font-size: 12px; color: var(--text-faint); }
.mark { flex: none; display: inline-flex; color: var(--text-faint); }
.mark.bad { color: var(--danger); }
.mark.live { color: var(--speaking); }
.rec { flex: none; font-size: 10px; font-weight: 700; color: var(--danger); border: 1px solid var(--danger); padding: 0 3px; }
.acts { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 8px 0; }
.none { padding: 12px 8px; color: var(--text-dim); }
.blank { padding: 24px 16px; color: var(--text-dim); }
.blank p { margin: 0 0 4px; }
.blank .acts { margin: 14px 0 0; }
.error { color: var(--danger); }
.ended { margin: 16px 8px; padding: 16px; background: var(--bg-2); border: 1px solid var(--line); border-left: 4px solid var(--warn); }
.ended.bad { border-left-color: var(--danger); }
.ended h2 { margin: 0 0 6px; font-size: 18px; text-transform: none; letter-spacing: 0; color: var(--text); }
.ended .reason { margin: 0 0 12px; overflow-wrap: anywhere; }
</style>
+125
View File
@@ -0,0 +1,125 @@
<script lang="ts">
import Icon from '../ui/Icon.svelte';
import RightPanel from '../ui/RightPanel.svelte';
import Rooms from './Rooms.svelte';
import ChatScreen from './ChatScreen.svelte';
import VoiceScreen from './VoiceScreen.svelte';
import People from './People.svelte';
import You from './You.svelte';
import Dock from './Dock.svelte';
import { session } from '../lib/session.svelte.ts';
import { ui } from '../lib/ui.svelte.ts';
import type { SavedServer } from '../lib/settings.svelte.ts';
// The Rooms interface: one screen at a time with a bar to move between them, made for
// phones first. Wide windows keep the rooms on the left next to the chosen screen. Same
// state, dialogs and menus as the standard interface; only the arrangement differs.
let { onadd, onedit, onbrowse }: { onadd: () => void; onedit: (s: SavedServer) => void; onbrowse: () => void } = $props();
type Screen = 'rooms' | 'chat' | 'voice' | 'people' | 'you';
let width = $state(window.innerWidth);
const wide = $derived(width >= 900);
let chosen = $state<Screen>('rooms');
// With the rooms always on the left, their place in the main area goes to the chat
const screen = $derived(wide && chosen === 'rooms' ? 'chat' : chosen);
const connected = $derived(session.status === 'connected');
const unread = $derived(Object.values(session.unread).reduce((a, b) => a + b, 0));
const inVoice = $derived(!!session.self);
const ITEMS: { id: Screen; name: string; icon: string }[] = [
{ id: 'rooms', name: 'Rooms', icon: 'rooms' },
{ id: 'chat', name: 'Chat', icon: 'message' },
{ id: 'voice', name: 'Voice', icon: 'channel' },
{ id: 'people', name: 'People', icon: 'users' },
{ id: 'you', name: 'You', icon: 'user' }
];
const items = $derived(wide ? ITEMS.filter(i => i.id !== 'rooms') : ITEMS);
// A direct message or a text channel opened from a menu shows its chat. Joining a room
// also changes the chat, but leaves you where you are.
let lastView = session.view;
$effect(() => {
const view = session.view;
if (view === lastView) return;
lastView = view;
if (view.startsWith('dm:') || session.isText(Number(view.slice(3)))) chosen = 'chat';
});
// Profiles and channel details open over the screen
const sheet = $derived(ui.panelOpen && connected && session.panel.kind !== 'members');
function closeSheet() {
ui.panelOpen = false;
session.closePanel();
}
</script>
<svelte:window bind:innerWidth={width} onkeydown={e => { if (e.key === 'Escape' && sheet) closeSheet(); }} />
<div class="shell" class:wide>
<nav class="bar" aria-label="Screens">
{#each items as item (item.id)}
<button class="item" class:active={screen === item.id} aria-current={screen === item.id ? 'page' : undefined} onclick={() => (chosen = item.id)}>
<span class="glyph">
<Icon name={item.icon} size={22} />
{#if item.id === 'chat' && unread}<span class="count">{unread > 99 ? '99+' : unread}</span>{/if}
{#if item.id === 'voice' && inVoice}<span class="on"></span>{/if}
</span>
<span class="cap">{item.name}</span>
</button>
{/each}
</nav>
{#if wide}
<div class="side">
<Rooms {onadd} {onedit} {onbrowse} onchat={() => (chosen = 'chat')} />
<Dock onopen={() => (chosen = 'voice')} />
</div>
{/if}
<main>
<div class="screen">
{#if screen === 'rooms'}<Rooms {onadd} {onedit} {onbrowse} onchat={() => (chosen = 'chat')} />
{:else if screen === 'chat'}<ChatScreen back={!wide} onback={() => (chosen = 'rooms')} />
{:else if screen === 'voice'}<VoiceScreen onrooms={() => (chosen = 'rooms')} onchat={() => (chosen = 'chat')} />
{:else if screen === 'people'}<People onchat={() => (chosen = 'chat')} />
{:else}<You {onadd} {onedit} {onbrowse} />{/if}
</div>
{#if !wide && screen !== 'voice'}<Dock onopen={() => (chosen = 'voice')} />{/if}
</main>
{#if sheet}
<button class="scrim" aria-label="Close" onclick={closeSheet}></button>
<div class="sheet"><RightPanel onnavigate={() => { closeSheet(); chosen = 'chat'; }} onclose={closeSheet} /></div>
{/if}
</div>
<style>
.shell { position: relative; height: 100%; display: flex; flex-direction: column; overflow: hidden; background: var(--bg-1); }
main { flex: 1; min-height: 0; min-width: 0; display: flex; flex-direction: column; }
.screen { flex: 1; min-height: 0; }
/* Phones: the bar is at the bottom, under the thumb */
.bar { order: 2; flex: none; display: flex; background: var(--bg-0); border-top: 1px solid var(--line); padding-bottom: env(safe-area-inset-bottom); }
.item { flex: 1; min-width: 0; height: 56px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; color: var(--text-faint); box-shadow: inset 0 2px 0 transparent; }
.item:hover { color: var(--text); }
.item.active { color: var(--text); box-shadow: inset 0 2px 0 var(--accent); }
.glyph { position: relative; display: flex; }
.cap { font-size: 11px; font-weight: 600; }
.count { position: absolute; top: -6px; left: 14px; min-width: 18px; padding: 0 4px; line-height: 16px; font-size: 10px; font-weight: 700; text-align: center; background: var(--danger); color: #fff; }
.on { position: absolute; top: -2px; right: -4px; width: 8px; height: 8px; background: var(--speaking); }
/* Wide windows: the bar on the left edge, the rooms next to it, the screen takes the rest */
.shell.wide { flex-direction: row; }
.wide .bar { order: 0; flex-direction: column; width: 76px; border-top: 0; border-right: 1px solid var(--line); padding: 8px 0; }
.wide .item { flex: none; height: 64px; box-shadow: inset 2px 0 0 transparent; }
.wide .item.active { box-shadow: inset 2px 0 0 var(--accent); }
.side { flex: none; width: 360px; min-height: 0; display: flex; flex-direction: column; border-right: 1px solid var(--line); }
.side > :global(.rooms) { flex: 1; }
.scrim { position: absolute; inset: 0; z-index: 15; background: rgb(0 0 0 / 0.55); }
.sheet { position: absolute; z-index: 20; left: 0; right: 0; bottom: 0; height: min(86%, 720px); display: flex; box-shadow: 0 -8px 32px rgb(0 0 0 / 0.4); border-top: 1px solid var(--line); }
.sheet :global(.panel) { width: 100% !important; border-left: 0; }
.sheet :global(.panel > .resize) { display: none; }
.wide .sheet { left: auto; top: 0; height: auto; width: 400px; border-top: 0; box-shadow: -8px 0 32px rgb(0 0 0 / 0.4); }
</style>
+53
View File
@@ -0,0 +1,53 @@
<script lang="ts">
import Icon from '../ui/Icon.svelte';
import { session, sessions } from '../lib/session.svelte.ts';
import { ui } from '../lib/ui.svelte.ts';
import { share } from '../lib/share.svelte.ts';
// Mute, deafen and share. large: the voice screen's row of big buttons with labels.
let { large = false }: { large?: boolean } = $props();
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 sharing = $derived(!!share.stream);
const canShare = $derived(session.status === 'connected' && share.supported(sessions.active));
function quick(e: MouseEvent, kind: 'input' | 'output') {
e.preventDefault();
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
ui.quickAudio = { kind, x: r.left + r.width / 2, y: r.top };
}
</script>
<div class="controls" class:large>
<button class:on={muted} aria-label={muted ? 'Unmute' : 'Mute'} title={muted ? 'Unmute (hold or right-click for microphone options)' : 'Mute (hold or right-click for microphone options)'}
onclick={() => session.setSelfMute(!muted)} oncontextmenu={e => quick(e, 'input')}>
<Icon name={muted ? 'mic-off' : 'mic'} size={large ? 24 : 20} />
{#if large}<span>{muted ? 'Muted' : 'Mute'}</span>{/if}
</button>
<button class:on={deafened} aria-label={deafened ? 'Undeafen' : 'Deafen'} title={deafened ? 'Undeafen (hold or right-click for output options)' : 'Deafen (hold or right-click for output options)'}
onclick={() => session.setSelfDeaf(!deafened)} oncontextmenu={e => quick(e, 'output')}>
<Icon name={deafened ? 'headphones-off' : 'headphones'} size={large ? 24 : 20} />
{#if large}<span>{deafened ? 'Deafened' : 'Deafen'}</span>{/if}
</button>
{#if large}
<button class:live={sharing} disabled={!sharing && !canShare} aria-label={sharing ? `Stop sharing your ${share.kind}` : 'Share your screen or camera'}
title={sharing ? `Stop sharing your ${share.kind}` : 'Share your screen or camera with your channel'}
onclick={() => (sharing ? share.stop() : share.start(sessions.active))}>
<Icon name={sharing ? share.kind : 'screen'} size={24} />
<span>{sharing ? 'Stop sharing' : 'Share'}</span>
</button>
{/if}
</div>
<style>
.controls { display: flex; gap: 4px; }
button { width: 44px; height: 44px; display: flex; align-items: center; justify-content: center; color: var(--text); background: var(--bg-3); }
button:hover:not(:disabled) { background: var(--bg-active); }
button.on { background: var(--danger); color: #fff; }
button.live { background: var(--speaking); color: var(--bg-0); }
.large { gap: 10px; justify-content: center; }
.large button { width: 96px; height: 72px; flex-direction: column; gap: 6px; font-size: 13px; font-weight: 600; }
</style>
+54
View File
@@ -0,0 +1,54 @@
<script lang="ts">
import VoiceStage from '../ui/VoiceStage.svelte';
import VoiceControls from './VoiceControls.svelte';
import { session, sessions } from '../lib/session.svelte.ts';
import { voice } from '../lib/audio/voice.svelte.ts';
// The room you are in as a call: everyone as tiles, streams large, the controls underneath
let { onrooms, onchat }: { onrooms: () => void; onchat: () => void } = $props();
const self = $derived(session.self);
const room = $derived(self ? session.channel(self.channelId) : undefined);
const elsewhere = $derived(!!self && !!sessions.voiceSession && sessions.voiceSession !== sessions.active);
const transport = $derived(session.voiceTransport);
</script>
<section class="voice" aria-label="Voice">
{#if self}
<header>
<div class="title">
<h1>{room?.name || 'Root'}</h1>
<span>{session.usersIn(self.channelId).length} here, voice over {transport.udp ? 'UDP' : 'TCP'}{#if voice.error}, voice unavailable{/if}</span>
</div>
<button class="btn" onclick={() => { session.selectView(`ch:${self.channelId}`); onchat(); }}>Chat</button>
</header>
{#if voice.error}<p class="note bad">{voice.error}</p>{/if}
{#if elsewhere}
<p class="note">Voice is on {sessions.voiceSession?.server?.label}; you are deafened here.
<button class="btn" onclick={() => sessions.setVoice(sessions.active)}>Talk here</button></p>
{/if}
<div class="stage"><VoiceStage always fill /></div>
<footer><VoiceControls large /></footer>
{:else}
<div class="blank">
<h1>Voice</h1>
<p>Connect to a server and join a room to talk.</p>
<button class="btn primary" onclick={onrooms}>Go to rooms</button>
</div>
{/if}
</section>
<style>
.voice { height: 100%; min-height: 0; display: flex; flex-direction: column; background: var(--bg-2); }
header { flex: none; display: flex; align-items: center; gap: 12px; padding: 14px 16px 10px; }
.title { flex: 1; min-width: 0; }
h1 { margin: 0; font-size: 24px; line-height: 1.2; letter-spacing: -0.01em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.title span { font-size: 13px; color: var(--text-dim); }
.note { flex: none; display: flex; align-items: center; gap: 12px; margin: 0 16px 8px; padding: 8px 12px; font-size: 14px; color: var(--text-dim); background: var(--bg-0); border: 1px solid var(--line); }
.note.bad { color: var(--danger); }
.stage { flex: 1; min-height: 0; display: flex; flex-direction: column; }
footer { flex: none; padding: 12px 16px 16px; border-top: 1px solid var(--line); background: var(--bg-1); }
.blank { padding: 32px 16px; color: var(--text-dim); }
.blank h1 { color: var(--text); }
.blank p { margin: 6px 0 14px; }
</style>
+117
View File
@@ -0,0 +1,117 @@
<script lang="ts">
import Icon from '../ui/Icon.svelte';
import Avatar from '../ui/Avatar.svelte';
import { session, sessions } from '../lib/session.svelte.ts';
import { store, type SavedServer } from '../lib/settings.svelte.ts';
import { ui } from '../lib/ui.svelte.ts';
import { changeNamePrompt } from '../lib/actions.ts';
import { voice } from '../lib/audio/voice.svelte.ts';
import { desktop } from '../lib/native.ts';
// Yourself, your servers and the settings, in one place
let { onadd, onedit, onbrowse }: { onadd: () => void; onedit: (s: SavedServer) => void; onbrowse: () => void } = $props();
const self = $derived(session.self);
const stateOf = (id: string) => sessions.byServer(id)?.status ?? 'idle';
type Tab = typeof ui.settingsTab;
const SETTINGS: { tab: Tab; name: string; note: string }[] = [
{ tab: 'voice', name: 'Voice', note: 'Microphone, speakers, how you transmit' },
{ tab: 'sounds', name: 'Sounds', note: 'Notification sounds' },
{ tab: 'appearance', name: 'Appearance', note: 'Colors and interface' },
{ tab: 'accessibility', name: 'Accessibility', note: '' },
{ tab: 'files', name: 'Chat and files', note: 'Uploads, previews, embeds' },
{ tab: 'identities', name: 'Identities', note: 'Your certificates' }
];
const openSettings = (tab: Tab) => { ui.settingsTab = tab; ui.settingsOpen = true; };
function profile(edit: boolean) {
if (!self) return;
session.showUser(self.session);
if (edit) ui.editDescription = true;
ui.panelOpen = true;
}
</script>
<section class="you" aria-label="You">
<div class="scroll">
<div class="card">
{#if self}
<Avatar name={self.name} size={64} speaking={voice.transmitting} />
<div class="who">
<h1>{self.name}</h1>
<span>{self.userId != null ? 'Registered' : 'Guest'} on {session.server?.label}, in {session.channel(self.channelId)?.name || 'Root'}</span>
</div>
{:else}
<div class="who">
<h1>You</h1>
<span>{session.status === 'connecting' ? 'Connecting...' : 'Not connected'}</span>
</div>
{/if}
</div>
{#if self}
<div class="acts">
<button class="btn" onclick={() => profile(false)}>Your profile</button>
<button class="btn" onclick={() => profile(true)}>Change your description</button>
<button class="btn" onclick={() => changeNamePrompt(self)}>Change name</button>
{#if self.userId == null}<button class="btn" onclick={() => session.register(self.session)}>Register on this server</button>{/if}
<button class="btn" onclick={() => (ui.userInfo = self.session)}>Connection information</button>
</div>
{/if}
<h2>Servers</h2>
<ul>
{#each store.servers as s (s.id)}
{@const state = stateOf(s.id)}
<li class="entry">
<button class="main" onclick={() => sessions.open(s)}>
<span class="text">
<span class="name">{s.label}{#if state !== 'idle'}<em>{state === 'connecting' ? 'connecting' : sessions.voiceId === s.id ? 'connected, voice' : 'connected'}</em>{/if}</span>
<span class="sub">{s.host}:{s.port} as {s.username}</span>
</span>
</button>
{#if state !== 'idle'}<button class="btn small" onclick={() => sessions.close(s.id)}>Disconnect</button>{/if}
<button class="btn small" onclick={() => onedit(s)}>Edit</button>
</li>
{/each}
</ul>
<div class="acts">
<button class="btn" onclick={onadd}>Add a server</button>
{#if desktop}<button class="btn" onclick={onbrowse}>Browse public servers</button>{/if}
{#if session.status === 'connected'}<button class="btn" onclick={() => (ui.serverInfo = true)}>Server information</button>{/if}
</div>
<h2>Settings</h2>
<ul>
{#each SETTINGS as item (item.tab)}
<li class="entry">
<button class="main" onclick={() => openSettings(item.tab)}>
<span class="text">
<span class="name">{item.name}</span>
{#if item.note}<span class="sub">{item.note}</span>{/if}
</span>
<Icon name="arrow-right" size={16} />
</button>
</li>
{/each}
</ul>
</div>
</section>
<style>
.you { height: 100%; min-height: 0; display: flex; flex-direction: column; background: var(--bg-1); }
.scroll { flex: 1; min-height: 0; overflow-y: auto; padding: 0 8px 24px; }
.card { display: flex; align-items: center; gap: 16px; padding: 20px 8px 12px; }
.who { min-width: 0; }
h1 { margin: 0; font-size: 24px; line-height: 1.2; letter-spacing: -0.01em; overflow-wrap: anywhere; }
.who span { font-size: 13px; color: var(--text-dim); }
.acts { display: flex; flex-wrap: wrap; gap: 8px; padding: 4px 8px; }
ul { list-style: none; margin: 0; padding: 0; }
h2 { margin: 24px 8px 6px; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-faint); }
.entry { display: flex; align-items: center; gap: 8px; min-height: 56px; padding-right: 8px; }
.entry:hover { background: var(--bg-hover); }
.main { flex: 1; min-width: 0; align-self: stretch; display: flex; align-items: center; gap: 12px; padding: 8px; text-align: left; color: var(--text-dim); }
.text { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.name { font-weight: 600; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.name em { margin-left: 8px; font-style: normal; font-size: 12px; font-weight: 600; color: var(--speaking); }
.sub { font-size: 13px; color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.small { flex: none; padding: 6px 12px; font-size: 13px; }
</style>
+47
View File
@@ -0,0 +1,47 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { planMove, byOrder, type Ordered } from '../src/core/channel-order.ts';
const ch = (id: number, name: string, position = 0): Ordered => ({ id, name, position });
// The order everyone sees once the plan is applied
function after(siblings: Ordered[], moved: Ordered, index: number): string[] {
const plan = planMove(siblings, moved, index);
const all = [...siblings.filter(c => c.id !== moved.id), moved].map(c => ({ ...c, position: plan.find(u => u.id === c.id)?.position ?? c.position }));
return all.sort(byOrder).map(c => c.name);
}
test('a spot that the name already gives needs no change', () => {
const list = [ch(1, 'Alpha'), ch(3, 'Gamma')];
assert.deepEqual(planMove(list, ch(2, 'Beta'), 1), []);
});
test('one change when there is room between the neighbours', () => {
const list = [ch(1, 'Alpha', 0), ch(2, 'Beta', 5), ch(3, 'Gamma', 9)];
const plan = planMove(list, ch(3, 'Gamma', 9), 1);
assert.equal(plan.length, 1);
assert.deepEqual(after(list, ch(3, 'Gamma', 9), 1), ['Alpha', 'Gamma', 'Beta']);
});
test('to the front and to the end', () => {
const list = [ch(1, 'Alpha'), ch(2, 'Beta'), ch(3, 'Gamma')];
assert.deepEqual(planMove(list, list[2], 0), [{ id: 3, position: -1 }]);
assert.deepEqual(planMove(list, list[0], 2), [{ id: 1, position: 1 }]);
});
test('channels that all share a position are numbered again when the name does not fit', () => {
const list = [ch(1, 'Alpha'), ch(2, 'Beta'), ch(3, 'Gamma'), ch(4, 'Delta')];
// Sorted: Alpha, Beta, Delta, Gamma. Gamma between Alpha and Beta.
assert.deepEqual(after(list, list[2], 1), ['Alpha', 'Gamma', 'Beta', 'Delta']);
assert.ok(planMove(list, list[2], 1).length > 1);
});
test('every spot works, for a channel of the row and for one coming from elsewhere', () => {
const list = [ch(1, 'b', 0), ch(2, 'a', 0), ch(3, 'c', 1), ch(4, 'd', 1), ch(5, 'e', 7)];
const sorted = [...list].sort(byOrder);
for (const moved of [...list, ch(9, 'new', 1)]) {
const rest = sorted.filter(c => c.id !== moved.id).map(c => c.name);
for (let i = 0; i <= rest.length; i++) {
assert.deepEqual(after(list, moved, i), [...rest.slice(0, i), moved.name, ...rest.slice(i)], `${moved.name} to ${i}`);
}
}
});
+419 -9
View File
@@ -14,6 +14,8 @@ import { MumbleClient } from '../../src/core/client.ts';
import type { Transport } from '../../src/core/transport.ts'; import type { Transport } from '../../src/core/transport.ts';
import { openTls } from '../../electron/tls-transport.ts'; import { openTls } from '../../electron/tls-transport.ts';
import { generateIdentity } from '../../electron/identity.ts'; import { generateIdentity } from '../../electron/identity.ts';
import { byOrder } from '../../src/core/channel-order.ts';
import { TEXT, TEXT_DATA_ID, TEXT_MARK, TextAssembler, encodeText, parseMsg, parseHistory } from '../../src/core/text-signal.ts';
const target = process.env.MUMBLE_TEST_HOST; const target = process.env.MUMBLE_TEST_HOST;
if (!target) { if (!target) {
@@ -70,6 +72,8 @@ const app = await electron.launch({
const bob = await headless('bob'); const bob = await headless('bob');
// SuperUser can create channels; needs MUMBLE_SUPERUSER_PASSWORD on the test server // SuperUser can create channels; needs MUMBLE_SUPERUSER_PASSWORD on the test server
const admin = process.env.MUMBLE_SUPERUSER_PASSWORD ? await headless('SuperUser', process.env.MUMBLE_SUPERUSER_PASSWORD) : null; const admin = process.env.MUMBLE_SUPERUSER_PASSWORD ? await headless('SuperUser', process.env.MUMBLE_SUPERUSER_PASSWORD) : null;
// The text channel made further down; checked again after the app reconnects
let notesId: number | null = null;
try { try {
const page = await app.firstWindow(); const page = await app.firstWindow();
page.on('console', m => { if (m.type() === 'error') console.log('[renderer]', m.text()); }); page.on('console', m => { if (m.type() === 'error') console.log('[renderer]', m.text()); });
@@ -78,7 +82,8 @@ try {
// First start: identity setup wizard // First start: identity setup wizard
await page.getByRole('dialog', { name: 'Set up your identity' }).waitFor(); await page.getByRole('dialog', { name: 'Set up your identity' }).waitFor();
await page.getByRole('button', { name: /Create a new identity/ }).click(); await page.getByRole('button', { name: /Create a new identity/ }).click();
await page.getByLabel('Name').fill('alice'); await page.getByLabel('Name', { exact: true }).fill('alice');
assert.ok(await page.getByLabel('Use this name as my username').isChecked());
await page.getByRole('button', { name: 'Create', exact: true }).click(); await page.getByRole('button', { name: 'Create', exact: true }).click();
await page.getByRole('button', { name: 'Skip for now' }).click(); await page.getByRole('button', { name: 'Skip for now' }).click();
await page.getByRole('button', { name: 'Done' }).click(); await page.getByRole('button', { name: 'Done' }).click();
@@ -92,6 +97,18 @@ try {
await page.getByRole('button', { name: 'Done', exact: true }).click(); await page.getByRole('button', { name: 'Done', exact: true }).click();
console.log('ok: welcome page without composer, manage identities'); console.log('ok: welcome page without composer, manage identities');
// Mute and deafen are there without a connection
const me = page.locator('.me');
await me.getByText('Not connected').waitFor();
assert.ok(await me.getByRole('button', { name: 'Server information' }).isDisabled());
await me.getByRole('button', { name: 'Deafen', exact: true }).click();
await me.getByRole('button', { name: 'Unmute', exact: true }).waitFor();
await me.getByText('Deafened').waitFor();
await me.getByRole('button', { name: 'Unmute', exact: true }).click();
await me.getByRole('button', { name: 'Deafen', exact: true }).waitFor();
await me.getByRole('button', { name: 'Mute', exact: true }).waitFor();
console.log('ok: mute and deafen shown while disconnected');
// Settings: upload host + key // Settings: upload host + key
if (process.env.UPLOAD_HOST && process.env.UPLOAD_KEY) { if (process.env.UPLOAD_HOST && process.env.UPLOAD_KEY) {
await page.getByTitle('Settings').click(); await page.getByTitle('Settings').click();
@@ -107,7 +124,7 @@ try {
await page.getByTitle('Add a server').click(); await page.getByTitle('Add a server').click();
await page.getByLabel('Address').fill(host); await page.getByLabel('Address').fill(host);
await page.getByLabel('Port').fill(port); await page.getByLabel('Port').fill(port);
await page.getByLabel('Username').fill('alice'); assert.equal(await page.getByLabel('Username').inputValue(), 'alice', 'username suggested from the identity');
await page.getByLabel('Label').fill('Test Server'); await page.getByLabel('Label').fill('Test Server');
await page.getByRole('button', { name: 'Save and connect' }).click(); await page.getByRole('button', { name: 'Save and connect' }).click();
await page.getByText(/Connected/).first().waitFor(); await page.getByText(/Connected/).first().waitFor();
@@ -213,6 +230,16 @@ try {
assert.equal(await page.evaluate(() => (window as any).pwned), undefined); assert.equal(await page.evaluate(() => (window as any).pwned), undefined);
assert.equal(await page.locator('.msg b', { hasText: 'bold' }).count(), 1); assert.equal(await page.locator('.msg b', { hasText: 'bold' }).count(), 1);
assert.equal(await page.locator('img[src^="https://evil"]').count(), 0); assert.equal(await page.locator('img[src^="https://evil"]').count(), 0);
// The full date appears at the right edge while the pointer is on a message
const anyMsg = page.locator('.chat .msg').last();
const stamp = anyMsg.locator('.stamp');
assert.equal(await stamp.evaluate(el => getComputedStyle(el).visibility), 'hidden');
await anyMsg.hover();
assert.equal(await stamp.evaluate(el => getComputedStyle(el).visibility), 'visible');
assert.match(await stamp.innerText(), /\d{4}.*\d{1,2}:\d{2}:\d{2}/);
const gap = await anyMsg.evaluate(el => el.getBoundingClientRect().right - el.querySelector('.stamp')!.getBoundingClientRect().right);
assert.ok(gap >= 0 && gap < 20, `stamp at the right edge: ${gap}`);
await page.mouse.move(5, 5);
console.log('ok: incoming text rendered and sanitized'); console.log('ok: incoming text rendered and sanitized');
// Right-click menus for text: composer editing, links, messages, authors // Right-click menus for text: composer editing, links, messages, authors
@@ -256,15 +283,23 @@ try {
await page.locator('.chat .composer').getByRole('button', { name: 'Text formatting' }).click(); await page.locator('.chat .composer').getByRole('button', { name: 'Text formatting' }).click();
console.log('ok: formatted message from the toolbar'); console.log('ok: formatted message from the toolbar');
// YouTube links become a click-to-play card; nothing loads from YouTube before the click // YouTube links are plain links by default
bob.sendText({ channels: [0] }, 'watch <a href="https://youtu.be/dQw4w9WgXcQ?t=1m30s">https://youtu.be/dQw4w9WgXcQ?t=1m30s</a>'); bob.sendText({ channels: [0] }, 'watch <a href="https://youtu.be/dQw4w9WgXcQ?t=1m30s">https://youtu.be/dQw4w9WgXcQ?t=1m30s</a>');
const card = page.locator('.msg', { hasText: 'watch' }).locator('.yt'); const ytMsg = page.locator('.msg', { hasText: 'watch' });
await ytMsg.getByRole('link', { name: 'https://youtu.be/dQw4w9WgXcQ?t=1m30s' }).waitFor();
assert.equal(await ytMsg.locator('.yt').count(), 0, 'no YouTube card by default');
// Turned on, they become a click-to-play card; nothing loads from YouTube before the click
await page.getByTitle('Settings').click();
await page.getByRole('tab', { name: 'Chat and files' }).click();
await page.getByLabel('YouTube links').selectOption('click');
await page.getByRole('button', { name: 'Done', exact: true }).click();
const card = ytMsg.locator('.yt');
await card.getByText('from 1:30, click to play').waitFor(); await card.getByText('from 1:30, click to play').waitFor();
assert.equal(await card.locator('iframe').count(), 0); assert.equal(await card.locator('iframe').count(), 0);
assert.equal(await card.locator('.facade').evaluate(el => getComputedStyle(el).backgroundImage), 'none', 'no thumbnail request by default'); assert.equal(await card.locator('.facade').evaluate(el => getComputedStyle(el).backgroundImage), 'none', 'no thumbnail request in click mode');
await card.locator('.facade').click(); await card.locator('.facade').click();
assert.equal(await card.locator('iframe').getAttribute('src'), 'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?autoplay=1&rel=0&start=90'); assert.equal(await card.locator('iframe').getAttribute('src'), 'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?autoplay=1&rel=0&start=90');
console.log('ok: YouTube link embedded as click-to-play'); console.log('ok: YouTube link plain by default, click-to-play when turned on');
// Bob -> DM // Bob -> DM
const alice = [...bob.users.values()].find(u => u.name === 'alice')!; const alice = [...bob.users.values()].find(u => u.name === 'alice')!;
@@ -303,16 +338,72 @@ try {
await page.getByRole('button', { name: 'Done', exact: true }).click(); await page.getByRole('button', { name: 'Done', exact: true }).click();
console.log('ok: quick audio options on right-click'); console.log('ok: quick audio options on right-click');
// STUN server for screen sharing: none by default, presets, or a typed address
await page.getByTitle('Settings').click();
const stun = page.getByLabel('STUN server', { exact: true });
assert.equal(await stun.inputValue(), '');
await stun.selectOption({ label: 'Cloudflare (stun.cloudflare.com:3478)' });
assert.equal(await page.evaluate(() => JSON.parse(localStorage.getItem('mumh5.settings')!).stunServer), 'stun.cloudflare.com:3478');
await stun.selectOption('custom');
await page.getByLabel('STUN server address').fill('stun.example.org:3478');
await page.getByLabel('STUN server address').blur();
assert.equal(await page.evaluate(() => JSON.parse(localStorage.getItem('mumh5.settings')!).stunServer), 'stun.example.org:3478');
await stun.selectOption('');
assert.equal(await page.evaluate(() => JSON.parse(localStorage.getItem('mumh5.settings')!).stunServer), '');
await page.getByRole('button', { name: 'Done', exact: true }).click();
console.log('ok: STUN server choices');
// Color schemes apply right away and are remembered // Color schemes apply right away and are remembered
await page.getByTitle('Settings').click(); await page.getByTitle('Settings').click();
await page.getByRole('tab', { name: 'Appearance' }).click(); await page.getByRole('tab', { name: 'Appearance' }).click();
await page.getByRole('radio', { name: 'Windows 95' }).click(); await page.getByRole('radio', { name: 'Windows 95' }).click();
assert.equal(await page.evaluate(() => document.documentElement.dataset.theme), 'win95'); assert.equal(await page.evaluate(() => document.documentElement.dataset.theme), 'win95');
assert.equal(await page.locator('.rail').evaluate(el => getComputedStyle(el).backgroundColor), 'rgb(0, 128, 128)'); assert.equal(await page.locator('.rail').evaluate(el => getComputedStyle(el).backgroundColor), 'rgb(0, 128, 128)');
await page.getByRole('radio', { name: 'Dark' }).click(); for (const [name, id, bg] of [['Forest', 'forest', 'rgb(22, 35, 28)'], ['Ember', 'ember', 'rgb(33, 25, 20)'], ['Ocean', 'ocean', 'rgb(15, 30, 44)'],
['Plum', 'plum', 'rgb(32, 22, 40)'], ['Paper', 'paper', 'rgb(248, 243, 231)'], ['High contrast', 'contrast', 'rgb(0, 0, 0)']]) {
await page.getByRole('radio', { name }).click();
assert.equal(await page.evaluate(() => document.documentElement.dataset.theme), id);
assert.equal(await page.locator('.chat').evaluate(el => getComputedStyle(el).backgroundColor), bg, `${name} colors the chat`);
if (shots) await page.screenshot({ path: path.join(shots, `theme-pick-${id}.png`) });
}
// Generated schemes, and narrowing the list
assert.equal(await page.getByRole('radiogroup', { name: 'Color scheme' }).getByRole('radio').count(), 100);
await page.getByRole('button', { name: 'Light', exact: true }).click();
assert.equal(await page.getByRole('radiogroup', { name: 'Color scheme' }).getByRole('radio').count(), 28);
await page.getByRole('radio', { name: 'Lavender' }).click();
assert.equal(await page.evaluate(() => document.documentElement.dataset.theme), 'lavender');
if (shots) await page.screenshot({ path: path.join(shots, 'theme-pick-lavender.png') });
await page.getByRole('button', { name: 'All 100' }).click();
await page.getByLabel('Find a color scheme').fill('synth');
assert.equal(await page.getByRole('radiogroup', { name: 'Color scheme' }).getByRole('radio').count(), 1);
await page.getByRole('radio', { name: 'Synthwave' }).click();
assert.equal(await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('color-scheme').trim()), 'dark');
if (shots) { await page.getByRole('button', { name: 'Done', exact: true }).click(); await page.screenshot({ path: path.join(shots, 'theme-synthwave.png') }); await page.getByTitle('Settings').click(); await page.getByRole('tab', { name: 'Appearance' }).click(); }
await page.getByLabel('Find a color scheme').fill('');
await page.getByRole('radio', { name: 'Dark', exact: true }).click();
assert.equal(await page.evaluate(() => document.documentElement.dataset.theme), undefined); assert.equal(await page.evaluate(() => document.documentElement.dataset.theme), undefined);
// Hints are off by default: the title under the pointer is set aside while hovered, and back afterwards
const hintTarget = page.locator('.rail').getByRole('button', { name: 'Add a server' });
const titleOf = () => page.locator('.rail .tile.add').first().getAttribute('title');
await page.getByRole('tab', { name: 'Accessibility' }).click();
assert.equal(await page.getByLabel('Show hints when the pointer rests').isChecked(), false);
await page.getByRole('button', { name: 'Done', exact: true }).click(); await page.getByRole('button', { name: 'Done', exact: true }).click();
console.log('ok: color schemes'); await hintTarget.hover();
assert.equal(await titleOf(), null, 'no tooltip while hints are off');
await page.mouse.move(700, 400);
assert.equal(await titleOf(), 'Add a server');
await page.getByTitle('Settings').click();
await page.getByRole('tab', { name: 'Accessibility' }).click();
await page.getByLabel('Show hints when the pointer rests').check();
await page.getByRole('button', { name: 'Done', exact: true }).click();
await hintTarget.hover();
assert.equal(await titleOf(), 'Add a server', 'tooltip kept while hints are on');
await page.mouse.move(700, 400);
await page.getByTitle('Settings').click();
await page.getByRole('tab', { name: 'Accessibility' }).click();
await page.getByLabel('Show hints when the pointer rests').uncheck();
await page.getByRole('button', { name: 'Done', exact: true }).click();
console.log('ok: color schemes, hints off by default and can be turned on');
// Layouts like the desktop client: classic (channels right of chat), stacked (channels above) // Layouts like the desktop client: classic (channels right of chat), stacked (channels above)
const box = (sel: string) => page.locator(sel).first().boundingBox().then(b => b!); const box = (sel: string) => page.locator(sel).first().boundingBox().then(b => b!);
@@ -359,6 +450,15 @@ try {
const row = pub.locator('tr', { hasText: 'E2E Murmur' }); const row = pub.locator('tr', { hasText: 'E2E Murmur' });
await row.getByText(/^\d+ \/ \d+$/).waitFor({ timeout: 8000 }); await row.getByText(/^\d+ \/ \d+$/).waitFor({ timeout: 8000 });
await row.getByText(/ms$/).waitFor(); await row.getByText(/ms$/).waitFor();
await row.getByRole('button', { name: 'Connect' }).waitFor();
// Column headers sort; a second click reverses
const firstRow = pub.locator('tbody tr').first();
await pub.getByRole('button', { name: 'Server', exact: true }).click();
await firstRow.getByText('E2E Murmur').waitFor();
await pub.getByRole('button', { name: 'Server', exact: true }).click();
await firstRow.getByText('Offline one').waitFor();
await pub.getByRole('button', { name: 'People', exact: true }).click();
await firstRow.getByText('E2E Murmur').waitFor();
await row.getByRole('button', { name: 'Add' }).click(); await row.getByRole('button', { name: 'Add' }).click();
assert.equal(await page.getByLabel('Address').inputValue(), host); assert.equal(await page.getByLabel('Address').inputValue(), host);
assert.equal(await page.getByLabel('Port').inputValue(), port); assert.equal(await page.getByLabel('Port').inputValue(), port);
@@ -532,6 +632,25 @@ try {
await page.locator('.panel h3', { hasText: 'bob' }).waitFor(); await page.locator('.panel h3', { hasText: 'bob' }).waitFor();
console.log('ok: user context menu and profile'); console.log('ok: user context menu and profile');
// Server information from the button next to mute
await page.locator('.me').getByRole('button', { name: 'Server information' }).click();
const srvInfo = page.getByRole('dialog', { name: 'Server information' });
await srvInfo.getByText(`${host}:${port}`).waitFor();
await srvInfo.getByText(/^\d+ of \d+$/).waitFor();
await srvInfo.getByText(/^\d+ characters$/).first().waitFor();
await srvInfo.getByRole('button', { name: 'Close', exact: true }).last().click();
await srvInfo.waitFor({ state: 'detached' });
console.log('ok: server information dialog');
// Toolbar: description editor and settings
await page.locator('.me').getByRole('button', { name: 'Change your description' }).click();
await page.locator('.panel .rich').waitFor();
await page.locator('.panel').getByRole('button', { name: 'Cancel' }).click();
await page.locator('.me').getByRole('button', { name: 'Configure' }).click();
await page.getByRole('dialog', { name: 'Settings' }).waitFor();
await page.getByRole('button', { name: 'Done', exact: true }).click();
console.log('ok: toolbar opens the description editor and settings');
// Information: own connection shows client details and the full certificate // Information: own connection shows client details and the full certificate
await page.locator('.sidebar .user.self').click({ button: 'right' }); await page.locator('.sidebar .user.self').click({ button: 'right' });
await page.getByRole('menuitem', { name: 'Information' }).click(); await page.getByRole('menuitem', { name: 'Information' }).click();
@@ -590,6 +709,29 @@ try {
assert.equal(await page.locator('.me .who span').textContent(), 'Root'); assert.equal(await page.locator('.me .who span').textContent(), 'Root');
console.log('ok: single click shows description without joining'); console.log('ok: single click shows description without joining');
// Description marker: only on channels that have one. Resting on the row previews, clicking the marker opens the panel
const marker = page.locator('.channel', { hasText: 'Lobby' }).getByRole('button', { name: 'Show description of Lobby' });
assert.equal(await page.locator('.channel', { hasText: 'Games' }).locator('.desc').count(), 0);
await page.locator('.panel').getByRole('button', { name: 'Close' }).click();
await page.locator('.channel .name', { hasText: 'Games' }).hover();
await page.waitForTimeout(2000);
assert.equal(await page.locator('.desc-pop').count(), 0, 'no preview for a channel without description');
for (const spot of [page.locator('.channel .name', { hasText: 'Lobby' }), marker]) {
await spot.hover();
await page.waitForTimeout(500);
assert.equal(await page.locator('.desc-pop').count(), 0, 'no preview before 1 second');
await page.locator('.desc-pop .html', { hasText: 'Welcome to the lobby' }).waitFor({ timeout: 3000 });
// Opens at the pointer
const at = (await spot.boundingBox())!;
const pop = (await page.locator('.desc-pop').boundingBox())!;
assert.ok(Math.abs(pop.x - (at.x + at.width / 2 + 12)) < 3 && Math.abs(pop.y - (at.y + at.height / 2 + 12)) < 3, 'preview at the pointer');
await page.mouse.move(700, 400);
await page.locator('.desc-pop').waitFor({ state: 'detached' });
}
await marker.click();
await page.locator('.panel .html', { hasText: 'Welcome to the lobby' }).waitFor();
console.log('ok: description previews on hover, marker opens it on click');
// Side chat sends to that channel while staying in Root // Side chat sends to that channel while staying in Root
const lobby = [...bob.channels.values()].find(c => c.name === 'Lobby')!; const lobby = [...bob.channels.values()].find(c => c.name === 'Lobby')!;
await page.locator('.panel [role=tab]', { hasText: 'Chat' }).click(); await page.locator('.panel [role=tab]', { hasText: 'Chat' }).click();
@@ -614,7 +756,15 @@ try {
await page.locator('.channel .name', { hasText: 'Lobby' }).click({ button: 'right' }); await page.locator('.channel .name', { hasText: 'Lobby' }).click({ button: 'right' });
await page.getByRole('menuitem', { name: 'Join channel' }).click(); await page.getByRole('menuitem', { name: 'Join channel' }).click();
await page.locator('.me .who span', { hasText: 'Lobby' }).waitFor(); await page.locator('.me .who span', { hasText: 'Lobby' }).waitFor();
console.log('ok: channel context menu joins'); // Messaging a channel from its menu opens the side chat, ready to type, without joining
await page.locator('.channel .name', { hasText: 'Games' }).click({ button: 'right' });
await page.getByRole('menuitem', { name: 'Send message to channel' }).click();
const sideInput = page.locator('.panel .composer [contenteditable]');
await sideInput.waitFor();
assert.equal(await sideInput.getAttribute('data-placeholder'), 'Message #Games');
await page.waitForFunction(() => document.activeElement === document.querySelector('.panel .composer [contenteditable]'), null, { timeout: 3000 });
await page.locator('.me .who span', { hasText: 'Lobby' }).waitFor();
console.log('ok: channel context menu joins, and messages a channel from the side chat');
// Side panel can be resized by dragging its left edge // Side panel can be resized by dragging its left edge
const panelBox = (await page.locator('aside.panel').boundingBox())!; const panelBox = (await page.locator('aside.panel').boundingBox())!;
@@ -714,6 +864,28 @@ try {
bob.joinChannel(0); bob.joinChannel(0);
console.log('ok: drag and drop moves channels and people'); console.log('ok: drag and drop moves channels and people');
// Dropped on the lower or upper third of a row, a channel goes behind or in front of it.
// A line shows where it will land.
const topOrder = () => [...bob.channels.values()].filter(c => c.parent === 0).sort(byOrder).map(c => c.name);
assert.deepEqual(topOrder(), ['Games', 'Lobby']);
await page.locator('.channel .name', { hasText: 'Games' }).hover();
await page.mouse.down();
const lobbyBox = (await page.locator('.channel', { hasText: 'Lobby' }).first().boundingBox())!;
await page.mouse.move(lobbyBox.x + 60, lobbyBox.y + lobbyBox.height - 4, { steps: 8 });
// Lobby is open (people inside), so the line sits below them
await page.locator('li.after-end', { hasText: 'Lobby' }).waitFor();
if (shots) await page.screenshot({ path: path.join(shots, 'drag-line.png') });
assert.equal(await page.locator('.channel.dragging', { hasText: 'Games' }).count(), 1, 'the dragged channel is dimmed');
await page.mouse.up();
await bobSees('Games behind Lobby', () => topOrder().join() === 'Lobby,Games');
assert.equal(await page.locator('li.after-end, .channel.after, .channel.before, .channel.into').count(), 0);
await page.locator('.channel .name', { hasText: 'Games' }).dragTo(page.locator('.channel', { hasText: 'Lobby' }).first(), { targetPosition: { x: 60, y: 3 } });
await bobSees('Games in front of Lobby again', () => topOrder().join() === 'Games,Lobby');
// The pointer rests on a row with a description; move away so no preview covers the list
await page.mouse.move(700, 400);
await page.locator('.desc-pop').waitFor({ state: 'detached' });
console.log('ok: drag and drop puts a channel in front of or behind another, with a line where it lands');
// Delete (Team Room is now inside Games, which stays collapsed while empty) // Delete (Team Room is now inside Games, which stays collapsed while empty)
await page.locator('.channel', { hasText: 'Games' }).first().getByRole('button', { name: 'Expand' }).click(); await page.locator('.channel', { hasText: 'Games' }).first().getByRole('button', { name: 'Expand' }).click();
await page.locator('.channel .name', { hasText: 'Team Room' }).click({ button: 'right' }); await page.locator('.channel .name', { hasText: 'Team Room' }).click({ button: 'right' });
@@ -721,6 +893,94 @@ try {
await page.getByRole('button', { name: 'Delete channel' }).click(); await page.getByRole('button', { name: 'Delete channel' }).click();
await bobSees('deleted', () => !chByName('Team Room')); await bobSees('deleted', () => !chByName('Team Room'));
console.log('ok: channel deleted'); console.log('ok: channel deleted');
// Toolbar: expand and collapse the whole tree
await page.locator('.me').getByRole('button', { name: 'Expand all channels' }).click();
await page.locator('.channel', { hasText: 'Minecraft' }).waitFor();
await page.locator('.me').getByRole('button', { name: 'Collapse all channels' }).click();
await page.locator('.channel', { hasText: 'Minecraft' }).waitFor({ state: 'detached' });
// Channels with people in them stay open
assert.notEqual(await page.locator('.me .who span').textContent(), 'Root');
await page.locator('.sidebar .row.user.self').waitFor();
console.log('ok: toolbar expands and collapses all channels, occupied ones stay open');
// ─── Text channels: read and written without joining, between mumh5 clients ───
// Bob plays a second mumh5 client by speaking the plugin data protocol
const assembler = new TextAssembler();
const signals: { type: number; text?: string }[] = [];
const waiting: { want: (s: { type: number; text?: string }) => boolean; res: (s: { type: number; text?: string }) => void }[] = [];
const bobSend = async (to: number[], type: Parameters<typeof encodeText>[0], body?: unknown) => {
for (const p of await encodeText(type, body)) { bob.sendPluginData(to, TEXT_DATA_ID, p); await new Promise(r => setTimeout(r, 120)); }
};
bob.on('pluginData', async (sender, dataId, data) => {
if (dataId !== TEXT_DATA_ID) return;
const signal = await assembler.push(sender, data);
if (!signal) return;
// Like a mumh5 client that was here before: answers a newcomer and hands over an old message
if (signal.type === TEXT.hello) bobSend([sender], TEXT.here);
if (signal.type === TEXT.want && notesId != null) {
bobSend([sender], TEXT.history, { c: notesId, m: [{ i: 'from-before', t: Date.now() - 3600_000, n: 'dave', k: '', h: 'said while you were away' }] });
}
signals.push(signal);
const at = waiting.findIndex(w => w.want(signal));
if (at >= 0) waiting.splice(at, 1)[0].res(signal);
});
const bobGets = (label: string, want: (s: { type: number; text?: string }) => boolean) =>
within(new Promise<{ type: number; text?: string }>(res => waiting.push({ want, res })), label);
await page.mouse.click(listBox.x + listBox.width / 2, listBox.y + listBox.height - 20, { button: 'right' });
await page.getByRole('menuitem', { name: 'Create channel...' }).click();
await page.getByLabel('Name').fill('notes');
await page.getByLabel('Text channel (mumh5 only)').check();
await page.getByRole('button', { name: 'Create channel' }).click();
await bobSees('text channel', () => !!chByName('notes')?.description.startsWith(TEXT_MARK));
notesId = chByName('notes')!.id;
const notesRow = page.locator('.channel', { hasText: 'notes' });
// No description marker for a channel whose description is only the text channel mark
assert.equal(await notesRow.locator('.desc').count(), 0);
const voiceChannel = await page.locator('.me .who span').textContent();
await notesRow.locator('.name').click();
await page.locator('.chat h1', { hasText: 'notes' }).waitFor();
assert.equal(await page.locator('.me .who span').textContent(), voiceChannel, 'opening a text channel does not join it');
const sent = bobGets('text channel message', m => m.type === TEXT.msg);
await page.locator('.chat .composer [contenteditable]').fill('text hello');
await page.locator('.chat .composer [contenteditable]').press('Enter');
const got = parseMsg((await sent).text);
assert.equal(got?.c, notesId);
assert.equal(got?.h, 'text hello');
await page.locator('.chat .msg', { hasText: 'text hello' }).waitFor();
console.log('ok: text channel created, opened without joining, message reaches others');
// A message from someone in another channel arrives; unread while another chat is open
await bobSend([aliceSession()], TEXT.msg, { i: 'bob-1', c: notesId, h: 'hello from <b>bob</b>' });
await page.locator('.chat .msg', { hasText: 'hello from bob' }).locator('strong', { hasText: 'bob' }).first().waitFor();
await page.locator('.channel .name', { hasText: voiceChannel! }).first().dblclick();
await page.locator('.chat h1', { hasText: voiceChannel! }).waitFor();
await bobSend([aliceSession()], TEXT.msg, { i: 'bob-2', c: notesId, h: 'second from bob' });
await notesRow.locator('.badge', { hasText: '1' }).waitFor();
// The same message again is not shown twice
await bobSend([aliceSession()], TEXT.msg, { i: 'bob-2', c: notesId, h: 'second from bob' });
await page.waitForTimeout(500);
await notesRow.locator('.name').click();
await page.locator('.chat .msg', { hasText: 'second from bob' }).waitFor();
assert.equal(await page.locator('.chat .html', { hasText: 'second from bob' }).count(), 1);
assert.equal(await notesRow.locator('.badge').count(), 0);
// Not into ordinary channels
await bobSend([aliceSession()], TEXT.msg, { i: 'bob-3', c: gamesId, h: 'sneaked in' });
await page.waitForTimeout(500);
assert.equal(await page.locator('.channel', { hasText: 'Games' }).first().locator('.badge').count(), 0);
console.log('ok: text channel messages arrive, count as unread, no duplicates, only in text channels');
// The app hands its messages to a client that asks
const handed = bobGets('history', m => m.type === TEXT.history && parseHistory(m.text).length > 0);
await bobSend([aliceSession()], TEXT.want);
const history = parseHistory((await handed).text);
assert.deepEqual(history.map(m => m.h), ['text hello', 'hello from <b>bob</b>', 'second from bob']);
assert.deepEqual(history.map(m => m.n), ['alice', 'bob', 'bob']);
assert.equal(history[0].c, notesId);
await page.locator('.channel .name', { hasText: voiceChannel! }).first().dblclick();
await page.locator('.chat h1', { hasText: voiceChannel! }).waitFor();
console.log('ok: text channel messages handed to a client that asks');
} }
// Without an upload host, images are sent inline within Mumble's image limit // Without an upload host, images are sent inline within Mumble's image limit
@@ -729,6 +989,11 @@ try {
await page.getByTitle('Settings').click(); await page.getByTitle('Settings').click();
await page.getByRole('tab', { name: 'Chat and files' }).click(); await page.getByRole('tab', { name: 'Chat and files' }).click();
await page.getByLabel('Upload host').fill(''); await page.getByLabel('Upload host').fill('');
// Settings that need the upload host are disabled and previews show as off
assert.ok(await page.getByLabel('Files expire after').isDisabled());
assert.ok(await page.getByLabel('Send image previews for regular Mumble clients').isDisabled());
assert.equal(await page.getByLabel('Link previews').inputValue(), 'off');
assert.ok(await page.getByLabel('Link previews').locator('option[value=host]').isDisabled());
await page.getByRole('button', { name: 'Done', exact: true }).click(); await page.getByRole('button', { name: 'Done', exact: true }).click();
const big = path.join(userData, 'big.png'); const big = path.join(userData, 'big.png');
writeFileSync(big, testPng(1600, 1200)); writeFileSync(big, testPng(1600, 1200));
@@ -739,6 +1004,19 @@ try {
assert.ok(inline.length <= 131072, `inline image within the server limit (${inline.length})`); assert.ok(inline.length <= 131072, `inline image within the server limit (${inline.length})`);
console.log(`ok: inline image without upload host (${inline.length} chars)`); console.log(`ok: inline image without upload host (${inline.length} chars)`);
// A click on an image in the chat shows it large; Escape or a click closes it
await page.locator('.chat .msg .html img').last().click();
const viewer = page.getByRole('dialog', { name: 'Image' });
await viewer.waitFor();
const shown = await viewer.locator('img').evaluate((i: HTMLImageElement) => [i.naturalWidth > 0, i.getBoundingClientRect().width]);
assert.ok(shown[0] && (shown[1] as number) > 300, `image shown large: ${shown}`);
await page.keyboard.press('Escape');
await viewer.waitFor({ state: 'detached' });
await page.locator('.chat .msg .html img').last().click();
await viewer.getByRole('button', { name: 'Close image' }).click({ position: { x: 5, y: 5 } });
await viewer.waitFor({ state: 'detached' });
console.log('ok: images open large on click');
// ─── Several servers at once ───────────────────────────────────────────── // ─── Several servers at once ─────────────────────────────────────────────
const userState = (name: string) => [...bob.users.values()].find(u => u.name === name); const userState = (name: string) => [...bob.users.values()].find(u => u.name === name);
const until2 = (label: string, cond: () => boolean) => within(new Promise<void>(res => { const until2 = (label: string, cond: () => boolean) => within(new Promise<void>(res => {
@@ -764,6 +1042,10 @@ try {
await page.locator('.channel .name', { hasText: 'Root' }).dblclick(); await page.locator('.channel .name', { hasText: 'Root' }).dblclick();
await until2('voice moved to the second server', () => !userState('alice-b')?.selfDeaf && !!userState('alice')?.selfDeaf); await until2('voice moved to the second server', () => !userState('alice-b')?.selfDeaf && !!userState('alice')?.selfDeaf);
assert.equal(await page.locator('.rail .tile.voice').getAttribute('title').then(t => t?.startsWith('Second')), true); assert.equal(await page.locator('.rail .tile.voice').getAttribute('title').then(t => t?.startsWith('Second')), true);
// The server with voice leads the rail, although it was added last
const railTop = (title: string) => page.locator(`.rail .tile[title^="${title}"]`).boundingBox().then(b => b!.y);
assert.ok(await railTop('Second') < await railTop('Test Server'), 'voice server on top');
assert.match(await page.locator('.rail .tile').first().getAttribute('title') ?? '', /^Test Server/, 'the saved order is untouched');
console.log('ok: voice follows the channel you join'); console.log('ok: voice follows the channel you join');
// Messages on the server not on screen show as unread on its tile // Messages on the server not on screen show as unread on its tile
@@ -779,6 +1061,8 @@ try {
// Custom icon from the tile's context menu: square-cropped and shown instead of initials // Custom icon from the tile's context menu: square-cropped and shown instead of initials
const iconFile = path.join(userData, 'icon.png'); const iconFile = path.join(userData, 'icon.png');
writeFileSync(iconFile, testPng(300, 200)); writeFileSync(iconFile, testPng(300, 200));
// With hints off, the title of whatever the pointer rests on is set aside; move off the tile first
await page.mouse.move(700, 400);
await page.locator('.rail .tile[title^="Second"]').click({ button: 'right' }); await page.locator('.rail .tile[title^="Second"]').click({ button: 'right' });
const chooser = page.waitForEvent('filechooser'); const chooser = page.waitForEvent('filechooser');
await page.getByRole('menuitem', { name: 'Change icon...' }).click(); await page.getByRole('menuitem', { name: 'Change icon...' }).click();
@@ -824,6 +1108,18 @@ try {
}), 'bob sees the new name'); }), 'bob sees the new name');
console.log('ok: name changed by reconnecting'); console.log('ok: name changed by reconnecting');
// Text channel after reconnecting: what this computer kept, plus what another client hands over
if (notesId != null) {
await page.locator('.channel .name', { hasText: 'notes' }).click();
await page.locator('.chat .msg', { hasText: 'said while you were away' }).locator('strong', { hasText: 'dave' }).waitFor();
const texts = await page.locator('.chat .msg .html').allTextContents();
assert.deepEqual(texts.map(t => t.trim()).filter(t => !t.startsWith('You joined')), ['said while you were away', 'text hello', 'hello from bob', 'second from bob']);
assert.equal(await page.locator('.channel', { hasText: 'notes' }).locator('.badge').count(), 0, 'old messages are not unread');
await page.locator('.channel .name', { hasText: 'Root' }).dblclick();
await page.locator('.chat h1', { hasText: 'Root' }).waitFor();
console.log('ok: text channel keeps its messages and catches up from another client');
}
// Self-registration from the right-click menu on yourself; the profile shows no badge either way // Self-registration from the right-click menu on yourself; the profile shows no badge either way
const meRow = page.locator('.sidebar .user.self'); const meRow = page.locator('.sidebar .user.self');
await meRow.click({ button: 'right' }); await meRow.click({ button: 'right' });
@@ -834,6 +1130,120 @@ try {
await page.keyboard.press('Escape'); await page.keyboard.press('Escape');
console.log('ok: registered on the server'); console.log('ok: registered on the server');
// Giving the microphone back while muted (for Bluetooth headsets), and taking it again
{
const muteBtn = (name: 'Mute' | 'Unmute') => page.locator('.me').getByRole('button', { name, exact: true });
if (!userState(newName)!.selfMute) {
await muteBtn('Mute').click();
await until2('muted', () => !!userState(newName)!.selfMute);
}
await page.getByTitle('Settings').click();
assert.equal(await page.getByLabel('Give the microphone back while muted').isChecked(), false, 'off by default on a desktop');
await page.getByLabel('Give the microphone back while muted').check();
await page.getByText('The microphone is given back now.').waitFor();
await page.getByRole('button', { name: 'Done', exact: true }).click();
// Unmuting takes it again
const heard = new Promise<void>(res => { const off = bob.on('voice', raw => { if (decodeVoice(raw)?.session === userState(newName)!.session) { off(); res(); } }); });
await muteBtn('Unmute').click();
await until2('unmuted', () => !userState(newName)!.selfMute);
await page.getByTitle('Settings').click();
await page.getByText('The microphone is open now.').waitFor();
await page.getByLabel('Give the microphone back while muted').uncheck();
await page.getByRole('button', { name: 'Done', exact: true }).click();
// Push to talk is the mode at this point: hold the key until bob hears the fake microphone
await page.locator('.chat header h1').click();
await page.keyboard.down('Backquote');
await within(heard, 'voice after taking the microphone again', 15000);
await page.keyboard.up('Backquote');
await muteBtn('Mute').click();
await until2('muted again', () => !!userState(newName)!.selfMute);
console.log('ok: microphone given back while muted and taken again on unmute');
}
// ─── The Rooms interface: the same app arranged for phones ─────────────────
{
const before = page.viewportSize()!;
await page.getByTitle('Settings').click();
await page.getByRole('tab', { name: 'Appearance' }).click();
await page.getByRole('radio', { name: 'Rooms' }).click();
await page.getByRole('button', { name: 'Done', exact: true }).click();
await page.setViewportSize({ width: 390, height: 800 });
const nav = page.getByRole('navigation', { name: 'Screens' });
const rooms = page.getByRole('region', { name: 'Rooms' });
await rooms.getByRole('heading', { name: 'Test Server' }).waitFor();
assert.equal(await page.locator('.sidebar').count(), 0, 'the standard interface is gone');
// People show in their room without opening it; the dock names the room you are in
await rooms.locator('.room.here .person', { hasText: 'bob' }).waitFor();
await page.locator('.dock', { hasText: 'Root' }).waitFor();
if (admin) {
await rooms.getByRole('button', { name: 'Join Games' }).click();
await until2('alice in Games', () => bob.channels.get(userState(newName)!.channelId)?.name === 'Games');
await page.locator('.dock', { hasText: 'Games' }).waitFor();
await rooms.getByRole('button', { name: 'Join Root' }).click();
await until2('alice back in Root', () => userState(newName)!.channelId === 0);
await page.locator('.dock', { hasText: 'Root' }).waitFor();
}
console.log('ok: rooms interface lists rooms with their people and joins from the list');
// Mute from the dock
const wasMuted = !!userState(newName)!.selfMute;
await page.locator('.dock').getByRole('button', { name: wasMuted ? 'Unmute' : 'Mute', exact: true }).click();
await until2('mute toggled from the dock', () => !!userState(newName)!.selfMute === !wasMuted);
await page.locator('.dock').getByRole('button', { name: wasMuted ? 'Mute' : 'Unmute', exact: true }).click();
await until2('mute restored', () => !!userState(newName)!.selfMute === wasMuted);
// Chat is a screen of its own
await nav.getByRole('button', { name: 'Chat' }).click();
const roomsMsg = new Promise<void>(res => bob.on('text', m => { if (m.html === 'hello from rooms') res(); }));
await page.locator('.chat2 .composer [contenteditable]').fill('hello from rooms');
await page.locator('.chat2 .composer [contenteditable]').press('Enter');
await within(roomsMsg, 'message from the rooms interface');
await page.locator('.chat2 .msg', { hasText: 'hello from rooms' }).waitFor();
await page.getByRole('button', { name: 'Back to rooms' }).click();
await rooms.waitFor();
// The voice screen shows the room as tiles with the large controls
await nav.getByRole('button', { name: 'Voice' }).click();
const voiceScreen = page.getByRole('region', { name: 'Voice', exact: true });
await voiceScreen.locator('.tile', { hasText: 'bob' }).waitFor();
await voiceScreen.getByRole('button', { name: /^Share your screen or camera$/ }).waitFor();
assert.equal(await page.locator('.dock').count(), 0, 'no dock on the voice screen');
// People: a profile opens over the screen, a message opens the chat
await nav.getByRole('button', { name: 'People' }).click();
const people = page.getByRole('region', { name: 'People' });
await people.locator('.entry', { hasText: 'bob' }).locator('.main').click();
await page.locator('.sheet h3', { hasText: 'bob' }).waitFor();
await page.keyboard.press('Escape');
await page.locator('.sheet').waitFor({ state: 'detached' });
await people.getByRole('button', { name: 'Message bob' }).click();
await page.locator('.chat2 h1', { hasText: 'bob' }).waitFor();
if (shots) await page.screenshot({ path: path.join(shots, 'rooms-phone.png') });
// Wide windows keep the rooms next to the screen
await page.setViewportSize({ width: 1280, height: 800 });
await rooms.waitFor();
await page.locator('.chat2').waitFor();
assert.equal(await nav.getByRole('button', { name: 'Rooms' }).count(), 0);
if (notesId != null) {
await rooms.locator('.entry', { hasText: 'notes' }).locator('.main').click();
await page.locator('.chat2 .msg', { hasText: 'text hello' }).waitFor();
}
if (shots) await page.screenshot({ path: path.join(shots, 'rooms-wide.png') });
console.log('ok: rooms interface: dock, chat, voice screen, people, profile sheet, wide layout');
// Back to the standard interface from the You screen
await nav.getByRole('button', { name: 'You' }).click();
await page.getByRole('region', { name: 'You' }).getByRole('button', { name: /^Appearance/ }).click();
await page.getByRole('radio', { name: 'Standard' }).click();
await page.getByRole('button', { name: 'Done', exact: true }).click();
await page.locator('.sidebar').waitFor();
await page.setViewportSize(before);
await page.locator('.channel .name', { hasText: 'Root' }).dblclick();
await page.locator('.chat h1', { hasText: 'Root' }).waitFor();
console.log('ok: back to the standard interface from the You screen');
}
// Kicked: the reason is shown with a Reconnect button // Kicked: the reason is shown with a Reconnect button
if (admin) { if (admin) {
const me = [...admin.users.values()].find(u => u.name === newName)!; const me = [...admin.users.values()].find(u => u.name === newName)!;
+1 -1
View File
@@ -81,7 +81,7 @@ const page = await app.firstWindow();
await page.setViewportSize({ width: 1440, height: 900 }); await page.setViewportSize({ width: 1440, height: 900 });
await page.getByRole('button', { name: /Create a new identity/ }).click(); await page.getByRole('button', { name: /Create a new identity/ }).click();
await page.getByLabel('Name').fill('kibi'); await page.getByLabel('Name', { exact: true }).fill('kibi');
await page.getByRole('button', { name: 'Create', exact: true }).click(); await page.getByRole('button', { name: 'Create', exact: true }).click();
await page.getByRole('button', { name: 'Skip for now' }).click(); await page.getByRole('button', { name: 'Skip for now' }).click();
await page.getByRole('button', { name: 'Done' }).click(); await page.getByRole('button', { name: 'Done' }).click();
+385
View File
@@ -0,0 +1,385 @@
// Screen sharing between two instances of the built app on a real Mumble server.
// npm run build && MUMBLE_TEST_HOST=localhost:64739 npm run test:e2e:share
import { _electron as electron, type Page } from 'playwright-core';
import electronPath from 'electron';
import assert from 'node:assert/strict';
import { mkdtempSync } from 'node:fs';
import { spawn } from 'node:child_process';
import { tmpdir } from 'node:os';
import path from 'node:path';
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, '../..');
const { ELECTRON_RUN_AS_NODE, ...env } = process.env;
const tag = Date.now() % 100000;
async function launch() {
const app = await electron.launch({
executablePath: electronPath as unknown as string,
args: [root, `--user-data-dir=${mkdtempSync(path.join(tmpdir(), 'mumh5-e2e-'))}`, '--ozone-platform=x11',
'--use-fake-device-for-media-stream=device-count=2', '--use-fake-ui-for-media-stream'],
env: env as Record<string, string>
});
const page = await app.firstWindow();
page.on('console', m => { if (m.type() === 'error') console.log('[renderer]', m.text()); });
await page.setViewportSize({ width: 1280, height: 800 });
return { app, page };
}
// Identity wizard, then connect; the username comes from the identity
async function join(page: Page, name: string) {
await page.getByRole('dialog', { name: 'Set up your identity' }).waitFor();
await page.getByRole('button', { name: /Create a new identity/ }).click();
await page.getByLabel('Name', { exact: true }).fill(name);
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();
await page.getByTitle('Add a server').click();
await page.getByLabel('Address').fill(host);
await page.getByLabel('Port').fill(port);
await page.getByRole('button', { name: 'Save and connect' }).click();
await page.getByText(/Connected/).first().waitFor();
}
const cleanup: (() => void)[] = [];
const alice = await launch();
const bob = await launch();
const aliceName = `share-a-${tag}`, bobName = `share-b-${tag}`;
try {
await join(alice.page, aliceName);
// Alice starts sharing: a note about IP addresses once, then the source picker
const shareBtn = alice.page.locator('.me').getByRole('button', { name: 'Share your screen' });
await shareBtn.click();
await alice.page.getByText(/You see each other's IP address/).waitFor();
await alice.page.getByRole('button', { name: 'Continue' }).click();
// The dialog: pick a source, see the preview, then start
const picker = alice.page.getByRole('dialog', { name: 'Share your screen' });
const startBtn = picker.getByRole('button', { name: 'Start sharing' });
const pickAndStart = async () => {
assert.ok(await startBtn.isDisabled(), 'nothing can start before a source is chosen');
// Listing is slow on the bare test display and can come back empty the first time
for (let i = 0; i < 6 && !(await picker.locator('.sources button').count()); i++) {
await Promise.race([picker.locator('.sources button').first().waitFor({ timeout: 40000 }), picker.getByText('Nothing found').waitFor({ timeout: 40000 })]).catch(() => {});
if (await picker.getByText('Nothing found').count()) await picker.getByRole('button', { name: 'Refresh' }).click();
}
await picker.locator('.sources button').first().click();
await alice.page.waitForFunction(() => (document.querySelector('.preview video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 15000 });
await startBtn.click();
};
await picker.getByLabel('Quality').selectOption('low');
await pickAndStart();
await alice.page.locator('.stage .status', { hasText: '0 watching' }).waitFor();
await alice.page.locator('.sidebar .row.user.self').getByTitle('You are sharing your screen').waitFor();
// The sharer sees what is being sent, large by default; the tiles themselves never carry the picture
const ownView = alice.page.getByRole('region', { name: 'Screen share' });
await ownView.getByText('Your screen').waitFor();
await alice.page.waitForFunction(() => (document.querySelector('.spot video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 15000 });
assert.equal(await alice.page.locator('.stage .tile video').count(), 0);
await ownView.getByRole('button', { name: 'Back to tiles' }).click();
await ownView.waitFor({ state: 'detached' });
await alice.page.locator('.stage .tile.self').click();
await ownView.waitFor();
console.log('ok: sharing started from the dialog, own stream shown large');
// Stacked layout: only the picture, as wide as the chat, and the layout stays stacked on narrow windows
const setLayout = async (name: string) => {
await alice.page.getByTitle('Settings').click();
await alice.page.getByRole('tab', { name: 'Appearance' }).click();
await alice.page.getByRole('radio', { name }).click();
await alice.page.getByRole('button', { name: 'Done', exact: true }).click();
};
await setLayout('Stacked');
await alice.page.locator('.app.stacked .stage.solo').waitFor();
assert.equal(await alice.page.locator('.stage .tiles').count(), 0, 'no participant tiles in the stacked layout');
const fits = () => alice.page.evaluate(() => {
const v = document.querySelector('.stage.solo video') as HTMLVideoElement, c = document.querySelector('.chat')!;
return v.videoWidth > 0 && Math.abs(v.getBoundingClientRect().width - c.getBoundingClientRect().width) < 2;
});
await alice.page.waitForFunction(() => (document.querySelector('.stage.solo video') as HTMLVideoElement | null)?.videoWidth! > 0);
assert.ok(await fits(), 'picture as wide as the chat');
await alice.page.setViewportSize({ width: 600, height: 800 });
await alice.page.locator('.app.stacked .stage.solo video').waitFor();
assert.ok(await fits(), 'still as wide as the chat on a narrow window');
// On a short window the picture gives way and the chat keeps about five lines
await alice.page.setViewportSize({ width: 600, height: 700 });
const heights = () => alice.page.evaluate(() => [document.querySelector('.chat [role=log]')!.getBoundingClientRect().height, document.querySelector('.stage.solo video')!.getBoundingClientRect().height]);
await alice.page.waitForFunction(() => document.querySelector('.stage.solo video')!.getBoundingClientRect().height < 250);
const [logH, videoH] = await heights();
assert.ok(logH >= 120, `chat keeps its lines: ${logH}`);
assert.ok(videoH > 40, `the picture is still shown: ${videoH}`);
await alice.page.setViewportSize({ width: 600, height: 800 });
// The chat can be dragged taller, never below its minimum; the picture takes the rest
const chatHandle = alice.page.getByRole('separator', { name: 'Resize the chat' });
await chatHandle.focus();
for (let i = 0; i < 12; i++) await alice.page.keyboard.press('ArrowUp');
await alice.page.waitForFunction(() => document.querySelector('.chat [role=log]')!.getBoundingClientRect().height > 200);
const grown = (await heights())[0];
for (let i = 0; i < 40; i++) await alice.page.keyboard.press('ArrowDown');
await alice.page.waitForFunction(() => document.querySelector('.chat [role=log]')!.getBoundingClientRect().height < 130);
const [smallest, videoNow] = await heights();
assert.ok(smallest >= 124, `not below the minimum: ${smallest}`);
assert.ok(videoNow > 100, `the picture fills the rest: ${videoNow}`);
console.log(`ok: stacked chat resizable (${Math.round(grown)} px down to ${Math.round(smallest)} px)`);
if (process.env.SHOTS_DIR) await alice.page.screenshot({ path: `${process.env.SHOTS_DIR}/stacked.png` });
await alice.page.getByText('0 watching').waitFor();
await alice.page.setViewportSize({ width: 1280, height: 800 });
await setLayout('Modern');
await alice.page.locator('.stage .tiles').waitFor();
console.log('ok: stacked layout shows only the picture and stays stacked when narrow');
// Bob connects afterwards and still learns about the stream
await join(bob.page, bobName);
// Next to the name in the channel list, and on the tile above the chat
const watch = bob.page.locator('.sidebar').getByRole('button', { name: `Watch the screen of ${aliceName}`, exact: true });
await watch.waitFor();
const tile = bob.page.locator('.stage .tile', { hasText: aliceName });
await tile.waitFor();
console.log('ok: a late joiner sees who is sharing');
// The tiles fill the stage exactly, whatever its size
const fills = () => bob.page.evaluate(() => {
const grid = document.querySelector('.stage .tiles')!.getBoundingClientRect();
const tiles = [...document.querySelectorAll('.stage .tile')].map(e => e.getBoundingClientRect());
const left = Math.min(...tiles.map(r => r.left)), right = Math.max(...tiles.map(r => r.right));
const top = Math.min(...tiles.map(r => r.top)), bottom = Math.max(...tiles.map(r => r.bottom));
return { ok: left - grid.left < 10 && grid.right - right < 10 && top - grid.top < 10 && grid.bottom - bottom < 10, height: Math.round(tiles[0].height), count: tiles.length };
});
const first = await fills();
assert.ok(first.ok && first.count === 2, `tiles fill the stage: ${JSON.stringify(first)}`);
const handle = bob.page.getByRole('separator', { name: 'Resize the tiles' });
await handle.focus();
for (let i = 0; i < 8; i++) await bob.page.keyboard.press('ArrowDown');
await bob.page.waitForFunction(h => Math.round(document.querySelector('.stage .tile')!.getBoundingClientRect().height) > h, first.height);
const taller = await fills();
assert.ok(taller.ok, 'still filled after making the stage taller');
await bob.page.setViewportSize({ width: 820, height: 700 });
await bob.page.waitForFunction(() => document.querySelector('.stage .tiles')!.getBoundingClientRect().width < 700);
assert.ok((await fills()).ok, 'still filled after making the window narrower');
await bob.page.setViewportSize({ width: 1280, height: 800 });
for (let i = 0; i < 8; i++) await bob.page.keyboard.press('ArrowUp');
if (process.env.SHOTS_DIR) await bob.page.screenshot({ path: `${process.env.SHOTS_DIR}/tiles.png` });
console.log(`ok: tiles fill the stage at every size (${first.height} to ${taller.height} px tall)`);
await tile.click();
await bob.page.getByRole('button', { name: 'Continue' }).click();
const view = bob.page.getByRole('region', { name: 'Screen share' });
await view.getByText(aliceName).waitFor();
await bob.page.waitForFunction(() => (document.querySelector('.spot video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 20000 });
const size = await bob.page.evaluate(() => { const v = document.querySelector('.spot video') as HTMLVideoElement; return [v.videoWidth, v.videoHeight]; });
await alice.page.locator('.stage .status', { hasText: '1 watching' }).waitFor();
console.log(`ok: bob receives the picture (${size[0]}x${size[1]}), alice sees one viewer`);
if (process.env.SHOTS_DIR) { await bob.page.waitForTimeout(500); await bob.page.screenshot({ path: `${process.env.SHOTS_DIR}/meeting.png` }); }
// Right-click on the picture: statistics for both sides
const nerd = async (page: Page, expect: RegExp[]) => {
await page.locator('.spot video').click({ button: 'right' });
await page.getByRole('menuitem', { name: 'Stats for nerds' }).click();
const box = page.getByRole('status', { name: 'Stream statistics' });
for (const e of expect) await box.getByText(e).first().waitFor({ timeout: 8000 });
const text = (await box.innerText()).replace(/\n+/g, ' | ');
await box.getByRole('button', { name: 'Close statistics' }).click();
await box.waitFor({ state: 'detached' });
return text;
};
const viewerStats = await nerd(bob.page, [/^(VP8|VP9|H264|AV1)/, /^\d+x\d+/, /^\d+ kbit\/s$/, /^direct/]);
const sharerStats = await nerd(alice.page, [new RegExp(`To ${bobName}`), /^(VP8|VP9|H264|AV1)/, /^\d+ kbit\/s$/]);
console.log(`ok: stats for nerds (viewer: ${viewerStats.slice(0, 200)}...)`);
assert.ok(sharerStats.includes('Round trip'));
// Touch screens: holding a finger on the picture opens the same menu
await bob.page.evaluate(() => {
const el = document.querySelector('.spot .pic')!;
const r = el.getBoundingClientRect();
const touch = new Touch({ identifier: 1, target: el, clientX: r.left + 40, clientY: r.top + 40 });
el.dispatchEvent(new TouchEvent('touchstart', { touches: [touch], bubbles: true }));
});
await bob.page.getByRole('menuitem', { name: 'Stats for nerds' }).waitFor({ timeout: 3000 });
await bob.page.keyboard.press('Escape');
console.log('ok: long press on the picture opens the menu');
// The large picture can move to a window of its own
const opened = bob.app.waitForEvent('window');
// From the right-click menu on the picture
await bob.page.locator('.spot video').click({ button: 'right' });
await bob.page.getByRole('menuitem', { name: 'Open in a separate window' }).click();
const streamWindow = await opened;
await streamWindow.waitForFunction(() => (document.querySelector('video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 15000 });
await view.getByText('Shown in a separate window.').waitFor();
await streamWindow.close();
await view.getByRole('button', { name: 'Open in a separate window' }).waitFor();
await bob.page.waitForFunction(() => (document.querySelector('.spot video') as HTMLVideoElement | null)?.videoWidth! > 0);
console.log('ok: stream in a separate window, back when it is closed');
// Back to tiles hides the picture; the sharer's tile brings it back
await view.getByRole('button', { name: 'Back to tiles' }).click();
await view.waitFor({ state: 'detached' });
assert.equal(await bob.page.locator('.stage .tile video').count(), 0);
await tile.click();
await view.waitFor();
console.log('ok: large view hidden and shown again from the tile');
// Leaving and coming back
await view.getByRole('button', { name: 'Stop watching' }).click();
await view.waitFor({ state: 'detached' });
await alice.page.locator('.stage .status', { hasText: '0 watching' }).waitFor();
// The sharer's profile says so and offers to watch
await bob.page.locator('.sidebar .row.user', { hasText: aliceName }).click();
const profile = bob.page.locator('.panel .sharing');
await profile.getByText('Sharing their screen').waitFor();
await profile.getByRole('button', { name: 'Watch' }).click();
await profile.getByRole('button', { name: 'Stop watching' }).waitFor();
await bob.page.waitForFunction(() => (document.querySelector('.spot video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 20000 });
console.log('ok: stop watching, watch again from the profile');
// Alice stops: the view and the indicator go away
await alice.page.locator('.me').getByRole('button', { name: 'Stop sharing your screen' }).click();
await view.waitFor({ state: 'detached' });
await watch.waitFor({ state: 'detached' });
await profile.waitFor({ state: 'detached' });
assert.equal(await alice.page.locator('.stage .status').count(), 0);
console.log('ok: stopping ends the stream for viewers');
// Camera instead of the screen, and a relay for networks without direct connections: both
// instances point at a mumh5 proxy, and the viewer is limited to the relay over TCP
const relayPort = 19000 + Math.floor(Math.random() * 500);
const relay = spawn(process.execPath, ['server/main.ts'], {
cwd: root, stdio: ['ignore', 'pipe', 'inherit'],
env: { ...env, MUMH5_PORT: String(relayPort), MUMH5_ALLOW_ANY: '1', MUMH5_ALLOW_PRIVATE: '1', MUMH5_STUN_PORT: String(relayPort + 500), MUMH5_STUN_BIND: '127.0.0.1', MUMH5_TURN_IP: '127.0.0.1' }
});
cleanup.push(() => relay.kill());
await new Promise<void>((res, rej) => { relay.stdout.on('data', d => { if (String(d).includes('listening')) res(); }); relay.on('exit', c => rej(new Error(`proxy exited with ${c}`))); });
for (const p of [alice.page, bob.page]) {
await p.getByTitle('Settings').click();
await p.getByLabel('Relay').fill(`http://127.0.0.1:${relayPort}`);
await p.getByLabel('Relay').blur();
await p.getByRole('button', { name: 'Done', exact: true }).click();
}
await bob.page.evaluate(() => localStorage.setItem('mumh5.iceDebug', 'relay-tcp'));
await shareBtn.click();
await picker.getByRole('tab', { name: 'Camera' }).click();
await alice.page.waitForFunction(() => (document.querySelector('.preview video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 15000 });
assert.equal(await picker.getByLabel('Sound').count(), 0, 'no extra sound with a camera');
await startBtn.click();
await alice.page.locator('.sidebar .row.user.self').getByTitle('You are sharing your camera').waitFor();
// Cameras play in the tiles and come on by themselves, like in a video call
const cam = bob.page.locator('.sidebar').getByRole('button', { name: `Watch the camera of ${aliceName}`, exact: true });
await cam.waitFor();
const liveCams = (p: Page) => p.evaluate(() => [...document.querySelectorAll('.stage .tile video')].filter(v => (v as HTMLVideoElement).videoWidth > 0).length);
const camsShown = (p: Page, n: number) => p.waitForFunction(
want => [...document.querySelectorAll('.stage .tile video')].filter(v => (v as HTMLVideoElement).videoWidth > 0).length === want, n, { timeout: 30000 });
await camsShown(bob.page, 1);
// One person's camera can be hidden, and shown again
await bob.page.locator('.stage .tile', { hasText: aliceName }).click({ button: 'right' });
await bob.page.getByRole('menuitem', { name: 'Hide camera' }).click();
await camsShown(bob.page, 0);
await alice.page.locator('.stage .status', { hasText: '0 watching' }).waitFor();
await bob.page.locator('.stage .tile', { hasText: aliceName }).click({ button: 'right' });
await bob.page.getByRole('menuitem', { name: 'Show camera' }).click();
await camsShown(bob.page, 1);
assert.equal(await bob.page.getByRole('region', { name: 'Screen share' }).count(), 0, 'a camera does not take over the stage by itself');
// Bob turns his camera on too: both now see both, each one their own mirrored
await bob.page.locator('.me').getByRole('button', { name: 'Share your screen or camera' }).click();
const bobDialog = bob.page.getByRole('dialog', { name: 'Share your screen' });
await bobDialog.getByRole('tab', { name: 'Camera' }).click();
await bob.page.waitForFunction(() => (document.querySelector('.preview video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 15000 });
await bobDialog.getByRole('button', { name: 'Start sharing' }).click();
await camsShown(bob.page, 2);
await camsShown(alice.page, 2);
assert.equal(await bob.page.locator('.stage .tile.self video.mirror').count(), 1, 'own camera is mirrored');
// The picture keeps its shape in the tile at any tile size: whole, not cropped
const shape = () => bob.page.evaluate(() => [...document.querySelectorAll('.stage .tile video')].map(v => {
const el = v as HTMLVideoElement, r = el.getBoundingClientRect();
const scale = Math.min(r.width / el.videoWidth, r.height / el.videoHeight);
return { fit: getComputedStyle(el).objectFit, inside: el.videoWidth * scale <= r.width + 1 && el.videoHeight * scale <= r.height + 1, box: r.width > 50 && r.height > 50 };
}));
for (const size of [{ width: 1280, height: 800 }, { width: 760, height: 620 }]) {
await bob.page.setViewportSize(size);
await camsShown(bob.page, 2);
assert.ok((await shape()).every(s => s.fit === 'contain' && s.inside && s.box), `cameras whole at ${size.width} wide`);
}
await bob.page.setViewportSize({ width: 1280, height: 800 });
assert.equal(await bob.page.locator('.stage .tile:not(.self) video.mirror').count(), 0);
await alice.page.locator('.stage .status', { hasText: '1 watching' }).waitFor();
await bob.page.locator('.stage .status', { hasText: '1 watching' }).waitFor();
if (process.env.SHOTS_DIR) await bob.page.screenshot({ path: `${process.env.SHOTS_DIR}/cams.png` });
// Switching cameras swaps the picture on the running stream. Chromium's second fake camera
// never delivers frames to an encoder (not even as the first camera), so the viewer is
// checked after switching there and back.
const ownCamera = () => alice.page.evaluate(() => ((document.querySelector('.stage .tile.self video') as HTMLVideoElement).srcObject as MediaStream).getVideoTracks()[0].label);
const firstCamera = await ownCamera();
const switchTo = async (different: boolean) => {
await alice.page.locator('.stage').getByRole('button', { name: 'Switch camera' }).click();
await alice.page.waitForFunction(([was, differs]) => {
const v = document.querySelector('.stage .tile.self video') as HTMLVideoElement | null;
const track = (v?.srcObject as MediaStream | null)?.getVideoTracks()[0];
return !!track && (track.label !== was) === differs && track.readyState === 'live' && v!.videoWidth > 0;
}, [firstCamera, different] as const, { timeout: 15000 });
await alice.page.getByRole('button', { name: 'Switch camera' }).and(alice.page.locator(':enabled')).waitFor();
};
await switchTo(true);
const secondCamera = await ownCamera();
await switchTo(false);
const fresh = await bob.page.evaluate(n => new Promise<number>(resolve => {
const v = document.querySelector(`.stage .tile[aria-label*="${n}"] video`) as HTMLVideoElement;
let count = 0;
const tick = () => { if (++count >= 8) resolve(count); else v.requestVideoFrameCallback(tick); };
v.requestVideoFrameCallback(tick);
setTimeout(() => resolve(count), 12000);
}), aliceName);
assert.ok(fresh >= 8, `viewer got ${fresh} new frames after switching cameras`);
await alice.page.locator('.stage .status', { hasText: '1 watching' }).waitFor();
assert.equal(await alice.page.locator('.bar.bad').count(), 0);
console.log(`ok: camera switched while sharing (${firstCamera} to ${secondCamera} and back), viewer kept the stream`);
// A click enlarges one camera; the statistics show it came through the relay
await bob.page.locator('.stage .tile', { hasText: aliceName }).click();
await bob.page.waitForFunction(() => (document.querySelector('.spot video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 20000 });
assert.equal(await bob.page.locator('.stage .tile', { hasText: aliceName }).locator('video').count(), 0, 'the enlarged camera is not also in its tile');
assert.equal(await bob.page.locator('.stage .tile.self video').count(), 1, 'the other tiles keep theirs');
const camStats = await nerd(bob.page, [/^relayed \(TURN\), reached over TCP/, /^0 direct, 0 through STUN, [1-9]\d* relayed/]);
await bob.page.getByRole('region', { name: 'Screen share' }).getByRole('button', { name: 'Back to tiles' }).click();
// Everyone's cameras off at once keeps Bob's own camera on and drops the others; on again brings them back
await bob.page.locator('.stage').getByRole('button', { name: 'Hide all cameras' }).click();
await camsShown(bob.page, 1);
await alice.page.locator('.stage .status', { hasText: '0 watching' }).waitFor();
await bob.page.locator('.stage').getByRole('button', { name: 'Show all cameras' }).click();
await camsShown(bob.page, 2);
await bob.page.locator('.me').getByRole('button', { name: 'Stop sharing your camera' }).click();
await camsShown(alice.page, 1);
await alice.page.locator('.me').getByRole('button', { name: 'Stop sharing your camera' }).click();
await cam.waitFor({ state: 'detached' });
await bob.page.evaluate(() => localStorage.removeItem('mumh5.iceDebug'));
console.log(`ok: cameras of several people at once, in the tiles, through the relay (${camStats.match(/Picture \| [^|]+/)?.[0]})`);
void liveCams;
// A sharer who disconnects takes the stream with them
await shareBtn.click();
await pickAndStart();
await watch.waitFor();
await alice.page.getByTitle('Disconnect').click();
await watch.waitFor({ state: 'detached' });
console.log('ok: disconnecting ends the stream');
// Sounds: the sharer hears the stream start and stop and viewers come and go; the channel hears streams start and stop
const sounds = (p: Page) => p.evaluate(() => [...((window as any).__mumh5Sounds ?? [])] as string[]);
const heardA = await sounds(alice.page), heardB = await sounds(bob.page);
for (const ev of ['shareStarted', 'viewerJoined', 'viewerLeft', 'shareStopped']) assert.ok(heardA.includes(ev), `sharer hears ${ev}: ${heardA}`);
for (const ev of ['shareStarted', 'shareStopped']) assert.ok(heardB.includes(ev), `viewer hears ${ev}: ${heardB}`);
console.log('ok: sounds for streams and viewers');
console.log('SHARE E2E PASSED');
} finally {
for (const c of cleanup) c();
await alice.app.close().catch(() => {});
await bob.app.close().catch(() => {});
}
+23
View File
@@ -0,0 +1,23 @@
// A bare browser window for the web E2E: no preload, so the page runs as it would in a browser.
const { app, BrowserWindow, desktopCapturer, 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', ''); });
});
// Stands in for the browser's own "choose what to share" dialog: always the first screen
session.defaultSession.setDisplayMediaRequestHandler(async (_request, callback) => {
let sources = [];
for (let i = 0; i < 10 && !sources.length; i++) sources = await desktopCapturer.getSources({ types: ['screen'] });
try { callback(sources.length ? { video: sources[0] } : {}); } catch { /* refused */ }
});
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());
+266
View File
@@ -0,0 +1,266 @@
// 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, encodeVoice, type VoicePacket } from '../../src/core/voice-packet.ts';
import { readFileSync } from 'node:fs';
import { instantiateOpus, WasmDecoder } from '../../src/lib/audio/opus-wasm.ts';
import { MumbleClient } from '../../src/core/client.ts';
import type { Transport } from '../../src/core/transport.ts';
import { openTls } from '../../electron/tls-transport.ts';
import { generateIdentity } from '../../electron/identity.ts';
const target = process.env.MUMBLE_TEST_HOST;
if (!target) {
console.log('MUMBLE_TEST_HOST not set, skipping');
process.exit(0);
}
const [host, port] = target.split(':');
const root = path.resolve(import.meta.dirname, '../..');
function headless(username: string): Promise<MumbleClient> {
const id = generateIdentity(username);
const client = new MumbleClient(createCodec());
const t: Transport = { onData: null, onClose: null, send: b => conn.send(b), close: () => conn.close() };
const conn = openTls(host, Number(port), id.certPem, id.keyPem, {
onSecure: () => {}, onData: c => t.onData?.(c), onClose: r => t.onClose?.(r)
});
client.connect(t, { username, os: 'test' });
return new Promise((res, rej) => { client.on('synced', () => res(client)); client.on('close', rej); });
}
function within<T>(p: Promise<T>, label: string, ms = 10000): Promise<T> {
return Promise.race([p, new Promise<T>((_, rej) => setTimeout(() => rej(new Error(`Timed out waiting for: ${label}`)), ms))]);
}
// The bundled proxy, as it would be deployed: one allowed server, serving dist-web
const proxyPort = 18000 + Math.floor(Math.random() * 1000);
const { ELECTRON_RUN_AS_NODE, ...env } = process.env;
const proxy = spawn(process.execPath, [path.join(root, 'dist-proxy/proxy.mjs')], {
env: { ...env, MUMH5_PORT: String(proxyPort), MUMH5_SERVERS: `${target}=Test Server`, MUMH5_STUN_PORT: String(proxyPort + 1000), MUMH5_STUN_BIND: '127.0.0.1',
// Everything is on this machine, so the relay has to be allowed to reach loopback
MUMH5_ALLOW_PRIVATE: '1', MUMH5_TURN_IP: '127.0.0.1' }, stdio: ['ignore', 'pipe', 'inherit']
});
await within(new Promise<void>((res, rej) => {
proxy.stdout.on('data', d => { if (String(d).includes('listening')) res(); });
proxy.on('exit', code => rej(new Error(`proxy exited with ${code}`)));
}), 'proxy start');
const downloads = mkdtempSync(path.join(tmpdir(), 'mumh5-e2e-dl-'));
const browser = () => electron.launch({
executablePath: electronPath as unknown as string,
args: [path.join(root, 'test/e2e/web-shell.cjs'), `--user-data-dir=${mkdtempSync(path.join(tmpdir(), 'mumh5-e2e-'))}`, '--ozone-platform=x11',
'--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'],
env: { ...env, MUMH5_WEB_URL: `http://127.0.0.1:${proxyPort}/`, MUMH5_DOWNLOADS: downloads } as Record<string, string>
});
const app = await browser();
let second: Awaited<ReturnType<typeof browser>> | null = null;
const bob = await headless('bob');
const name = `webalice${Date.now() % 100000}`;
try {
const page = await app.firstWindow();
page.on('console', m => { if (m.type() === 'error') console.log('[page]', m.text()); });
await page.setViewportSize({ width: 1280, height: 800 });
assert.equal(await page.evaluate(() => 'mumh5Native' in window), false, 'no desktop API in the page');
// Identity: created through the proxy, kept in the browser
await page.getByRole('dialog', { name: 'Set up your identity' }).waitFor();
await page.getByText(/passed to the proxy at/).waitFor();
await page.getByRole('button', { name: /Create a new identity/ }).click();
await page.getByLabel('Name', { exact: true }).fill('alice');
await page.getByRole('button', { name: 'Create', exact: true }).click();
// Backup: downloaded as a .p12 file
await page.getByLabel(/^Backup password/).fill('backup-pass');
await page.getByLabel('Repeat password').fill('backup-pass');
await page.getByRole('button', { name: 'Save backup...' }).click();
await page.getByText('Backup saved to your downloads as alice.p12').waitFor();
const saved = path.join(downloads, 'alice.p12');
for (let i = 0; i < 50 && !existsSync(saved + '.done'); i++) await new Promise(r => setTimeout(r, 100));
assert.ok(existsSync(saved + '.done') && statSync(saved).size > 1000, 'backup file downloaded');
await page.getByRole('button', { name: 'Done' }).click();
await page.getByRole('dialog', { name: 'Set up your identity' }).waitFor({ state: 'detached' });
const stored = await page.evaluate(() => JSON.parse(localStorage.getItem('mumh5.identities')!));
assert.equal(stored.identities.length, 1);
assert.equal(stored.setupDone, true);
console.log('ok: identity created through the proxy and backed up');
// Desktop-only features are not offered
assert.equal(await page.getByTitle('Browse public servers').count(), 0);
await page.getByTitle('Settings').click();
await page.getByRole('tab', { name: 'Chat and files' }).click();
assert.ok(await page.getByLabel('Link previews').locator('option[value=direct]').isDisabled());
await page.getByRole('tab', { name: 'Identities' }).click();
await page.getByText('alice', { exact: true }).first().waitFor();
await page.getByRole('button', { name: 'Done', exact: true }).click();
console.log('ok: public list and direct previews are desktop only');
// The connect dialog offers the proxy's servers
await page.getByTitle('Add a server').click();
const pick = page.getByLabel('Server', { exact: true });
await pick.waitFor();
await page.waitForFunction(v => (document.getElementById('c-host') as HTMLSelectElement)?.value === v, `${host}:${port}`);
assert.equal(await page.getByLabel('Label').inputValue(), 'Test Server');
await page.getByLabel('Username').fill(name);
await page.getByRole('button', { name: 'Save and connect' }).click();
await page.getByText(/Connected/).first().waitFor();
await page.locator('.sidebar').getByText('bob').waitFor();
console.log('ok: connected through the proxy, sees bob');
// Text both ways
const got = new Promise<string>(res => bob.on('text', m => res(m.html)));
await page.getByLabel('Message').fill('hello from the browser');
await page.keyboard.press('Enter');
assert.match(await within(got, 'text from the page'), /hello from the browser/);
bob.sendText({ channels: [0] }, 'hello browser');
await page.locator('.msg', { hasText: 'hello browser' }).waitFor();
console.log('ok: text in both directions');
// Voice goes through the TCP tunnel
const session = () => [...bob.users.values()].find(u => u.name === name)!.session;
const stream = new Promise<VoicePacket[]>(res => {
const packets: VoicePacket[] = [];
const off = bob.on('voice', raw => {
const p = decodeVoice(raw);
if (!p || p.session !== session()) return;
packets.push(p);
if (packets.length >= 20) { off(); res(packets); }
});
});
await page.getByTitle('Settings').click();
await page.getByRole('radio', { name: /Always on/ }).click();
await page.getByRole('button', { name: 'Done', exact: true }).click();
const packets = await within(stream, 'voice packets from the page', 20000);
assert.ok(packets.every(p => p.opus.length > 0));
console.log(`ok: page sends Opus voice through the proxy (${packets[0].opus.length} bytes per packet)`);
await page.locator('.me').getByRole('button', { name: 'Server information' }).click();
await page.getByRole('dialog', { name: 'Server information' }).getByText('Through the TCP connection').waitFor();
await page.getByRole('dialog', { name: 'Server information' }).getByRole('button', { name: 'Close', exact: true }).last().click();
console.log('ok: server information shows voice over TCP');
// Own information: with the proxy on this machine the server sees our own address, shown as such
await page.locator('.sidebar .row.user.self').click({ button: 'right' });
await page.getByRole('menuitem', { name: 'Information' }).click();
const own = page.getByRole('dialog', { name: /^Information:/ });
await own.getByText('Address', { exact: true }).waitFor();
assert.equal(await own.getByText('Seen by the server').count(), 0);
await own.getByRole('button', { name: 'Close', exact: true }).last().click();
console.log('ok: own information shows the address the server sees');
// The identity survives a reload and the server recognizes the same certificate
const hash = () => [...bob.users.values()].find(u => u.name === name)?.hash;
const before = hash();
assert.ok(before, 'bob sees the certificate hash');
await page.reload();
await page.locator('.rail .tile[title^="Test Server"]').click();
await page.getByText(/Connected/).first().waitFor();
const deadline = Date.now() + 5000;
while (hash() !== before && Date.now() < deadline) await new Promise(r => setTimeout(r, 100));
assert.equal(hash(), before, 'same certificate after a reload');
console.log('ok: identity kept across a reload');
// Screen sharing between two browsers: the proxy's own STUN is used without any setting
assert.equal((await (await fetch(`http://127.0.0.1:${proxyPort}/api/config`)).json()).stun, proxyPort + 1000);
second = await browser();
const page2 = await second.firstWindow();
page2.on('console', m => { if (m.type() === 'error') console.log('[page2]', m.text()); });
await page2.setViewportSize({ width: 1280, height: 800 });
await page2.getByRole('button', { name: /Create a new identity/ }).click();
const carol = `webcarol${Date.now() % 100000}`;
// This browser is made to use the built-in Opus codec, as one without WebCodecs audio would
await page2.evaluate(() => localStorage.setItem('mumh5.forceWasmOpus', '1'));
await page2.getByLabel('Name', { exact: true }).fill(carol);
await page2.getByRole('button', { name: 'Create', exact: true }).click();
await page2.getByRole('button', { name: 'Skip for now' }).click();
await page2.getByRole('button', { name: 'Done' }).click();
await page2.getByTitle('Add a server').click();
await page2.getByRole('button', { name: 'Save and connect' }).click();
await page2.getByText(/Connected/).first().waitFor();
// Talking with the built-in codec: its packets are real Opus, checked by decoding them here
const carolSession = () => [...bob.users.values()].find(u => u.name === carol)!.session;
const carolVoice = new Promise<VoicePacket[]>(res => {
const packets: VoicePacket[] = [];
const off = bob.on('voice', raw => {
const p = decodeVoice(raw);
if (!p || p.session !== carolSession() || !p.opus.length) return;
packets.push(p);
if (packets.length >= 20) { off(); res(packets); }
});
});
await page2.getByTitle('Settings').click();
await page2.getByRole('radio', { name: /Always on/ }).click();
await page2.getByRole('button', { name: 'Done', exact: true }).click();
const fromCarol = await within(carolVoice, 'voice from the built-in codec', 20000);
const opus = await instantiateOpus(readFileSync(path.join(root, 'node_modules/@evan/opus/wasm/opus.wasm')));
const check = new WasmDecoder(opus);
const decoded = fromCarol.map(p => check.decode(p.opus));
assert.ok(decoded.every(pcm => pcm && pcm.length === 960), 'every packet is 20 ms of decodable Opus');
assert.ok(decoded.some(pcm => pcm!.some(v => Math.abs(v) > 0.01)), 'and carries the test tone, not silence');
// Hearing: her own packets sent back as bob light him up, and no codec error shows
const back = fromCarol.map((p, i) => encodeVoice({ target: 0, frame: 9000 + i * 2, opus: p.opus, last: i === fromCarol.length - 1 }, bob.protobufVoice));
const pump = setInterval(() => { const pk = back.shift(); if (pk) bob.sendVoiceTunnel(pk); }, 20);
await page2.locator('.sidebar .row.user', { hasText: 'bob' }).locator('.label.talking').waitFor({ timeout: 5000 });
clearInterval(pump);
assert.equal(await page2.getByText('Voice is not working').count(), 0);
console.log(`ok: voice with the built-in Opus codec (${fromCarol[0].opus.length} bytes per packet)`);
await page.locator('.me').getByRole('button', { name: 'Share your screen' }).click();
await page.getByRole('button', { name: 'Continue' }).click();
const dialog = page.getByRole('dialog', { name: 'Share your screen' });
await dialog.getByRole('button', { name: 'Choose a screen or window...' }).click();
await page.waitForFunction(() => (document.querySelector('.preview video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 60000 });
await dialog.getByRole('button', { name: 'Start sharing' }).click();
await page.locator('.stage .status', { hasText: '0 watching' }).waitFor();
// The viewer is limited to what a browser without direct UDP has (Vanadium's default): only
// the proxy's relay, reached over TCP
await page2.evaluate(() => localStorage.setItem('mumh5.iceDebug', 'relay-tcp'));
await page2.locator('.stage .tile', { hasText: name }).click();
await page2.getByRole('button', { name: 'Continue' }).click();
await page2.waitForFunction(() => (document.querySelector('.spot video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 30000 });
await page.locator('.stage .status', { hasText: '1 watching' }).waitFor();
// Phone-sized window: the picture still has room and frames
await page2.setViewportSize({ width: 390, height: 780 });
await page2.waitForFunction(() => {
const v = document.querySelector('.spot video') as HTMLVideoElement | null;
return !!v && v.videoWidth > 0 && !v.paused && v.getBoundingClientRect().height > 100 && v.getBoundingClientRect().width > 300;
}, null, { timeout: 15000 });
if (process.env.SHOTS_DIR) await page2.screenshot({ path: `${process.env.SHOTS_DIR}/phone.png` });
// The statistics confirm which way the stream came
await page2.setViewportSize({ width: 1280, height: 800 });
await page2.locator('.spot video').click({ button: 'right' });
await page2.getByRole('menuitem', { name: 'Stats for nerds' }).click();
const stats = page2.getByRole('status', { name: 'Stream statistics' });
await stats.getByText(/^(direct|relayed)/).first().waitFor({ timeout: 8000 });
const route = (await stats.innerText()).replace(/\n+/g, ' | ');
assert.match(route, /relayed \(TURN\), reached over TCP/, route);
assert.match(route, /0 direct, 0 through STUN, [1-9]\d* relayed/, route);
await stats.getByText(/^\d+ kbit\/s$/).first().waitFor({ timeout: 8000 });
console.log('ok: screen sharing to a browser without direct UDP, through the relay over TCP');
// A camera instead, still watched through the relay over TCP
await page.locator('.me').getByRole('button', { name: 'Stop sharing your screen' }).click();
await page2.getByRole('region', { name: 'Screen share' }).waitFor({ state: 'detached' });
await page.locator('.me').getByRole('button', { name: 'Share your screen or camera' }).click();
await dialog.getByRole('tab', { name: 'Camera' }).click();
await page.waitForFunction(() => (document.querySelector('.preview video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 30000 });
await dialog.getByRole('button', { name: 'Start sharing' }).click();
await page2.locator('.sidebar').getByRole('button', { name: `Watch the camera of ${name}`, exact: true }).click();
await page2.waitForFunction(() => (document.querySelector('.stage .tile video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 30000 });
await page.locator('.stage .status', { hasText: '1 watching' }).waitFor();
console.log('ok: camera shared between two browsers, shown in the tile, through the relay');
console.log('WEB E2E PASSED');
} finally {
bob.disconnect();
await app.close().catch(() => {});
await second?.close().catch(() => {});
proxy.kill();
}
+54
View File
@@ -0,0 +1,54 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { instantiateOpus, WasmEncoder, WasmDecoder } from '../src/lib/audio/opus-wasm.ts';
const opus = await instantiateOpus(readFileSync(new URL('../node_modules/@evan/opus/wasm/opus.wasm', import.meta.url)));
// One second of a 440 Hz tone in the engine's 10 ms frames
const tone = () => Array.from({ length: 100 }, (_, f) => Float32Array.from({ length: 480 }, (_, i) => 0.5 * Math.sin(2 * Math.PI * 440 * (f * 480 + i) / 48000)));
for (const frameMs of [10, 20, 40, 60]) {
test(`a tone survives encoding and decoding with ${frameMs} ms packets`, () => {
const packets: Uint8Array[] = [];
const encoder = new WasmEncoder(opus, 40000, frameMs, p => packets.push(p));
for (const frame of tone()) encoder.push(frame);
assert.equal(packets.length, Math.floor(1000 / frameMs), 'one packet per frame duration');
// Around the asked bitrate, not raw audio
const bytes = packets.reduce((n, p) => n + p.length, 0);
assert.ok(bytes > 2000 && bytes < 8000, `about 40 kbit/s: ${bytes} bytes for one second`);
const decoder = new WasmDecoder(opus);
const out = packets.map(p => decoder.decode(p)!);
assert.ok(out.every(pcm => pcm.length === 48 * frameMs), 'each packet decodes to its duration');
// The tone is still there: count the zero crossings in the second half (the codec needs a moment to settle)
const all = Float32Array.from(out.slice(out.length / 2).flatMap(pcm => [...pcm]));
let crossings = 0;
for (let i = 1; i < all.length; i++) if ((all[i - 1] < 0) !== (all[i] < 0)) crossings++;
const hz = crossings / 2 / (all.length / 48000);
assert.ok(Math.abs(hz - 440) < 15, `decoded tone at ${hz.toFixed(1)} Hz`);
const peak = all.reduce((m, v) => Math.max(m, Math.abs(v)), 0);
assert.ok(peak > 0.3 && peak < 0.8, `level kept: ${peak.toFixed(2)}`);
encoder.close();
decoder.close();
});
}
test('an unfinished frame is dropped at the end, and garbage does not decode', () => {
const packets: Uint8Array[] = [];
const encoder = new WasmEncoder(opus, 40000, 20, p => packets.push(p));
encoder.push(tone()[0]);
assert.equal(packets.length, 0);
encoder.flush();
encoder.push(tone()[0]);
assert.equal(packets.length, 0, 'the half frame before the flush is gone');
encoder.push(tone()[1]);
assert.equal(packets.length, 1);
const decoder = new WasmDecoder(opus);
assert.equal(decoder.decode(new Uint8Array(5000)), null);
assert.equal(decoder.decode(Uint8Array.of()), null);
encoder.close();
encoder.push(tone()[0]);
decoder.close();
assert.equal(decoder.decode(packets[0]), null);
});
+47
View File
@@ -0,0 +1,47 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { audioApps, planLinks, VIRTUAL_MIC } from '../electron/pipewire.ts';
// A cut-down pw-dump: the virtual microphone (10), this app (20, process 500), a browser whose
// stream has no name of its own (30, named by its client), a mono game (40) and a music player (50)
const node = (id: number, props: Record<string, unknown>) => ({ id, type: 'PipeWire:Interface:Node', info: { props } });
const client = (id: number, name: string, pid: number) => ({ id, type: 'PipeWire:Interface:Client', info: { props: { 'application.name': name, 'application.process.id': pid } } });
const port = (id: number, nodeId: number, direction: string, channel: string) => ({ id, type: 'PipeWire:Interface:Port', info: { direction, props: { 'node.id': nodeId, 'audio.channel': channel } } });
const link = (id: number, outPort: number, inPort: number, inNode: number) =>
({ id, type: 'PipeWire:Interface:Link', info: { 'output-port-id': outPort, 'input-port-id': inPort, 'input-node-id': inNode } as any });
const graph = [
node(10, { 'node.name': VIRTUAL_MIC, 'media.class': 'Audio/Source/Virtual' }), port(11, 10, 'input', 'FL'), port(12, 10, 'input', 'FR'),
port(13, 10, 'output', 'FL'), port(14, 10, 'output', 'FR'),
client(2, 'mumh5', 500), node(20, { 'media.class': 'Stream/Output/Audio', 'node.name': 'mumh5', 'client.id': 2 }), port(21, 20, 'output', 'FL'), port(22, 20, 'output', 'FR'),
client(3, 'Firefox', 600), node(30, { 'media.class': 'Stream/Output/Audio', 'node.name': 'Firefox', 'client.id': 3 }), port(31, 30, 'output', 'FL'), port(32, 30, 'output', 'FR'),
node(40, { 'media.class': 'Stream/Output/Audio', 'application.name': 'Game', 'application.process.id': 700 }), port(41, 40, 'output', 'MONO'),
node(50, { 'media.class': 'Stream/Output/Audio', 'application.name': 'Spotify' }), port(51, 50, 'output', 'FL'), port(52, 50, 'output', 'FR'), port(53, 50, 'output', 'LFE'),
node(60, { 'media.class': 'Audio/Sink', 'node.name': 'speakers' })
];
test('lists programs that play sound, without this app', () => {
assert.deepEqual(audioApps(graph, [500]), ['Firefox', 'Game', 'Spotify']);
});
test('whole system links every program but this app', () => {
const { add, remove } = planLinks(graph, { kind: 'all' }, [500]);
assert.deepEqual(add.sort(), [[31, 11], [32, 12], [41, 11], [41, 12], [51, 11], [52, 12]].sort());
assert.deepEqual(remove, []);
});
test('one program links only that program', () => {
assert.deepEqual(planLinks(graph, { kind: 'app', name: 'Firefox' }, [500]).add, [[31, 11], [32, 12]]);
});
test('existing links are kept, stale ones removed', () => {
const linked = [...graph, link(90, 31, 11, 10), link(91, 51, 11, 10), link(92, 21, 60, 60)];
const { add, remove } = planLinks(linked, { kind: 'app', name: 'Firefox' }, [500]);
assert.deepEqual(add, [[32, 12]]);
// Spotify's link goes; the link to the speakers is none of our business
assert.deepEqual(remove, [91]);
});
test('nothing without the virtual microphone', () => {
assert.deepEqual(planLinks(graph.slice(5), { kind: 'all' }, []), { add: [], remove: [] });
});
+252
View File
@@ -0,0 +1,252 @@
// 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 dgram from 'node:dgram';
import assert from 'node:assert/strict';
import { proxyLine } from '../electron/tls-transport.ts';
import { stunResponse } from '../server/proxy.ts';
import { configFromEnv } from '../server/proxy.ts';
import { startProxy, defaults, parseServers, isPrivateAddress, type ProxyConfig } from '../server/proxy.ts';
import { WebSocket as WsClient } from 'ws';
import { WebSocketTransport } from '../src/core/ws-transport.ts';
import { createCodec } from '../src/core/proto.ts';
import { MumbleClient } from '../src/core/client.ts';
const target = process.env.MUMBLE_TEST_HOST;
async function withProxy(config: Partial<ProxyConfig>, fn: (base: string) => Promise<void>): Promise<void> {
const proxy = await startProxy({ ...defaults, port: 0, origins: ['*'], ...config });
try { await fn(`http://127.0.0.1:${proxy.port}`); } finally { await proxy.close(); }
}
const post = (base: string, route: string, body: unknown, headers: Record<string, string> = {}) =>
fetch(`${base}/api/${route}`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(body) });
test('server list parsing', () => {
assert.deepEqual(parseServers('Mumble.Example.com, voice.example.org:1234=Friends ,[::1]:5=Local'), [
{ host: 'mumble.example.com', port: 64738, label: 'mumble.example.com' },
{ host: 'voice.example.org', port: 1234, label: 'Friends' },
{ host: '::1', port: 5, label: 'Local' }
]);
assert.deepEqual(parseServers(''), []);
});
test('empty environment variables fall back to the defaults', () => {
const c = configFromEnv({ MUMH5_PORT: '', MUMH5_BIND: '', MUMH5_STUN_PORT: '', MUMH5_STUN_BIND: '', MUMH5_MAX_CONNECTIONS: '', MUMH5_SERVERS: 'a.example', MUMH5_ALLOW_ANY: '' });
assert.deepEqual([c.port, c.bind, c.stunPort, c.stunBind, c.maxConnections, c.allowAny], [8080, '127.0.0.1', 3478, '::', 200, false]);
assert.equal(configFromEnv({ MUMH5_STUN_PORT: '0' }).stunPort, null);
assert.equal(configFromEnv({ MUMH5_PORT: '8089' }).port, 8089);
});
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, stun: null, turn: 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);
});
});
// A binding request: type, length 0, magic cookie, 12 byte transaction id
const stunRequest = () => Uint8Array.from([0, 1, 0, 0, 0x21, 0x12, 0xa4, 0x42, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
test('STUN answers carry the sender address, masked as the protocol asks', () => {
const v4 = stunResponse(stunRequest(), '203.0.113.7', 54321)!;
assert.deepEqual([...v4.subarray(0, 4)], [1, 1, 0, 12]);
assert.deepEqual([...v4.subarray(4, 20)], [...stunRequest().subarray(4, 20)]);
assert.deepEqual([...v4.subarray(20, 26)], [0, 0x20, 0, 8, 0, 1]);
assert.equal(((v4[26] << 8) | v4[27]) ^ 0x2112, 54321);
assert.deepEqual([...v4.subarray(28)].map((b, i) => b ^ [0x21, 0x12, 0xa4, 0x42][i]), [203, 0, 113, 7]);
// An IPv4 sender seen through an IPv6 socket is still IPv4
assert.deepEqual([...stunResponse(stunRequest(), '::ffff:203.0.113.7', 54321)!], [...v4]);
const v6 = stunResponse(stunRequest(), '2001:db8::7', 1)!;
assert.equal(v6[25], 2);
const mask = stunRequest().subarray(4, 20);
assert.deepEqual([...v6.subarray(28)].map((b, i) => b ^ mask[i]), [0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 7]);
// Not a binding request
assert.equal(stunResponse(new Uint8Array(20), '203.0.113.7', 1), null);
assert.equal(stunResponse(stunRequest().subarray(0, 12), '203.0.113.7', 1), null);
});
test('the proxy answers STUN over UDP and announces the port', async () => {
const proxy = await startProxy({ ...defaults, port: 0, origins: ['*'], servers: parseServers('voice.example.org'), stunPort: 0, stunBind: '127.0.0.1' });
try {
assert.ok(proxy.stunPort);
assert.equal((await (await fetch(`http://127.0.0.1:${proxy.port}/api/config`)).json()).stun, proxy.stunPort);
const client = dgram.createSocket('udp4');
const answer = await new Promise<Buffer>((resolve, reject) => {
client.once('message', resolve);
client.once('error', reject);
client.send(stunRequest(), proxy.stunPort!, '127.0.0.1');
setTimeout(() => reject(new Error('no STUN answer')), 3000);
});
assert.equal(((answer[26] << 8) | answer[27]) ^ 0x2112, client.address().port);
// Relay credentials for people on this site: a username that expires and its password
const base = `http://127.0.0.1:${proxy.port}`;
assert.equal((await (await fetch(`${base}/api/config`)).json()).turn, true);
// What clients need for screen sharing: the port, and relay credentials that expire.
// Readable from any origin, since the desktop app asks from outside this site.
const res = await fetch(`${base}/api/relay`, { headers: { Origin: 'app://mumh5' } });
assert.equal(res.headers.get('access-control-allow-origin'), '*');
const cred = await res.json();
assert.ok(Number(cred.username) > Date.now() / 1000 && cred.credential.length > 20 && cred.port === proxy.stunPort);
assert.deepEqual([...answer.subarray(28)].map((b, i) => b ^ [0x21, 0x12, 0xa4, 0x42][i]), [127, 0, 0, 1]);
client.close();
} finally {
await proxy.close();
}
});
test('requests from other origins are refused', async () => {
await withProxy({ servers: parseServers('voice.example.org'), origins: [] }, async base => {
const cross = await post(base, 'identity/create', { name: 'x' }, { Origin: 'https://evil.example' });
assert.equal(cross.status, 403);
const same = await post(base, 'certs/describe', { ders: [] }, { Origin: base });
assert.equal(same.status, 200);
});
});
test('static files are served without leaving the folder', async () => {
await withProxy({ servers: parseServers('voice.example.org'), staticDir: 'test' }, async base => {
const ok = await fetch(`${base}/proxy.test.ts`);
assert.equal(ok.status, 200);
assert.equal((await fetch(`${base}/..%2Fpackage.json`)).status, 404);
assert.equal((await fetch(`${base}/%2e%2e/package.json`)).status, 404);
});
});
test('behind a reverse proxy, limits count the forwarded client address', async () => {
await withProxy({ servers: parseServers('voice.example.org'), trustProxy: true, maxPerAddress: 1 }, async base => {
const open = (forwarded: string) => new Promise<string>(resolve => {
const ws = new WsClient(`${base.replace(/^http/, 'ws')}/api/connect`, { headers: { 'X-Forwarded-For': forwarded } });
ws.on('open', () => resolve('open'));
ws.on('unexpected-response', (_req, res) => resolve(String(res.statusCode)));
ws.on('error', () => resolve('error'));
});
assert.equal(await open('203.0.113.1'), 'open');
assert.equal(await open('203.0.113.2'), 'open');
// A made-up first entry does not hide the address the reverse proxy saw
assert.equal(await open('198.51.100.9, 203.0.113.1'), '503');
});
});
test('PROXY protocol lines', () => {
assert.equal(proxyLine('203.0.113.7', 40000, '127.0.0.1', 64738), 'PROXY TCP4 203.0.113.7 127.0.0.1 40000 64738\r\n');
assert.equal(proxyLine('::ffff:203.0.113.7', 40000, '::ffff:10.0.0.2', 64738), 'PROXY TCP4 203.0.113.7 10.0.0.2 40000 64738\r\n');
assert.equal(proxyLine('2001:db8::7', 40000, '::1', 64738), 'PROXY TCP6 2001:db8::7 ::1 40000 64738\r\n');
assert.equal(proxyLine('203.0.113.7', 40000, '::1', 64738), 'PROXY TCP6 ::ffff:203.0.113.7 ::1 40000 64738\r\n');
assert.equal(proxyLine('2001:db8::7', 40000, '127.0.0.1', 64738), 'PROXY UNKNOWN\r\n');
});
test('the client address is announced to the server before TLS when asked to', async () => {
// Stands in for go-mmproxy: reads the first bytes of the connection
let gotFirst!: (s: string) => void;
const first = new Promise<string>(resolve => { gotFirst = resolve; });
const upstream = net.createServer(s => s.once('data', d => { gotFirst(d.toString('latin1')); s.destroy(); }));
await new Promise<void>(resolve => upstream.listen(0, '127.0.0.1', resolve));
const port = (upstream.address() as net.AddressInfo).port;
try {
await withProxy({ servers: parseServers(`127.0.0.1:${port}`), sendProxy: true, trustProxy: true }, async base => {
const id = await identity(base, 'a');
const ws = new WsClient(wsUrl(base), { headers: { 'X-Forwarded-For': '203.0.113.7' } });
ws.on('open', () => ws.send(JSON.stringify({ host: '127.0.0.1', port, ...id })));
ws.on('error', () => {});
assert.match(await first, new RegExp(`^PROXY TCP4 203\\.0\\.113\\.7 127\\.0\\.0\\.1 \\d+ ${port}\\r\\n`));
ws.terminate();
});
} finally {
upstream.close();
}
});
const identity = async (base: string, name: string) => (await post(base, 'identity/create', { name })).json() as Promise<{ certPem: string; keyPem: string }>;
const wsUrl = (base: string) => `${base.replace(/^http/, 'ws')}/api/connect`;
test('servers off the allowlist are refused', async () => {
await withProxy({ servers: parseServers('voice.example.org') }, async base => {
const t = new WebSocketTransport(wsUrl(base), { host: '127.0.0.1', port: 64738 }, await identity(base, 'a'));
await assert.rejects(t.secure, /does not allow connecting to 127\.0\.0\.1:64738/);
});
});
test('private addresses are refused when any server is allowed', async () => {
await withProxy({ allowAny: true }, async base => {
const id = await identity(base, 'a');
await assert.rejects(new WebSocketTransport(wsUrl(base), { host: '127.0.0.1', port: 64738 }, id).secure, /private addresses/);
await assert.rejects(new WebSocketTransport(wsUrl(base), { host: 'localhost', port: 64738 }, id).secure, /private addresses/);
});
});
test('a listed server on a private address is reachable when any server is allowed', async () => {
// Refuses the TLS handshake, which is enough to see the connection was attempted
const local = net.createServer(s => s.destroy());
await new Promise<void>(resolve => local.listen(0, '127.0.0.1', resolve));
const port = (local.address() as net.AddressInfo).port;
try {
await withProxy({ allowAny: true, servers: parseServers(`127.0.0.1:${port},localhost:${port}`) }, async base => {
const id = await identity(base, 'a');
for (const host of ['127.0.0.1', 'localhost']) {
const err = await new WebSocketTransport(wsUrl(base), { host, port }, id).secure.then(() => null, e => e as Error);
assert.ok(err && !/private addresses|does not allow/.test(err.message), `${host}: ${err?.message}`);
}
// Other private addresses stay blocked
await assert.rejects(new WebSocketTransport(wsUrl(base), { host: '127.0.0.1', port: port + 1 }, id).secure, /private addresses/);
});
} finally {
local.close();
}
});
test('two clients talk through the proxy', { skip: !target }, async () => {
const [host, port = '64738'] = target!.split(':');
await withProxy({ servers: parseServers(target!) }, async base => {
const connect = async (name: string) => {
const transport = new WebSocketTransport(wsUrl(base), { host, port: Number(port) }, await identity(base, name));
const info = await transport.secure;
assert.match(info.fingerprint, /^([0-9A-F]{2}:){31}[0-9A-F]{2}$/);
assert.ok(info.chain.length >= 1);
assert.equal(info.clientAddress, '127.0.0.1');
const client = new MumbleClient(createCodec());
client.connect(transport, { username: name, os: 'test' });
await new Promise<void>((resolve, reject) => { client.on('synced', () => resolve()); client.on('close', reject); });
return client;
};
const a = await connect(`proxy-a-${Date.now() % 100000}`);
const b = await connect(`proxy-b-${Date.now() % 100000}`);
const got = new Promise<string>(resolve => b.on('text', m => resolve(m.html)));
// A direct message, so other test files listening in the root channel are not disturbed
a.sendText({ users: [b.session!] }, '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();
});
});
+42
View File
@@ -0,0 +1,42 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { SHARE, encodeShare, ShareAssembler } from '../src/core/share-signal.ts';
test('bare signals are one byte', async () => {
const packets = await encodeShare(SHARE.on);
assert.deepEqual(packets, [new Uint8Array([SHARE.on])]);
assert.deepEqual(await new ShareAssembler().push(1, packets[0]), { type: SHARE.on });
});
test('session descriptions survive chunking, in any order', async () => {
// Random-looking text compresses badly, forcing several chunks
const sdp = Array.from({ length: 400 }, (_, i) => `a=candidate:${(i * 2654435761 >>> 0).toString(36)} 1 udp ${i} 2001:db8::${i} typ host`).join('\r\n');
const packets = await encodeShare(SHARE.offer, sdp);
assert.ok(packets.length > 1, 'needs more than one packet');
// Murmur drops plugin data over 1000 bytes
assert.ok(packets.every(p => p.length <= 1000));
const assembler = new ShareAssembler();
let result = null;
for (const p of [...packets].reverse()) result = await assembler.push(7, p);
assert.deepEqual(result, { type: SHARE.offer, text: sdp });
});
test('chunks from different senders do not mix', async () => {
const a = await encodeShare(SHARE.answer, Array.from({ length: 400 }, (_, i) => (i * 40503 * 2654435761 >>> 0).toString(36)).join(' '));
assert.ok(a.length > 1);
const assembler = new ShareAssembler();
assert.equal(await assembler.push(1, a[0]), null);
for (const p of a.slice(1)) assert.equal(await assembler.push(2, p), null);
let result = null;
for (const p of a.slice(1)) result = await assembler.push(1, p);
assert.equal(result?.type, SHARE.answer);
});
test('garbage is ignored', async () => {
const assembler = new ShareAssembler();
assert.equal(await assembler.push(1, new Uint8Array([99])), null);
assert.equal(await assembler.push(1, new Uint8Array([SHARE.offer, 1])), null);
assert.equal(await assembler.push(1, new Uint8Array([SHARE.offer, 1, 5, 2, 1, 2, 3])), null);
// A complete transfer that is not valid compressed data
assert.equal(await assembler.push(1, new Uint8Array([SHARE.offer, 1, 0, 1, 255, 255, 255, 255])), null);
});
+84
View File
@@ -0,0 +1,84 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { TEXT, TEXT_MARK, HAND_OVER, MAX_HTML, TextAssembler, encodeText, encodeHistory, parseMsg, parseHistory, mergeMsgs, isTextChannel, markText, stripMark, newId, type TextMsg } from '../src/core/text-signal.ts';
const msg = (i: string, t: number, h = 'hi'): TextMsg => ({ i, c: 3, t, n: 'ann', k: 'abc', h });
test('the marker turns a description into a text channel and comes off again', () => {
assert.equal(isTextChannel('<b>Rules</b>'), false);
const marked = markText('<b>Rules</b>', true);
assert.equal(marked, TEXT_MARK + '<b>Rules</b>');
assert.equal(isTextChannel(marked), true);
// Marking twice leaves one marker
assert.equal(markText(marked, true), marked);
assert.equal(markText(marked, false), '<b>Rules</b>');
assert.equal(stripMark(marked), '<b>Rules</b>');
});
test('a message survives the trip, in packets Murmur accepts', async () => {
const body = { i: newId(), c: 7, h: '<b>hello</b> ' + 'x'.repeat(3000) };
const packets = await encodeText(TEXT.msg, body);
assert.ok(packets.every(p => p.length <= 1000));
const assembler = new TextAssembler();
let signal = null;
for (const p of packets) signal = await assembler.push(1, p);
assert.equal(signal?.type, TEXT.msg);
assert.deepEqual(parseMsg(signal?.text), body);
});
test('a short message is one packet', async () => {
assert.equal((await encodeText(TEXT.msg, { i: newId(), c: 1, h: 'How is everyone doing today?' })).length, 1);
});
test('malformed messages are refused', () => {
assert.equal(parseMsg('nope'), null);
assert.equal(parseMsg('[]'), null);
assert.equal(parseMsg(JSON.stringify({ i: 'a', c: -1, h: 'x' })), null);
assert.equal(parseMsg(JSON.stringify({ i: 'a', c: 1, h: '' })), null);
assert.equal(parseMsg(JSON.stringify({ i: '', c: 1, h: 'x' })), null);
assert.equal(parseMsg(JSON.stringify({ i: 'a', c: 1, h: 'x'.repeat(MAX_HTML + 1) })), null);
});
test('history keeps valid entries, limits their number and pulls future times back', () => {
const now = 1_000_000;
const m = [
{ i: 'a', t: 500, n: 'ann', k: 'h1', h: 'one' },
{ i: 'b', t: now + 99999, n: 'bob', k: '', h: 'two' },
{ i: 'c', t: 'soon', n: 'eve', k: '', h: 'bad time' },
{ i: 'd', t: 600, n: 'eve', k: '', h: '' },
null
];
assert.deepEqual(parseHistory(JSON.stringify({ c: 4, m }), now), [
{ i: 'a', c: 4, t: 500, n: 'ann', k: 'h1', h: 'one' },
{ i: 'b', c: 4, t: now, n: 'bob', k: '', h: 'two' }
]);
const many = Array.from({ length: HAND_OVER + 30 }, (_, i) => ({ i: `m${i}`, t: i, n: 'ann', k: '', h: 'x' }));
assert.equal(parseHistory(JSON.stringify({ c: 4, m: many }), now).length, HAND_OVER);
assert.deepEqual(parseHistory('{"c":4}', now), []);
});
test('merging adds only unknown messages, in order of time, and keeps the newest', () => {
const have = [msg('a', 10), msg('c', 30)];
const { all, added } = mergeMsgs(have, [msg('b', 20), msg('a', 10), msg('d', 40)]);
assert.deepEqual(all.map(m => m.i), ['a', 'b', 'c', 'd']);
assert.deepEqual(added.map(m => m.i), ['b', 'd']);
assert.equal(mergeMsgs(have, [msg('a', 10)]).added.length, 0);
// Over the limit the oldest go, and an old message that does not make it is not reported as added
const kept = mergeMsgs([msg('x', 50), msg('y', 60)], [msg('old', 1), msg('z', 70)], 2);
assert.deepEqual(kept.all.map(m => m.i), ['y', 'z']);
assert.deepEqual(kept.added.map(m => m.i), ['z']);
});
test('history that is too large for one signal is cut down to the newest messages', async () => {
// Incompressible text, so 50 messages do not fit
const noise = (seed: number) => Array.from({ length: 1500 }, (_, i) => ((i + 1) * (seed + 7) * 2654435761 >>> 0).toString(36)).join('');
const msgs = Array.from({ length: 50 }, (_, i) => msg(`m${i}`, i, noise(i)));
const packets = await encodeHistory(3, msgs);
assert.ok(packets.length > 0 && packets.length <= 32);
const assembler = new TextAssembler();
let signal = null;
for (const p of packets) signal = await assembler.push(1, p);
const back = parseHistory(signal?.text);
assert.ok(back.length > 0 && back.length < 50);
assert.equal(back[back.length - 1].i, 'm49');
});

Some files were not shown because too many files have changed in this diff Show More