Initial commit: mumh5, a modern Mumble client
Electron desktop app with a Svelte 5 interface for any Mumble server. - Mumble protocol core: TLS, handshake, channels, users, text, plugin data, client-side pacing of Murmur's rate limits - Voice: WebCodecs Opus over the TCP tunnel, voice activity, push to talk, always-on, devices, per-user volume and local mute - Several servers at once, voice on one; server rail with icons and ordering - Chat: channels, direct messages, side chat, file sharing through f0ckm, inline images without it, click-to-play YouTube - Profiles with rich descriptions, registration, rename, nicknames, connection information and moderation menus - Identity wizard, multiple identities, PKCS#12 import/export, desktop Mumble certificate import, certificate pinning and viewer - Tray icon with voice state - Unit, server and end-to-end tests against a real Murmur Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
dist-web/
|
||||||
|
dist-electron/
|
||||||
|
release/
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
## Style rules
|
||||||
|
|
||||||
|
- No rounded corners anywhere. No `border-radius` (a global `border-radius: 0 !important` backs this up in `src/app.css`).
|
||||||
|
- No emojis in code, UI text, comments, commit messages or docs. Emoji inside chat content from users is fine.
|
||||||
|
- UI copy is short, plain and honest. Don't claim something works if it is not tested. Explanations go in tooltips or help text, not in big callouts.
|
||||||
|
- Colors come from the CSS variables in `src/app.css`; icons from `src/lib/icons.ts` via `<Icon>`.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
File sharing uploads to a f0ckm instance (`../f0ckm`, `src/chat_upload_handler.mjs` there) with an upload-only key; links are posted in chat.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # Vite + Electron, restarts Electron when main/preload change
|
||||||
|
npm run check # svelte-check; must report 0 errors and 0 warnings
|
||||||
|
npm test # unit tests; server tests too when MUMBLE_TEST_HOST is set
|
||||||
|
npm run build # renderer to dist/, main+preload to dist-electron/ (esbuild)
|
||||||
|
npm run test:e2e # drives the built app (needs npm run build first)
|
||||||
|
npm run screenshots # regenerates docs/screenshots (see test/e2e/screenshots.ts)
|
||||||
|
npm run proto # regenerate src/core/mumble-pb.js and mumble-udp-pb.js from proto/
|
||||||
|
```
|
||||||
|
|
||||||
|
Test server (autoban off because tests reconnect a lot; SuperUser for channel setup):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d --name mumh5-test-murmur -p 64739:64738 -p 64739:64738/udp \
|
||||||
|
-e MUMBLE_CONFIG_AUTOBANATTEMPTS=0 -e MUMBLE_SUPERUSER_PASSWORD=testsuper mumblevoip/mumble-server
|
||||||
|
MUMBLE_TEST_HOST=localhost:64739 MUMBLE_SUPERUSER_PASSWORD=testsuper npm run test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
A reused test server keeps registrations and channels from earlier runs; tests must use unique names or clean up (the E2E removes its channels first).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- `electron/` (Node, main process): window, TLS sockets to Mumble servers (`tls-transport.ts`), 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).
|
||||||
|
- `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/ui/`: Svelte components. `App.svelte` owns layout and global dialogs (`ui.svelte.ts` store).
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
- 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.
|
||||||
|
- 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`).
|
||||||
|
|
||||||
|
## Svelte reactivity pitfalls (all hit before)
|
||||||
|
|
||||||
|
- `$state` wraps assigned objects in proxies: `this.server !== server` compares proxy to raw object and is always true. Use ids or counters.
|
||||||
|
- `(obj[key] ??= []).push(x)` pushes to the raw array; assign first, then push through `obj[key]`.
|
||||||
|
- The Mumble client mutates its objects in place. Session getters return shallow copies and read `tick`, otherwise views don't update.
|
||||||
|
|
||||||
|
## Mumble protocol pitfalls (all hit before)
|
||||||
|
|
||||||
|
- Messages or comments longer than the server's text limit (5000) are parsed by Murmur as XML; anything not well-formed is denied as TextTooLong. Always emit XHTML (`<br/>`, `<img .../>`, `toMumbleHtml`).
|
||||||
|
- 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.
|
||||||
|
- Long comments and descriptions arrive as a hash only; a new hash invalidates the old text; fetch with RequestBlob.
|
||||||
|
- Renaming while connected is not possible; mumh5 reconnects with the new name.
|
||||||
|
|
||||||
|
## Running the app in this environment
|
||||||
|
|
||||||
|
- The shell has `ELECTRON_RUN_AS_NODE=1` set; strip it when launching Electron (the dev script and tests do).
|
||||||
|
- The desktop is Wayland and Electron picks it automatically, so windows appear on the user's screen even with `DISPLAY=:99`. For hidden runs: `Xvfb :99`, `--ozone-platform=x11`, and unset `WAYLAND_DISPLAY` and `XDG_SESSION_TYPE`.
|
||||||
|
- The user often has their own mumh5 (`npm run dev`) running. Only stop processes started with a `mumh5-e2e` profile; never kill Electron processes in general.
|
||||||
|
- `pkill -f pattern` matches the invoking shell; use `pkill -f "[p]attern"`.
|
||||||
|
|
||||||
|
## Commits
|
||||||
|
|
||||||
|
End commit messages with `Co-Authored-By: Claude <noreply@anthropic.com>` style attribution (the exact line comes from the session). No emojis in commit messages.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Kibi Kelburton
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
# mumh5
|
||||||
|
|
||||||
|
**Mumble, the way it should feel in 2026.**
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Your community keeps its own server, its own rules and its own data. You get an app that's nice to use.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why mumh5
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
mumh5 keeps the foundation and replaces the experience.
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
- **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
|
||||||
|
|
||||||
|
| | Mumble desktop | mumh5 |
|
||||||
|
|---|---|---|
|
||||||
|
| Interface | Qt widgets, dense dialogs | Modern layout: server rail, channel tree, chat, side panels |
|
||||||
|
| 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 |
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
**Where the desktop client is still ahead, today:** UDP voice transport (mumh5 sends voice through the encrypted TCP connection for now, which adds a little delay on bad networks), system-wide push-to-talk, whisper and shout, positional audio, the in-game overlay, recording and the ACL editor. These are on the roadmap below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Voice
|
||||||
|
- Opus voice with voice activity detection, 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
|
||||||
|
- Microphone and output device selection, input and output volume
|
||||||
|
- Per-person volume (0 to 300%) and "mute for me"
|
||||||
|
- Speaking indicators in the channel tree, member list and your own panel
|
||||||
|
- Bitrate automatically limited to what the server allows
|
||||||
|
- Right-click the mute or deafen button for quick device and volume options
|
||||||
|
- Hear-yourself microphone test
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Channels and people
|
||||||
|
- Channel tree that starts collapsed; channels with people in them open up
|
||||||
|
- Single click previews a channel's description, double click joins
|
||||||
|
- Side chat: write in any channel without leaving yours
|
||||||
|
- Profiles with rich descriptions (formatting, lists, links, pictures), local nicknames, registration status
|
||||||
|
- Change your name (mumh5 reconnects for you) and register on a server with one click
|
||||||
|
- Connection information: ping, packet loss, client version and, for admins, the user's certificate chain
|
||||||
|
- Moderation from the right-click menu: move, server mute and deafen, priority speaker, kick, ban, register
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Chat
|
||||||
|
- Messages grouped by author, direct messages, unread badges
|
||||||
|
- File sharing: drag and drop, paste, or the attach button, with upload progress
|
||||||
|
- Inline images, video and audio players for shared media
|
||||||
|
- YouTube links as click-to-play players
|
||||||
|
- Incoming HTML is sanitized; remote images from unknown hosts are never loaded
|
||||||
|
- Messages stay compatible with the desktop client, including picture previews
|
||||||
|
|
||||||
|
### Servers and identity
|
||||||
|
- Server rail with custom icons, drag-and-drop ordering, connection status and unread counts
|
||||||
|
- Several servers connected at once; voice follows the channel you join
|
||||||
|
- Identity setup wizard: create a new certificate, import a `.p12`, or reuse your desktop Mumble identity
|
||||||
|
- Password-protected backups of your identity
|
||||||
|
- Trust on first use for server certificates, a green header for CA-signed ones, and a certificate viewer
|
||||||
|
- 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>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Getting started
|
||||||
|
|
||||||
|
There are no release builds yet. For now, build it yourself; it takes a few minutes.
|
||||||
|
|
||||||
|
You need [Node.js](https://nodejs.org/) 22 or newer and git.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone gitea@git.lat:kibi/mumh5.git
|
||||||
|
cd mumh5
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
On first start, mumh5 asks you to set up your identity. If you already use Mumble on this computer, pick "Use my Mumble desktop identity" to keep your registrations. Then add a server with the plus button.
|
||||||
|
|
||||||
|
### Building installers
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dist:linux # AppImage and .deb
|
||||||
|
npm run dist:win # NSIS installer
|
||||||
|
npm run dist:mac # .dmg
|
||||||
|
```
|
||||||
|
|
||||||
|
Installers are written to `release/`. Build each platform on that platform for the best results.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
f0ckm keeps these uploads separate from its imageboard, respects its own list of allowed file types, and deletes them after 30 days by default.
|
||||||
|
|
||||||
|
1. On your f0ckm server, create an upload-only key:
|
||||||
|
```bash
|
||||||
|
node scripts/chat-upload-key.mjs create mumh5
|
||||||
|
```
|
||||||
|
2. In mumh5, open **Settings, Chat and files**, enter the f0ckm address and the key, and press **Test key**.
|
||||||
|
|
||||||
|
Without an upload host, mumh5 still sends images, scaled to fit the server's limit, the same way the desktop client does.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- **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.
|
||||||
|
- **Strict content security policy** in the app, and all message HTML is sanitized.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
- UDP voice with Mumble's OCB2-AES128 encryption, for the lowest latency
|
||||||
|
- System-wide push to talk
|
||||||
|
- Whisper and shout
|
||||||
|
- Rich chat between mumh5 users: replies, reactions, edits, typing indicators
|
||||||
|
- Video and screen sharing between mumh5 users
|
||||||
|
- A browser version, through a small self-hosted WebSocket proxy
|
||||||
|
- Release builds for all platforms
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # app with hot reload
|
||||||
|
npm run check # type check
|
||||||
|
npm test # unit tests
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests against a real Mumble server, including an end-to-end test that drives the app:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d --name mumh5-test-murmur -p 64739:64738 -p 64739:64738/udp \
|
||||||
|
-e MUMBLE_CONFIG_AUTOBANATTEMPTS=0 -e MUMBLE_SUPERUSER_PASSWORD=testsuper mumblevoip/mumble-server
|
||||||
|
MUMBLE_TEST_HOST=localhost:64739 npm test
|
||||||
|
npm run build
|
||||||
|
MUMBLE_TEST_HOST=localhost:64739 MUMBLE_SUPERUSER_PASSWORD=testsuper npm run test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
```
|
||||||
|
electron/ main process: window, TLS sockets, identities, tray
|
||||||
|
src/core/ Mumble protocol: framing, codec, client state, voice packets
|
||||||
|
src/lib/ app state, voice engine, sanitizing, uploads
|
||||||
|
src/ui/ Svelte components
|
||||||
|
proto/ Mumble.proto and MumbleUDP.proto from upstream
|
||||||
|
test/ unit, server and end-to-end tests
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AI disclosure and credits
|
||||||
|
|
||||||
|
mumh5 is built by **Kibi** together with **Claude**, Anthropic's AI model, working in Claude Code.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
[MIT](LICENSE). The Mumble protocol definitions in `proto/`, and the code generated from them, are licensed by The Mumble Developers under a BSD license; see [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
|
||||||
|
|
||||||
|
mumh5 is not affiliated with the Mumble project. Mumble is a trademark of its respective owners.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Third-party notices
|
||||||
|
|
||||||
|
## Mumble protocol definitions
|
||||||
|
|
||||||
|
`proto/Mumble.proto` and `proto/MumbleUDP.proto` are copied from the Mumble project
|
||||||
|
(https://github.com/mumble-voip/mumble). `src/core/mumble-pb.js` and `src/core/mumble-udp-pb.js`
|
||||||
|
are generated from them. They are covered by the following license:
|
||||||
|
|
||||||
|
```
|
||||||
|
Copyright (C) The Mumble Developers
|
||||||
|
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions
|
||||||
|
are met:
|
||||||
|
|
||||||
|
- Redistributions of source code must retain the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer.
|
||||||
|
- Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer in the documentation
|
||||||
|
and/or other materials provided with the distribution.
|
||||||
|
- Neither the name of The Mumble Developers nor the names of its
|
||||||
|
contributors may be used to endorse or promote products derived from this
|
||||||
|
software without specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||||
|
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||||
|
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||||
|
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION OR
|
||||||
|
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
|
||||||
|
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
|
||||||
|
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
|
||||||
|
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
||||||
|
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||||
|
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
```
|
||||||
|
|
||||||
|
## npm dependencies
|
||||||
|
|
||||||
|
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/`.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 228 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 246 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 128 KiB |
@@ -0,0 +1,39 @@
|
|||||||
|
import { X509Certificate } from 'node:crypto';
|
||||||
|
|
||||||
|
export interface CertDetails {
|
||||||
|
subject: string;
|
||||||
|
issuer: string;
|
||||||
|
validFrom: string;
|
||||||
|
validTo: string;
|
||||||
|
serialNumber: string;
|
||||||
|
fingerprint: string; // SHA-1, the hash Mumble uses for users
|
||||||
|
fingerprint256: string;
|
||||||
|
subjectAltName: string;
|
||||||
|
keyType: string;
|
||||||
|
keyBits: number | null;
|
||||||
|
ca: boolean;
|
||||||
|
selfSigned: boolean;
|
||||||
|
pem: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain, IPC-safe summary of a certificate for the viewer
|
||||||
|
export function describeCert(input: string | Buffer | X509Certificate): CertDetails {
|
||||||
|
const c = input instanceof X509Certificate ? input : new X509Certificate(input);
|
||||||
|
const key = c.publicKey;
|
||||||
|
const d = key.asymmetricKeyDetails;
|
||||||
|
return {
|
||||||
|
subject: c.subject,
|
||||||
|
issuer: c.issuer,
|
||||||
|
validFrom: new Date(c.validFrom).toISOString(),
|
||||||
|
validTo: new Date(c.validTo).toISOString(),
|
||||||
|
serialNumber: c.serialNumber,
|
||||||
|
fingerprint: c.fingerprint,
|
||||||
|
fingerprint256: c.fingerprint256,
|
||||||
|
subjectAltName: c.subjectAltName ?? '',
|
||||||
|
keyType: key.asymmetricKeyType ?? 'unknown',
|
||||||
|
keyBits: d?.modulusLength ?? null,
|
||||||
|
ca: c.ca,
|
||||||
|
selfSigned: c.checkIssued(c),
|
||||||
|
pem: c.toString()
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { promises as fs } from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { generateIdentity, identityFromP12, identityToP12, certCommonName, type Identity } from './identity.ts';
|
||||||
|
import { describeCert, type CertDetails } from './certs.ts';
|
||||||
|
|
||||||
|
export interface StoredIdentity extends Identity {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StoreFile {
|
||||||
|
identities: StoredIdentity[];
|
||||||
|
defaultId: string | null;
|
||||||
|
setupDone: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IdentitySummary {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
fingerprint: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
cert: CertDetails;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where the desktop Mumble client (1.5+) keeps its settings, including its certificate
|
||||||
|
export function mumbleSettingsPaths(): string[] {
|
||||||
|
const home = os.homedir();
|
||||||
|
return [
|
||||||
|
path.join(process.env.APPDATA ?? path.join(home, 'AppData', 'Roaming'), 'Mumble', 'Mumble', 'mumble_settings.json'),
|
||||||
|
path.join(home, 'Library', 'Application Support', 'Mumble', 'Mumble', 'mumble_settings.json'),
|
||||||
|
path.join(process.env.XDG_CONFIG_HOME ?? path.join(home, '.config'), 'Mumble', 'Mumble', 'mumble_settings.json')
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client identities (certificates), stored with owner-only permissions in the app's data folder
|
||||||
|
export class IdentityStore {
|
||||||
|
private data: StoreFile | null = null;
|
||||||
|
private file: string;
|
||||||
|
private legacyFile: string;
|
||||||
|
|
||||||
|
constructor(dir: string) {
|
||||||
|
this.file = path.join(dir, 'identities.json');
|
||||||
|
this.legacyFile = path.join(dir, 'identity.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async load(): Promise<StoreFile> {
|
||||||
|
if (this.data) return this.data;
|
||||||
|
try {
|
||||||
|
this.data = JSON.parse(await fs.readFile(this.file, 'utf8')) as StoreFile;
|
||||||
|
} catch {
|
||||||
|
this.data = { identities: [], defaultId: null, setupDone: false };
|
||||||
|
// Earlier versions generated one identity silently; keep it, but still run the setup wizard
|
||||||
|
try {
|
||||||
|
const legacy = JSON.parse(await fs.readFile(this.legacyFile, 'utf8')) as Identity;
|
||||||
|
const id = { ...legacy, id: randomUUID(), name: certCommonName(legacy.certPem) || 'My identity', createdAt: new Date().toISOString() };
|
||||||
|
this.data.identities.push(id);
|
||||||
|
this.data.defaultId = id.id;
|
||||||
|
await this.save();
|
||||||
|
} catch { /* no legacy identity */ }
|
||||||
|
}
|
||||||
|
return this.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async save(): Promise<void> {
|
||||||
|
await fs.writeFile(this.file, JSON.stringify(this.data), { mode: 0o600 });
|
||||||
|
}
|
||||||
|
|
||||||
|
private summary(d: StoreFile, i: StoredIdentity): IdentitySummary {
|
||||||
|
return { id: i.id, name: i.name, fingerprint: i.fingerprint, isDefault: i.id === d.defaultId, cert: describeCert(i.certPem) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(): Promise<{ identities: IdentitySummary[]; setupDone: boolean }> {
|
||||||
|
const d = await this.load();
|
||||||
|
return { identities: d.identities.map(i => this.summary(d, i)), setupDone: d.setupDone };
|
||||||
|
}
|
||||||
|
|
||||||
|
// The identity to connect with: the requested one, else the default, else a new one
|
||||||
|
async get(id?: string | null): Promise<StoredIdentity> {
|
||||||
|
const d = await this.load();
|
||||||
|
const found = d.identities.find(i => i.id === id) ?? d.identities.find(i => i.id === d.defaultId) ?? d.identities[0];
|
||||||
|
if (found) return found;
|
||||||
|
const created = await this.add(generateIdentity(os.userInfo().username), os.userInfo().username);
|
||||||
|
return d.identities.find(i => i.id === created.id)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async add(identity: Identity, name: string): Promise<IdentitySummary> {
|
||||||
|
const d = await this.load();
|
||||||
|
const existing = d.identities.find(i => i.fingerprint === identity.fingerprint);
|
||||||
|
if (existing) throw new Error(`This certificate is already stored as "${existing.name}".`);
|
||||||
|
const stored: StoredIdentity = { ...identity, id: randomUUID(), name: name.trim() || 'Identity', createdAt: new Date().toISOString() };
|
||||||
|
d.identities.push(stored);
|
||||||
|
d.defaultId ??= stored.id;
|
||||||
|
await this.save();
|
||||||
|
return this.summary(d, stored);
|
||||||
|
}
|
||||||
|
|
||||||
|
create(name: string, email: string): Promise<IdentitySummary> {
|
||||||
|
return this.add(generateIdentity(name, email), name);
|
||||||
|
}
|
||||||
|
|
||||||
|
importP12(bytes: Uint8Array, password: string, name: string): Promise<IdentitySummary> {
|
||||||
|
const identity = identityFromP12(bytes, password);
|
||||||
|
return this.add(identity, name || certCommonName(identity.certPem));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finds the desktop client's certificate; returns its name if one exists
|
||||||
|
async findMumbleCertificate(): Promise<{ path: string; name: string } | null> {
|
||||||
|
for (const p of mumbleSettingsPaths()) {
|
||||||
|
try {
|
||||||
|
const settings = JSON.parse(await fs.readFile(p, 'utf8'));
|
||||||
|
if (typeof settings.certificate !== 'string' || !settings.certificate) continue;
|
||||||
|
const identity = identityFromP12(new Uint8Array(Buffer.from(settings.certificate, 'base64')), '');
|
||||||
|
return { path: p, name: certCommonName(identity.certPem) || 'Mumble certificate' };
|
||||||
|
} catch { /* not there or unreadable */ }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async importFromMumble(name: string): Promise<IdentitySummary> {
|
||||||
|
const found = await this.findMumbleCertificate();
|
||||||
|
if (!found) throw new Error('No certificate from the desktop Mumble client was found.');
|
||||||
|
const settings = JSON.parse(await fs.readFile(found.path, 'utf8'));
|
||||||
|
return this.importP12(new Uint8Array(Buffer.from(settings.certificate, 'base64')), '', name || found.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async exportP12(id: string, password: string): Promise<{ bytes: Uint8Array; name: string }> {
|
||||||
|
const d = await this.load();
|
||||||
|
const i = d.identities.find(x => x.id === id);
|
||||||
|
if (!i) throw new Error('Identity not found.');
|
||||||
|
return { bytes: identityToP12(i, password, i.name), name: i.name };
|
||||||
|
}
|
||||||
|
|
||||||
|
async setDefault(id: string): Promise<void> {
|
||||||
|
const d = await this.load();
|
||||||
|
if (d.identities.some(i => i.id === id)) d.defaultId = id;
|
||||||
|
await this.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
async rename(id: string, name: string): Promise<void> {
|
||||||
|
const d = await this.load();
|
||||||
|
const i = d.identities.find(x => x.id === id);
|
||||||
|
if (i && name.trim()) i.name = name.trim();
|
||||||
|
await this.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: string): Promise<void> {
|
||||||
|
const d = await this.load();
|
||||||
|
d.identities = d.identities.filter(i => i.id !== id);
|
||||||
|
if (d.defaultId === id) d.defaultId = d.identities[0]?.id ?? null;
|
||||||
|
await this.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
async finishSetup(): Promise<void> {
|
||||||
|
const d = await this.load();
|
||||||
|
d.setupDone = true;
|
||||||
|
await this.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import forge from 'node-forge';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
|
||||||
|
export interface Identity {
|
||||||
|
certPem: string;
|
||||||
|
keyPem: string;
|
||||||
|
fingerprint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Self-signed client certificate, the same kind the desktop Mumble client generates.
|
||||||
|
// Mumble servers identify registered users by the hash of this certificate.
|
||||||
|
export function generateIdentity(name: string, email = ''): Identity {
|
||||||
|
const keys = forge.pki.rsa.generateKeyPair({ bits: 2048 });
|
||||||
|
const cert = forge.pki.createCertificate();
|
||||||
|
cert.publicKey = keys.publicKey;
|
||||||
|
cert.serialNumber = '01' + forge.util.bytesToHex(forge.random.getBytesSync(8));
|
||||||
|
cert.validity.notBefore = new Date();
|
||||||
|
cert.validity.notAfter = new Date();
|
||||||
|
cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 20);
|
||||||
|
const attrs: { name: string; value: string }[] = [{ name: 'commonName', value: name || 'mumh5 user' }];
|
||||||
|
if (email) attrs.push({ name: 'emailAddress', value: email });
|
||||||
|
cert.setSubject(attrs);
|
||||||
|
cert.setIssuer(attrs);
|
||||||
|
cert.setExtensions([
|
||||||
|
{ name: 'basicConstraints', cA: false },
|
||||||
|
{ name: 'keyUsage', digitalSignature: true, keyEncipherment: true },
|
||||||
|
{ name: 'extKeyUsage', clientAuth: true }
|
||||||
|
]);
|
||||||
|
cert.sign(keys.privateKey, forge.md.sha256.create());
|
||||||
|
const certPem = forge.pki.certificateToPem(cert);
|
||||||
|
return { certPem, keyPem: forge.pki.privateKeyToPem(keys.privateKey), fingerprint: certFingerprint(certPem) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// SHA-1 of the DER certificate, the hash Mumble shows and stores for users.
|
||||||
|
export function certFingerprint(certPem: string): string {
|
||||||
|
const der = forge.asn1.toDer(forge.pki.certificateToAsn1(forge.pki.certificateFromPem(certPem))).getBytes();
|
||||||
|
return createHash('sha1').update(Buffer.from(der, 'binary')).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read a PKCS#12 file (.p12/.pfx, as exported by the desktop Mumble client).
|
||||||
|
export function identityFromP12(bytes: Uint8Array, password: string): Identity {
|
||||||
|
let p12: forge.pkcs12.Pkcs12Pfx;
|
||||||
|
try {
|
||||||
|
const der = forge.util.binary.raw.encode(bytes);
|
||||||
|
p12 = forge.pkcs12.pkcs12FromAsn1(forge.asn1.fromDer(der), password);
|
||||||
|
} catch {
|
||||||
|
throw new Error('Could not open the file. Wrong password, or not a PKCS#12 certificate.');
|
||||||
|
}
|
||||||
|
const bags = (type: string) => p12.getBags({ bagType: type })[type] ?? [];
|
||||||
|
const cert = bags(forge.pki.oids.certBag).find(b => b.cert)?.cert;
|
||||||
|
const key = [...bags(forge.pki.oids.pkcs8ShroudedKeyBag), ...bags(forge.pki.oids.keyBag)].find(b => b.key)?.key;
|
||||||
|
if (!cert || !key) throw new Error('The file does not contain both a certificate and its private key.');
|
||||||
|
const certPem = forge.pki.certificateToPem(cert);
|
||||||
|
return { certPem, keyPem: forge.pki.privateKeyToPem(key), fingerprint: certFingerprint(certPem) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// PKCS#12 with 3DES, which the desktop Mumble client and other tools can import.
|
||||||
|
export function identityToP12(id: Identity, password: string, friendlyName: string): Uint8Array {
|
||||||
|
const asn1 = forge.pkcs12.toPkcs12Asn1(
|
||||||
|
forge.pki.privateKeyFromPem(id.keyPem),
|
||||||
|
[forge.pki.certificateFromPem(id.certPem)],
|
||||||
|
password,
|
||||||
|
{ algorithm: '3des', friendlyName }
|
||||||
|
);
|
||||||
|
return new Uint8Array(Buffer.from(forge.asn1.toDer(asn1).getBytes(), 'binary'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common name of a certificate, used as the default identity name
|
||||||
|
export function certCommonName(certPem: string): string {
|
||||||
|
const cn = forge.pki.certificateFromPem(certPem).subject.getField('CN');
|
||||||
|
return cn ? String(cn.value) : '';
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { app, BrowserWindow, dialog, ipcMain, shell, session, systemPreferences, type WebContents } from 'electron';
|
||||||
|
import { promises as fs } from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { IdentityStore } from './identity-store.ts';
|
||||||
|
import { describeCert } from './certs.ts';
|
||||||
|
import * as tray from './tray.ts';
|
||||||
|
import { openTls } from './tls-transport.ts';
|
||||||
|
|
||||||
|
const devUrl = process.env.VITE_DEV_SERVER_URL;
|
||||||
|
|
||||||
|
let identityStore: IdentityStore | null = null;
|
||||||
|
const identities = () => (identityStore ??= new IdentityStore(app.getPath('userData')));
|
||||||
|
|
||||||
|
// ─── Mumble TLS connections, one per renderer request ─────────────────────────
|
||||||
|
|
||||||
|
type Conn = ReturnType<typeof openTls> & { owner: WebContents };
|
||||||
|
const conns = new Map<string, Conn>();
|
||||||
|
|
||||||
|
// ─── Identities (client certificates) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
ipcMain.handle('identities:list', () => identities().list());
|
||||||
|
ipcMain.handle('identities:create', (_e, name: string, email: string) => identities().create(String(name), String(email ?? '')));
|
||||||
|
ipcMain.handle('identities:import', (_e, bytes: Uint8Array, password: string, name: string) =>
|
||||||
|
identities().importP12(new Uint8Array(bytes), String(password ?? ''), String(name ?? '')));
|
||||||
|
ipcMain.handle('identities:findMumble', () => identities().findMumbleCertificate().then(f => f && { name: f.name }));
|
||||||
|
ipcMain.handle('identities:importMumble', (_e, name: string) => identities().importFromMumble(String(name ?? '')));
|
||||||
|
ipcMain.handle('identities:setDefault', (_e, id: string) => identities().setDefault(String(id)));
|
||||||
|
ipcMain.handle('identities:rename', (_e, id: string, name: string) => identities().rename(String(id), String(name)));
|
||||||
|
ipcMain.handle('identities:remove', (_e, id: string) => identities().remove(String(id)));
|
||||||
|
ipcMain.handle('identities:finishSetup', () => identities().finishSetup());
|
||||||
|
|
||||||
|
// Saves a password-protected .p12 backup where the user chooses; returns the path or null
|
||||||
|
ipcMain.handle('identities:export', async (e, id: string, password: string) => {
|
||||||
|
const { bytes, name } = await identities().exportP12(String(id), String(password ?? ''));
|
||||||
|
const win = BrowserWindow.fromWebContents(e.sender);
|
||||||
|
const opts = {
|
||||||
|
title: 'Save identity backup',
|
||||||
|
defaultPath: path.join(app.getPath('documents'), `${name.replace(/[^\w.-]+/g, '_') || 'identity'}.p12`),
|
||||||
|
filters: [{ name: 'PKCS#12 certificate', extensions: ['p12', 'pfx'] }]
|
||||||
|
};
|
||||||
|
const res = win ? await dialog.showSaveDialog(win, opts) : await dialog.showSaveDialog(opts);
|
||||||
|
if (res.canceled || !res.filePath) return null;
|
||||||
|
await fs.writeFile(res.filePath, bytes, { mode: 0o600 });
|
||||||
|
return res.filePath;
|
||||||
|
});
|
||||||
|
|
||||||
|
// The renderer picks the connection id and subscribes before calling open, so no event can be missed
|
||||||
|
ipcMain.handle('mumble:open', async (e, connId: string, host: string, port: number, identityId?: string) => {
|
||||||
|
if (typeof connId !== 'string' || conns.has(connId)) throw new Error('Invalid connection id');
|
||||||
|
const id = await identities().get(identityId);
|
||||||
|
const owner = e.sender;
|
||||||
|
const emit = (channel: string, ...args: unknown[]) => { if (!owner.isDestroyed()) owner.send(channel, connId, ...args); };
|
||||||
|
const conn = openTls(String(host), Number(port) || 64738, id.certPem, id.keyPem, {
|
||||||
|
onSecure: info => emit('mumble:secure', info),
|
||||||
|
onData: chunk => emit('mumble:data', chunk),
|
||||||
|
onClose: reason => { conns.delete(connId); emit('mumble:close', reason); }
|
||||||
|
});
|
||||||
|
conns.set(connId, Object.assign(conn, { owner }));
|
||||||
|
owner.once('destroyed', () => conn.close());
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.on('mumble:send', (e, connId: string, bytes: Uint8Array) => {
|
||||||
|
const conn = conns.get(connId);
|
||||||
|
if (conn && conn.owner === e.sender) conn.send(bytes);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.on('mumble:close', (e, connId: string) => {
|
||||||
|
const conn = conns.get(connId);
|
||||||
|
if (conn && conn.owner === e.sender) conn.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Parses DER certificates (e.g. a user's chain from UserStats) for the viewer
|
||||||
|
ipcMain.handle('certs:describe', (_e, ders: Uint8Array[]) =>
|
||||||
|
(Array.isArray(ders) ? ders.slice(0, 8) : []).flatMap(d => { try { return [describeCert(Buffer.from(d))]; } catch { return []; } }));
|
||||||
|
|
||||||
|
ipcMain.on('tray:update', (_e, state: tray.TrayState) => tray.update(state));
|
||||||
|
|
||||||
|
ipcMain.handle('platform:info', () => ({ os: process.platform, osVersion: os.release() }));
|
||||||
|
|
||||||
|
// ─── Window ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let mainWindow: BrowserWindow | null = null;
|
||||||
|
|
||||||
|
function createWindow() {
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
width: 1280,
|
||||||
|
height: 800,
|
||||||
|
minWidth: 360,
|
||||||
|
minHeight: 480,
|
||||||
|
backgroundColor: '#111214',
|
||||||
|
title: 'mumh5',
|
||||||
|
autoHideMenuBar: true,
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, 'preload.cjs'),
|
||||||
|
contextIsolation: true,
|
||||||
|
sandbox: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
// Voice must play without a click first
|
||||||
|
autoplayPolicy: 'no-user-gesture-required',
|
||||||
|
// Keep audio and timers running at full speed when the window is in the background
|
||||||
|
backgroundThrottling: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Links from chat open in the system browser, never inside the app
|
||||||
|
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
|
if (/^https?:\/\//i.test(url)) shell.openExternal(url);
|
||||||
|
return { action: 'deny' };
|
||||||
|
});
|
||||||
|
win.webContents.on('will-navigate', (e, url) => {
|
||||||
|
if (devUrl && url.startsWith(devUrl)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
if (/^https?:\/\//i.test(url)) shell.openExternal(url);
|
||||||
|
});
|
||||||
|
|
||||||
|
mainWindow = win;
|
||||||
|
win.on('close', e => {
|
||||||
|
if (tray.shouldHideOnClose()) {
|
||||||
|
e.preventDefault();
|
||||||
|
win.hide();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
win.on('show', () => tray.onWindowVisibility());
|
||||||
|
win.on('hide', () => tray.onWindowVisibility());
|
||||||
|
win.on('closed', () => { if (mainWindow === win) mainWindow = null; });
|
||||||
|
|
||||||
|
if (devUrl) win.loadURL(devUrl);
|
||||||
|
else win.loadFile(path.join(__dirname, '../dist/index.html'));
|
||||||
|
}
|
||||||
|
|
||||||
|
app.whenReady().then(async () => {
|
||||||
|
// macOS asks once per app for microphone access
|
||||||
|
if (process.platform === 'darwin') await systemPreferences.askForMediaAccess('microphone').catch(() => false);
|
||||||
|
// YouTube refuses embeds without a referrer (error 153); a file:// app sends none
|
||||||
|
session.defaultSession.webRequest.onBeforeSendHeaders({ urls: ['https://www.youtube-nocookie.com/*'] }, (details, cb) => {
|
||||||
|
if (!details.requestHeaders.Referer) details.requestHeaders.Referer = 'https://mumh5.app/';
|
||||||
|
cb({ requestHeaders: details.requestHeaders });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Microphone, camera and screen capture for voice and video; nothing else
|
||||||
|
session.defaultSession.setPermissionRequestHandler((_wc, permission, cb) => {
|
||||||
|
cb(['media', 'display-capture', 'clipboard-sanitized-write', 'notifications'].includes(permission));
|
||||||
|
});
|
||||||
|
createWindow();
|
||||||
|
await tray.setupTray(() => mainWindow, action => mainWindow?.webContents.send('tray:action', action));
|
||||||
|
app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
if (process.platform !== 'darwin') app.quit();
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { contextBridge, ipcRenderer } from 'electron';
|
||||||
|
|
||||||
|
type Listener = (connId: string, ...args: any[]) => void;
|
||||||
|
const listeners = { secure: new Set<Listener>(), data: new Set<Listener>(), close: new Set<Listener>() };
|
||||||
|
for (const key of Object.keys(listeners) as (keyof typeof listeners)[]) {
|
||||||
|
ipcRenderer.on(`mumble:${key}`, (_e, connId: string, ...args: any[]) => {
|
||||||
|
for (const fn of listeners[key]) fn(connId, ...args);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// The only native surface the renderer gets
|
||||||
|
contextBridge.exposeInMainWorld('mumh5Native', {
|
||||||
|
platformInfo: () => ipcRenderer.invoke('platform:info'),
|
||||||
|
updateTray: (state: unknown) => ipcRenderer.send('tray:update', state),
|
||||||
|
onTrayAction: (fn: (action: string) => void) => { ipcRenderer.on('tray:action', (_e, a: string) => fn(a)); },
|
||||||
|
describeCerts: (ders: Uint8Array[]) => ipcRenderer.invoke('certs:describe', ders),
|
||||||
|
identities: {
|
||||||
|
list: () => ipcRenderer.invoke('identities:list'),
|
||||||
|
create: (name: string, email: string) => ipcRenderer.invoke('identities:create', name, email),
|
||||||
|
importP12: (bytes: Uint8Array, password: string, name: string) => ipcRenderer.invoke('identities:import', bytes, password, name),
|
||||||
|
findMumble: () => ipcRenderer.invoke('identities:findMumble'),
|
||||||
|
importMumble: (name: string) => ipcRenderer.invoke('identities:importMumble', name),
|
||||||
|
exportP12: (id: string, password: string) => ipcRenderer.invoke('identities:export', id, password),
|
||||||
|
setDefault: (id: string) => ipcRenderer.invoke('identities:setDefault', id),
|
||||||
|
rename: (id: string, name: string) => ipcRenderer.invoke('identities:rename', id, name),
|
||||||
|
remove: (id: string) => ipcRenderer.invoke('identities:remove', id),
|
||||||
|
finishSetup: () => ipcRenderer.invoke('identities:finishSetup')
|
||||||
|
},
|
||||||
|
open: (connId: string, host: string, port: number, identityId?: string) => ipcRenderer.invoke('mumble:open', connId, host, port, identityId),
|
||||||
|
send: (connId: string, bytes: Uint8Array) => ipcRenderer.send('mumble:send', connId, bytes),
|
||||||
|
close: (connId: string) => ipcRenderer.send('mumble:close', connId),
|
||||||
|
on: (event: keyof typeof listeners, fn: Listener) => {
|
||||||
|
listeners[event].add(fn);
|
||||||
|
return () => listeners[event].delete(fn);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import tls from 'node:tls';
|
||||||
|
import { describeCert, type CertDetails } from './certs.ts';
|
||||||
|
|
||||||
|
export interface TlsHandlers {
|
||||||
|
onSecure(info: { fingerprint: string; authorized: boolean; authError: string | null; chain: CertDetails[] }): void;
|
||||||
|
onData(chunk: Uint8Array): void;
|
||||||
|
onClose(reason: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opens a TLS connection to a Mumble server. Most servers use self-signed certificates,
|
||||||
|
// so verification is left to the caller (trust on first use via the sha256 fingerprint).
|
||||||
|
export function openTls(host: string, port: number, cert: string, key: string, h: TlsHandlers) {
|
||||||
|
let closeReason = 'Connection closed';
|
||||||
|
const socket = tls.connect({
|
||||||
|
host, port, cert, key,
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
servername: /^[\d.:]+$/.test(host) ? undefined : host
|
||||||
|
});
|
||||||
|
socket.setNoDelay(true);
|
||||||
|
socket.setKeepAlive(true, 30000);
|
||||||
|
socket.setTimeout(20000, () => {
|
||||||
|
closeReason = 'Connection timed out';
|
||||||
|
socket.destroy();
|
||||||
|
});
|
||||||
|
socket.on('secureConnect', () => {
|
||||||
|
socket.setTimeout(0);
|
||||||
|
// Leaf first, then the issuers the server sent (the root links to itself)
|
||||||
|
const chain: CertDetails[] = [];
|
||||||
|
let cur = socket.getPeerCertificate(true) as tls.DetailedPeerCertificate | undefined;
|
||||||
|
const seen = new Set<string>();
|
||||||
|
while (cur?.raw && !seen.has(cur.fingerprint256) && chain.length < 8) {
|
||||||
|
seen.add(cur.fingerprint256);
|
||||||
|
chain.push(describeCert(cur.raw));
|
||||||
|
cur = cur.issuerCertificate;
|
||||||
|
}
|
||||||
|
h.onSecure({
|
||||||
|
chain,
|
||||||
|
fingerprint: chain[0]?.fingerprint256 ?? '',
|
||||||
|
authorized: socket.authorized,
|
||||||
|
authError: socket.authorizationError ? String(socket.authorizationError) : null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
socket.on('data', (chunk: Buffer) => h.onData(new Uint8Array(chunk)));
|
||||||
|
socket.on('error', (e: Error) => { closeReason = e.message; });
|
||||||
|
socket.on('close', () => h.onClose(closeReason));
|
||||||
|
return {
|
||||||
|
send(bytes: Uint8Array) { if (!socket.destroyed) socket.write(bytes); },
|
||||||
|
close() { closeReason = 'Disconnected'; socket.destroy(); }
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { app, BrowserWindow, Menu, Tray, nativeImage } from 'electron';
|
||||||
|
import { promises as fs } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
// State the renderer reports; the tray mirrors it
|
||||||
|
export interface TrayState {
|
||||||
|
icon: string; // PNG data URL drawn by the renderer
|
||||||
|
tooltip: string;
|
||||||
|
connected: boolean;
|
||||||
|
server: string;
|
||||||
|
muted: boolean;
|
||||||
|
deafened: boolean;
|
||||||
|
mode: 'vad' | 'ptt' | 'continuous';
|
||||||
|
}
|
||||||
|
|
||||||
|
let tray: Tray | null = null;
|
||||||
|
let state: TrayState | null = null;
|
||||||
|
let closeToTray = true;
|
||||||
|
let quitting = false;
|
||||||
|
const prefsFile = () => path.join(app.getPath('userData'), 'tray.json');
|
||||||
|
|
||||||
|
export async function setupTray(getWindow: () => BrowserWindow | null, send: (action: string) => void): Promise<void> {
|
||||||
|
try { closeToTray = JSON.parse(await fs.readFile(prefsFile(), 'utf8')).closeToTray !== false; } catch { /* defaults */ }
|
||||||
|
|
||||||
|
const show = () => {
|
||||||
|
const win = getWindow();
|
||||||
|
if (!win) return;
|
||||||
|
if (win.isMinimized()) win.restore();
|
||||||
|
win.show();
|
||||||
|
win.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const rebuild = () => {
|
||||||
|
if (!tray || !state) return;
|
||||||
|
const win = getWindow();
|
||||||
|
const s = state;
|
||||||
|
const menu = Menu.buildFromTemplate([
|
||||||
|
{ label: win?.isVisible() ? 'Hide mumh5' : 'Show mumh5', click: () => (win?.isVisible() ? win.hide() : show()) },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ label: s.connected ? `Connected: ${s.server}` : 'Not connected', enabled: false },
|
||||||
|
{ label: 'Mute', type: 'checkbox', checked: s.muted, enabled: s.connected, click: () => send('toggle-mute') },
|
||||||
|
{ label: 'Deafen', type: 'checkbox', checked: s.deafened, enabled: s.connected, click: () => send('toggle-deafen') },
|
||||||
|
{
|
||||||
|
label: 'Transmit', submenu: [
|
||||||
|
{ label: 'Voice activity', type: 'radio', checked: s.mode === 'vad', click: () => send('mode:vad') },
|
||||||
|
{ label: 'Push to talk', type: 'radio', checked: s.mode === 'ptt', click: () => send('mode:ptt') },
|
||||||
|
{ label: 'Always on', type: 'radio', checked: s.mode === 'continuous', click: () => send('mode:continuous') }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ label: 'Voice settings...', click: () => { show(); send('settings'); } },
|
||||||
|
{ label: 'Disconnect', enabled: s.connected, click: () => send('disconnect') },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: 'Close to tray', type: 'checkbox', checked: closeToTray, click: item => {
|
||||||
|
closeToTray = item.checked;
|
||||||
|
fs.writeFile(prefsFile(), JSON.stringify({ closeToTray })).catch(() => {});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ label: 'Quit mumh5', click: () => { quitting = true; app.quit(); } }
|
||||||
|
]);
|
||||||
|
tray.setContextMenu(menu);
|
||||||
|
// Inspection hook for the end-to-end tests (tray menus cannot be clicked by automation)
|
||||||
|
(globalThis as any).__mumh5Tray = {
|
||||||
|
state: { ...s, icon: s.icon.slice(0, 22) },
|
||||||
|
tooltip: s.tooltip,
|
||||||
|
items: menu.items.map(i => ({ label: i.label, checked: i.checked, enabled: i.enabled })),
|
||||||
|
click: (label: string) => { const item = menu.items.find(i => i.label === label); item?.click(undefined, getWindow() ?? undefined, getWindow()?.webContents); }
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
app.on('before-quit', () => { quitting = true; });
|
||||||
|
|
||||||
|
// Called for every state change from the renderer
|
||||||
|
update = (next: TrayState) => {
|
||||||
|
state = next;
|
||||||
|
const image = nativeImage.createFromDataURL(next.icon);
|
||||||
|
if (!tray) {
|
||||||
|
tray = new Tray(image);
|
||||||
|
tray.on('click', show);
|
||||||
|
} else {
|
||||||
|
tray.setImage(image);
|
||||||
|
}
|
||||||
|
tray.setToolTip(next.tooltip);
|
||||||
|
rebuild();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keep the Show/Hide label right
|
||||||
|
onWindowVisibility = rebuild;
|
||||||
|
}
|
||||||
|
|
||||||
|
export let update: (s: TrayState) => void = () => {};
|
||||||
|
export let onWindowVisibility: () => void = () => {};
|
||||||
|
|
||||||
|
// Closing the window hides it while the tray is there, unless the user quits
|
||||||
|
export function shouldHideOnClose(): boolean {
|
||||||
|
return !!tray && closeToTray && !quitting && process.platform !== 'darwin';
|
||||||
|
}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<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" />
|
||||||
|
<title>mumh5</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+5823
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,89 @@
|
|||||||
|
{
|
||||||
|
"name": "mumh5",
|
||||||
|
"productName": "mumh5",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Modern Mumble client with rich chat, file sharing and video",
|
||||||
|
"license": "MIT",
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist-electron/main.cjs",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "node scripts/dev.mjs",
|
||||||
|
"build": "vite build && node scripts/build-electron.mjs",
|
||||||
|
"build:web": "vite build --mode web --outDir dist-web",
|
||||||
|
"check": "svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"test": "node --test test/*.test.ts",
|
||||||
|
"dist": "npm run build && electron-builder",
|
||||||
|
"dist:linux": "npm run build && electron-builder --linux",
|
||||||
|
"dist:win": "npm run build && electron-builder --win",
|
||||||
|
"dist:mac": "npm run build && electron-builder --mac",
|
||||||
|
"test:e2e": "node test/e2e/app.e2e.ts",
|
||||||
|
"proto": "pbjs -t static-module -w es6 --keep-case --no-delimited --no-service --no-comments --force-number proto/Mumble.proto -o src/core/mumble-pb.js && pbjs -t static-module -w es6 --keep-case --no-delimited --no-service --no-comments --force-number proto/MumbleUDP.proto -o src/core/mumble-udp-pb.js",
|
||||||
|
"screenshots": "node test/e2e/screenshots.ts"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"appId": "dev.f0ck.mumh5",
|
||||||
|
"productName": "mumh5",
|
||||||
|
"files": [
|
||||||
|
"dist/**",
|
||||||
|
"dist-electron/**",
|
||||||
|
"package.json",
|
||||||
|
"LICENSE",
|
||||||
|
"THIRD_PARTY_NOTICES.md"
|
||||||
|
],
|
||||||
|
"directories": {
|
||||||
|
"output": "release"
|
||||||
|
},
|
||||||
|
"linux": {
|
||||||
|
"target": [
|
||||||
|
"AppImage",
|
||||||
|
"deb"
|
||||||
|
],
|
||||||
|
"category": "Network"
|
||||||
|
},
|
||||||
|
"win": {
|
||||||
|
"target": [
|
||||||
|
"nsis"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"mac": {
|
||||||
|
"target": [
|
||||||
|
"dmg"
|
||||||
|
],
|
||||||
|
"category": "public.app-category.social-networking",
|
||||||
|
"extendInfo": {
|
||||||
|
"NSMicrophoneUsageDescription": "mumh5 uses the microphone for voice chat.",
|
||||||
|
"NSCameraUsageDescription": "mumh5 uses the camera for video chat."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^7.3.1",
|
||||||
|
"@tsconfig/svelte": "^5.0.8",
|
||||||
|
"@types/node": "^26.6.3",
|
||||||
|
"@types/node-forge": "^1.3.14",
|
||||||
|
"electron": "^44.5.1",
|
||||||
|
"electron-builder": "^26.15.3",
|
||||||
|
"esbuild": "^0.28.2",
|
||||||
|
"playwright-core": "^1.63.0",
|
||||||
|
"protobufjs-cli": "^2.7.0",
|
||||||
|
"svelte": "^5.57.1",
|
||||||
|
"svelte-check": "^4.7.6",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
|
"vite": "^8.3.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"dompurify": "^3.4.16",
|
||||||
|
"node-forge": "^1.4.0",
|
||||||
|
"protobufjs": "^8.8.0"
|
||||||
|
},
|
||||||
|
"allowScripts": {
|
||||||
|
"electron@44.5.1": true,
|
||||||
|
"esbuild@0.28.2": true,
|
||||||
|
"electron-winstaller": false
|
||||||
|
},
|
||||||
|
"author": "Kibi Kelburton",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "gitea@git.lat:kibi/mumh5.git"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,639 @@
|
|||||||
|
// Copyright The Mumble Developers. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-style license
|
||||||
|
// that can be found in the LICENSE file at the root of the
|
||||||
|
// Mumble source tree or at <https://www.mumble.info/LICENSE>.
|
||||||
|
|
||||||
|
syntax = "proto2";
|
||||||
|
|
||||||
|
package MumbleProto;
|
||||||
|
|
||||||
|
option optimize_for = SPEED;
|
||||||
|
|
||||||
|
message Version {
|
||||||
|
// Legacy version number format.
|
||||||
|
optional uint32 version_v1 = 1;
|
||||||
|
// New version number format.
|
||||||
|
// Necessary since patch level may exceed 255. (See https://github.com/mumble-voip/mumble/issues/5827)
|
||||||
|
optional uint64 version_v2 = 5;
|
||||||
|
|
||||||
|
// Client release name.
|
||||||
|
optional string release = 2;
|
||||||
|
// Client OS name.
|
||||||
|
optional string os = 3;
|
||||||
|
// Client OS version.
|
||||||
|
optional string os_version = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not used. Not even for tunneling UDP through TCP.
|
||||||
|
message UDPTunnel {
|
||||||
|
// Not used.
|
||||||
|
required bytes packet = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Used by the client to send the authentication credentials to the server.
|
||||||
|
message Authenticate {
|
||||||
|
// UTF-8 encoded username.
|
||||||
|
optional string username = 1;
|
||||||
|
// Server or user password.
|
||||||
|
optional string password = 2;
|
||||||
|
// Additional access tokens for server ACL groups.
|
||||||
|
repeated string tokens = 3;
|
||||||
|
// A list of CELT bitstream version constants supported by the client.
|
||||||
|
repeated int32 celt_versions = 4;
|
||||||
|
optional bool opus = 5 [default = false];
|
||||||
|
// 0 = REGULAR, 1 = BOT
|
||||||
|
optional int32 client_type = 6 [default = 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sent by the client to notify the server that the client is still alive.
|
||||||
|
// Server must reply to the packet with the same timestamp and its own
|
||||||
|
// good/late/lost/resync numbers. None of the fields is strictly required.
|
||||||
|
message Ping {
|
||||||
|
// Client timestamp. Server should not attempt to decode.
|
||||||
|
optional uint64 timestamp = 1;
|
||||||
|
// The amount of good packets received.
|
||||||
|
optional uint32 good = 2;
|
||||||
|
// The amount of late packets received.
|
||||||
|
optional uint32 late = 3;
|
||||||
|
// The amount of packets never received.
|
||||||
|
optional uint32 lost = 4;
|
||||||
|
// The amount of nonce resyncs.
|
||||||
|
optional uint32 resync = 5;
|
||||||
|
// The total amount of UDP packets received.
|
||||||
|
optional uint32 udp_packets = 6;
|
||||||
|
// The total amount of TCP packets received.
|
||||||
|
optional uint32 tcp_packets = 7;
|
||||||
|
// UDP ping average.
|
||||||
|
optional float udp_ping_avg = 8;
|
||||||
|
// UDP ping variance.
|
||||||
|
optional float udp_ping_var = 9;
|
||||||
|
// TCP ping average.
|
||||||
|
optional float tcp_ping_avg = 10;
|
||||||
|
// TCP ping variance.
|
||||||
|
optional float tcp_ping_var = 11;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sent by the server when it rejects the user connection.
|
||||||
|
message Reject {
|
||||||
|
enum RejectType {
|
||||||
|
// The rejection reason is unknown (details should be available
|
||||||
|
// in Reject.reason).
|
||||||
|
None = 0;
|
||||||
|
// The client attempted to connect with an incompatible version.
|
||||||
|
WrongVersion = 1;
|
||||||
|
// The user name supplied by the client was invalid.
|
||||||
|
InvalidUsername = 2;
|
||||||
|
// The client attempted to authenticate as a user with a password but it
|
||||||
|
// was wrong.
|
||||||
|
WrongUserPW = 3;
|
||||||
|
// The client attempted to connect to a passworded server but the password
|
||||||
|
// was wrong.
|
||||||
|
WrongServerPW = 4;
|
||||||
|
// Supplied username is already in use.
|
||||||
|
UsernameInUse = 5;
|
||||||
|
// Server is currently full and cannot accept more users.
|
||||||
|
ServerFull = 6;
|
||||||
|
// The user did not provide a certificate but one is required.
|
||||||
|
NoCertificate = 7;
|
||||||
|
AuthenticatorFail = 8;
|
||||||
|
// The server is currently not accepting new connections
|
||||||
|
NoNewConnections = 9;
|
||||||
|
}
|
||||||
|
// Rejection type.
|
||||||
|
optional RejectType type = 1;
|
||||||
|
// Human readable rejection reason.
|
||||||
|
optional string reason = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerSync message is sent by the server when it has authenticated the user
|
||||||
|
// and finished synchronizing the server state.
|
||||||
|
message ServerSync {
|
||||||
|
// The session of the current user.
|
||||||
|
optional uint32 session = 1;
|
||||||
|
// Maximum bandwidth that the user should use.
|
||||||
|
optional uint32 max_bandwidth = 2;
|
||||||
|
// Server welcome text.
|
||||||
|
optional string welcome_text = 3;
|
||||||
|
// Current user permissions in the root channel.
|
||||||
|
// Note: The permissions data type usually is uin32 (e.g. in PermissionQuery and PermissionDenied messages). Here
|
||||||
|
// it is uint64 because of an oversight in the past. Nonetheless it should never exceed the uin32 range.
|
||||||
|
// See also: https://github.com/mumble-voip/mumble/issues/5139
|
||||||
|
optional uint64 permissions = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sent by the client when it wants a channel removed. Sent by the server when
|
||||||
|
// a channel has been removed and clients should be notified.
|
||||||
|
message ChannelRemove {
|
||||||
|
required uint32 channel_id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Used to communicate channel properties between the client and the server.
|
||||||
|
// Sent by the server during the login process or when channel properties are
|
||||||
|
// updated. Client may use this message to update said channel properties.
|
||||||
|
message ChannelState {
|
||||||
|
// Unique ID for the channel within the server.
|
||||||
|
optional uint32 channel_id = 1;
|
||||||
|
// channel_id of the parent channel.
|
||||||
|
optional uint32 parent = 2;
|
||||||
|
// UTF-8 encoded channel name.
|
||||||
|
optional string name = 3;
|
||||||
|
// A collection of channel id values of the linked channels. Absent during
|
||||||
|
// the first channel listing.
|
||||||
|
repeated uint32 links = 4;
|
||||||
|
// UTF-8 encoded channel description. Only if the description is less than
|
||||||
|
// 128 bytes
|
||||||
|
optional string description = 5;
|
||||||
|
// A collection of channel_id values that should be added to links.
|
||||||
|
repeated uint32 links_add = 6;
|
||||||
|
// A collection of channel_id values that should be removed from links.
|
||||||
|
repeated uint32 links_remove = 7;
|
||||||
|
// True if the channel is temporary.
|
||||||
|
optional bool temporary = 8 [default = false];
|
||||||
|
// Position weight to tweak the channel position in the channel list.
|
||||||
|
optional int32 position = 9 [default = 0];
|
||||||
|
// SHA1 hash of the description if the description is 128 bytes or more.
|
||||||
|
optional bytes description_hash = 10;
|
||||||
|
// Maximum number of users allowed in the channel. If this value is zero,
|
||||||
|
// the maximum number of users allowed in the channel is given by the
|
||||||
|
// server's "usersperchannel" setting.
|
||||||
|
optional uint32 max_users = 11;
|
||||||
|
// Whether this channel has enter restrictions (ACL denying ENTER) set
|
||||||
|
optional bool is_enter_restricted = 12;
|
||||||
|
// Whether the receiver of this msg is considered to be able to enter this channel
|
||||||
|
optional bool can_enter = 13;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Used to communicate user leaving or being kicked. May be sent by the client
|
||||||
|
// when it attempts to kick a user. Sent by the server when it informs the
|
||||||
|
// clients that a user is not present anymore.
|
||||||
|
message UserRemove {
|
||||||
|
// The user who is being kicked, identified by their session, not present
|
||||||
|
// when no one is being kicked.
|
||||||
|
required uint32 session = 1;
|
||||||
|
// The user who initiated the removal. Either the user who performs the kick
|
||||||
|
// or the user who is currently leaving.
|
||||||
|
optional uint32 actor = 2;
|
||||||
|
// Reason for the kick, stored as the ban reason if the user is banned.
|
||||||
|
optional string reason = 3;
|
||||||
|
// True if the user shall be banned, false if the user shall be kicked
|
||||||
|
optional bool ban = 4;
|
||||||
|
// True if the user shall be banned by certificate.
|
||||||
|
optional bool ban_certificate = 5;
|
||||||
|
// True if the user shall be banned by IP.
|
||||||
|
optional bool ban_ip = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sent by the server when it communicates new and changed users to client.
|
||||||
|
// First seen during login procedure. May be sent by the client when it wishes
|
||||||
|
// to alter its state.
|
||||||
|
message UserState {
|
||||||
|
message VolumeAdjustment {
|
||||||
|
optional uint32 listening_channel = 1;
|
||||||
|
optional float volume_adjustment = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unique user session ID of the user whose state this is, may change on
|
||||||
|
// reconnect.
|
||||||
|
optional uint32 session = 1;
|
||||||
|
// The session of the user who is updating this user.
|
||||||
|
optional uint32 actor = 2;
|
||||||
|
// User name, UTF-8 encoded.
|
||||||
|
optional string name = 3;
|
||||||
|
// Registered user ID if the user is registered.
|
||||||
|
optional uint32 user_id = 4;
|
||||||
|
// Channel on which the user is.
|
||||||
|
optional uint32 channel_id = 5;
|
||||||
|
// True if the user is muted by admin.
|
||||||
|
optional bool mute = 6;
|
||||||
|
// True if the user is deafened by admin.
|
||||||
|
optional bool deaf = 7;
|
||||||
|
// True if the user has been suppressed from talking by a reason other than
|
||||||
|
// being muted.
|
||||||
|
optional bool suppress = 8;
|
||||||
|
// True if the user has muted self.
|
||||||
|
optional bool self_mute = 9;
|
||||||
|
// True if the user has deafened self.
|
||||||
|
optional bool self_deaf = 10;
|
||||||
|
// User image if it is less than 128 bytes.
|
||||||
|
optional bytes texture = 11;
|
||||||
|
// The positional audio plugin identifier.
|
||||||
|
// Positional audio information is only sent to users who share
|
||||||
|
// identical plugin contexts.
|
||||||
|
//
|
||||||
|
// This value is not transmitted to clients.
|
||||||
|
optional bytes plugin_context = 12;
|
||||||
|
// The user's plugin-specific identity.
|
||||||
|
// This value is not transmitted to clients.
|
||||||
|
optional string plugin_identity = 13;
|
||||||
|
// User comment if it is less than 128 bytes.
|
||||||
|
optional string comment = 14;
|
||||||
|
// The hash of the user certificate.
|
||||||
|
optional string hash = 15;
|
||||||
|
// SHA1 hash of the user comment if it 128 bytes or more.
|
||||||
|
optional bytes comment_hash = 16;
|
||||||
|
// SHA1 hash of the user picture if it 128 bytes or more.
|
||||||
|
optional bytes texture_hash = 17;
|
||||||
|
// True if the user is a priority speaker.
|
||||||
|
optional bool priority_speaker = 18;
|
||||||
|
// True if the user is currently recording.
|
||||||
|
optional bool recording = 19;
|
||||||
|
// A list of temporary access tokens to be respected when processing this request.
|
||||||
|
repeated string temporary_access_tokens = 20;
|
||||||
|
// A list of channels the user wants to start listening to.
|
||||||
|
repeated uint32 listening_channel_add = 21;
|
||||||
|
// a list of channels the user does no longer want to listen to.
|
||||||
|
repeated uint32 listening_channel_remove = 22;
|
||||||
|
// A list of volume adjustments the user has applied to listeners
|
||||||
|
repeated VolumeAdjustment listening_volume_adjustment = 23;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relays information on the bans. The client may send the BanList message to
|
||||||
|
// either modify the list of bans or query them from the server. The server
|
||||||
|
// sends this list only after a client queries for it.
|
||||||
|
message BanList {
|
||||||
|
message BanEntry {
|
||||||
|
// Banned IP address.
|
||||||
|
required bytes address = 1;
|
||||||
|
// The length of the subnet mask for the ban.
|
||||||
|
required uint32 mask = 2;
|
||||||
|
// User name for identification purposes (does not affect the ban).
|
||||||
|
optional string name = 3;
|
||||||
|
// The certificate hash of the banned user.
|
||||||
|
optional string hash = 4;
|
||||||
|
// Reason for the ban (does not affect the ban).
|
||||||
|
optional string reason = 5;
|
||||||
|
// Ban start time.
|
||||||
|
optional string start = 6;
|
||||||
|
// Ban duration in seconds.
|
||||||
|
optional uint32 duration = 7;
|
||||||
|
}
|
||||||
|
// List of ban entries currently in place.
|
||||||
|
repeated BanEntry bans = 1;
|
||||||
|
// True if the server should return the list, false if it should replace old
|
||||||
|
// ban list with the one provided.
|
||||||
|
optional bool query = 2 [default = false];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Used to send and broadcast text messages.
|
||||||
|
message TextMessage {
|
||||||
|
// The message sender, identified by its session.
|
||||||
|
optional uint32 actor = 1;
|
||||||
|
// Target users for the message, identified by their session.
|
||||||
|
repeated uint32 session = 2;
|
||||||
|
// The channels to which the message is sent, identified by their
|
||||||
|
// channel_ids.
|
||||||
|
repeated uint32 channel_id = 3;
|
||||||
|
// The root channels when sending message recursively to several channels,
|
||||||
|
// identified by their channel_ids.
|
||||||
|
repeated uint32 tree_id = 4;
|
||||||
|
// The UTF-8 encoded message. May be HTML if the server allows.
|
||||||
|
required string message = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message PermissionDenied {
|
||||||
|
enum DenyType {
|
||||||
|
// Operation denied for other reason, see reason field.
|
||||||
|
Text = 0;
|
||||||
|
// Permissions were denied.
|
||||||
|
Permission = 1;
|
||||||
|
// Cannot modify SuperUser.
|
||||||
|
SuperUser = 2;
|
||||||
|
// Invalid channel name.
|
||||||
|
ChannelName = 3;
|
||||||
|
// Text message too long.
|
||||||
|
TextTooLong = 4;
|
||||||
|
// The flux capacitor was spelled wrong.
|
||||||
|
H9K = 5;
|
||||||
|
// Operation not permitted in temporary channel.
|
||||||
|
TemporaryChannel = 6;
|
||||||
|
// Operation requires certificate.
|
||||||
|
MissingCertificate = 7;
|
||||||
|
// Invalid username.
|
||||||
|
UserName = 8;
|
||||||
|
// Channel is full.
|
||||||
|
ChannelFull = 9;
|
||||||
|
// Channels are nested too deeply.
|
||||||
|
NestingLimit = 10;
|
||||||
|
// Maximum channel count reached.
|
||||||
|
ChannelCountLimit = 11;
|
||||||
|
// Amount of listener objects for this channel has been reached
|
||||||
|
ChannelListenerLimit = 12;
|
||||||
|
// Amount of listener proxies for the user has been reached
|
||||||
|
UserListenerLimit = 13;
|
||||||
|
}
|
||||||
|
// The denied permission when type is Permission.
|
||||||
|
optional uint32 permission = 1;
|
||||||
|
// channel_id for the channel where the permission was denied when type is
|
||||||
|
// Permission.
|
||||||
|
optional uint32 channel_id = 2;
|
||||||
|
// The user who was denied permissions, identified by session.
|
||||||
|
optional uint32 session = 3;
|
||||||
|
// Textual reason for the denial.
|
||||||
|
optional string reason = 4;
|
||||||
|
// Type of the denial.
|
||||||
|
optional DenyType type = 5;
|
||||||
|
// The name that is invalid when type is UserName.
|
||||||
|
optional string name = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ACL {
|
||||||
|
message ChanGroup {
|
||||||
|
// Name of the channel group, UTF-8 encoded.
|
||||||
|
required string name = 1;
|
||||||
|
// True if the group has been inherited from the parent (Read only).
|
||||||
|
optional bool inherited = 2 [default = true];
|
||||||
|
// True if the group members are inherited.
|
||||||
|
optional bool inherit = 3 [default = true];
|
||||||
|
// True if the group can be inherited by sub channels.
|
||||||
|
optional bool inheritable = 4 [default = true];
|
||||||
|
// Users explicitly included in this group, identified by user_id.
|
||||||
|
repeated uint32 add = 5;
|
||||||
|
// Users explicitly removed from this group in this channel if the group
|
||||||
|
// has been inherited, identified by user_id.
|
||||||
|
repeated uint32 remove = 6;
|
||||||
|
// Users inherited, identified by user_id.
|
||||||
|
repeated uint32 inherited_members = 7;
|
||||||
|
}
|
||||||
|
message ChanACL {
|
||||||
|
// True if this ACL applies to the current channel.
|
||||||
|
optional bool apply_here = 1 [default = true];
|
||||||
|
// True if this ACL applies to the sub channels.
|
||||||
|
optional bool apply_subs = 2 [default = true];
|
||||||
|
// True if the ACL has been inherited from the parent.
|
||||||
|
optional bool inherited = 3 [default = true];
|
||||||
|
// ID of the user that is affected by this ACL.
|
||||||
|
optional uint32 user_id = 4;
|
||||||
|
// ID of the group that is affected by this ACL.
|
||||||
|
optional string group = 5;
|
||||||
|
// Bit flag field of the permissions granted by this ACL.
|
||||||
|
optional uint32 grant = 6;
|
||||||
|
// Bit flag field of the permissions denied by this ACL.
|
||||||
|
optional uint32 deny = 7;
|
||||||
|
}
|
||||||
|
// Channel ID of the channel this message affects.
|
||||||
|
required uint32 channel_id = 1;
|
||||||
|
// True if the channel inherits its parent's ACLs.
|
||||||
|
optional bool inherit_acls = 2 [default = true];
|
||||||
|
// User group specifications.
|
||||||
|
repeated ChanGroup groups = 3;
|
||||||
|
// ACL specifications.
|
||||||
|
repeated ChanACL acls = 4;
|
||||||
|
// True if the message is a query for ACLs instead of setting them.
|
||||||
|
optional bool query = 5 [default = false];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client may use this message to refresh its registered user information. The
|
||||||
|
// client should fill the IDs or Names of the users it wants to refresh. The
|
||||||
|
// server fills the missing parts and sends the message back.
|
||||||
|
message QueryUsers {
|
||||||
|
// user_ids.
|
||||||
|
repeated uint32 ids = 1;
|
||||||
|
// User names in the same order as ids.
|
||||||
|
repeated string names = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Used to initialize and resync the UDP encryption. Either side may request a
|
||||||
|
// resync by sending the message without any values filled. The resync is
|
||||||
|
// performed by sending the message with only the client or server nonce
|
||||||
|
// filled.
|
||||||
|
message CryptSetup {
|
||||||
|
// Encryption key.
|
||||||
|
optional bytes key = 1;
|
||||||
|
// Client nonce.
|
||||||
|
optional bytes client_nonce = 2;
|
||||||
|
// Server nonce.
|
||||||
|
optional bytes server_nonce = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Used to add or remove custom context menu item on client-side.
|
||||||
|
message ContextActionModify {
|
||||||
|
enum Context {
|
||||||
|
// Action is applicable to the server.
|
||||||
|
Server = 0x01;
|
||||||
|
// Action can target a Channel.
|
||||||
|
Channel = 0x02;
|
||||||
|
// Action can target a User.
|
||||||
|
User = 0x04;
|
||||||
|
}
|
||||||
|
enum Operation {
|
||||||
|
Add = 0;
|
||||||
|
Remove = 1;
|
||||||
|
}
|
||||||
|
// The action identifier. Used later to initiate an action.
|
||||||
|
required string action = 1;
|
||||||
|
// The display name of the action.
|
||||||
|
optional string text = 2;
|
||||||
|
// Context bit flags defining where the action should be displayed.
|
||||||
|
// Flags can be OR-ed to combine different types.
|
||||||
|
optional uint32 context = 3;
|
||||||
|
// Choose either to add or to remove the context action.
|
||||||
|
// Note: This field only exists after Mumble 1.2.4-beta1 release.
|
||||||
|
// The message will be recognized as Add regardless of this field
|
||||||
|
// before said release.
|
||||||
|
optional Operation operation = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sent by the client when it wants to initiate a Context action.
|
||||||
|
message ContextAction {
|
||||||
|
// The target User for the action, identified by session.
|
||||||
|
optional uint32 session = 1;
|
||||||
|
// The target Channel for the action, identified by channel_id.
|
||||||
|
optional uint32 channel_id = 2;
|
||||||
|
// The action that should be executed.
|
||||||
|
required string action = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lists the registered users.
|
||||||
|
message UserList {
|
||||||
|
message User {
|
||||||
|
// Registered user ID.
|
||||||
|
required uint32 user_id = 1;
|
||||||
|
// Registered user name.
|
||||||
|
optional string name = 2;
|
||||||
|
optional string last_seen = 3;
|
||||||
|
optional uint32 last_channel = 4;
|
||||||
|
}
|
||||||
|
// A list of registered users.
|
||||||
|
repeated User users = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sent by the client when it wants to register or clear whisper targets.
|
||||||
|
//
|
||||||
|
// Note: The first available target ID is 1 as 0 is reserved for normal
|
||||||
|
// talking. Maximum target ID is 30.
|
||||||
|
message VoiceTarget {
|
||||||
|
message Target {
|
||||||
|
// Users that are included as targets.
|
||||||
|
repeated uint32 session = 1;
|
||||||
|
// Channel that is included as a target.
|
||||||
|
optional uint32 channel_id = 2;
|
||||||
|
// ACL group that is included as a target.
|
||||||
|
optional string group = 3;
|
||||||
|
// True if the voice should follow links from the specified channel.
|
||||||
|
optional bool links = 4 [default = false];
|
||||||
|
// True if the voice should also be sent to children of the specific
|
||||||
|
// channel.
|
||||||
|
optional bool children = 5 [default = false];
|
||||||
|
}
|
||||||
|
// Voice target ID.
|
||||||
|
optional uint32 id = 1;
|
||||||
|
// The receivers that this voice target includes.
|
||||||
|
repeated Target targets = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sent by the client when it wants permissions for a certain channel. Sent by
|
||||||
|
// the server when it replies to the query or wants the user to resync all
|
||||||
|
// channel permissions.
|
||||||
|
message PermissionQuery {
|
||||||
|
// channel_id of the channel for which the permissions are queried.
|
||||||
|
optional uint32 channel_id = 1;
|
||||||
|
// Channel permissions.
|
||||||
|
optional uint32 permissions = 2;
|
||||||
|
// True if the client should drop its current permission information for all
|
||||||
|
// channels.
|
||||||
|
optional bool flush = 3 [default = false];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sent by the server to notify the users of the version of the CELT codec they
|
||||||
|
// should use. This may change during the connection when new users join.
|
||||||
|
message CodecVersion {
|
||||||
|
// The version of the CELT Alpha codec.
|
||||||
|
required int32 alpha = 1;
|
||||||
|
// The version of the CELT Beta codec.
|
||||||
|
required int32 beta = 2;
|
||||||
|
// True if the user should prefer Alpha over Beta.
|
||||||
|
required bool prefer_alpha = 3 [default = true];
|
||||||
|
optional bool opus = 4 [default = false];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Used to communicate user stats between the server and clients.
|
||||||
|
message UserStats {
|
||||||
|
message Stats {
|
||||||
|
// The amount of good packets received.
|
||||||
|
optional uint32 good = 1;
|
||||||
|
// The amount of late packets received.
|
||||||
|
optional uint32 late = 2;
|
||||||
|
// The amount of packets never received.
|
||||||
|
optional uint32 lost = 3;
|
||||||
|
// The amount of nonce resyncs.
|
||||||
|
optional uint32 resync = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RollingStats {
|
||||||
|
// Rolling packet statistics time window as defined on the server.
|
||||||
|
optional uint32 time_window = 1;
|
||||||
|
// Rolling packet statistics for packets received from the client.
|
||||||
|
optional Stats from_client = 2;
|
||||||
|
// Rolling packet statistics for packets sent by the server.
|
||||||
|
optional Stats from_server = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// User whose stats these are.
|
||||||
|
optional uint32 session = 1;
|
||||||
|
// True if the message contains only mutable stats (packets, ping).
|
||||||
|
optional bool stats_only = 2 [default = false];
|
||||||
|
// Full user certificate chain of the user certificate in DER format.
|
||||||
|
repeated bytes certificates = 3;
|
||||||
|
// Packet statistics for packets received from the client.
|
||||||
|
optional Stats from_client = 4;
|
||||||
|
// Packet statistics for packets sent by the server.
|
||||||
|
optional Stats from_server = 5;
|
||||||
|
|
||||||
|
// Amount of UDP packets sent.
|
||||||
|
optional uint32 udp_packets = 6;
|
||||||
|
// Amount of TCP packets sent.
|
||||||
|
optional uint32 tcp_packets = 7;
|
||||||
|
// UDP ping average.
|
||||||
|
optional float udp_ping_avg = 8;
|
||||||
|
// UDP ping variance.
|
||||||
|
optional float udp_ping_var = 9;
|
||||||
|
// TCP ping average.
|
||||||
|
optional float tcp_ping_avg = 10;
|
||||||
|
// TCP ping variance.
|
||||||
|
optional float tcp_ping_var = 11;
|
||||||
|
|
||||||
|
// Client version.
|
||||||
|
optional Version version = 12;
|
||||||
|
// A list of CELT bitstream version constants supported by the client of this
|
||||||
|
// user.
|
||||||
|
repeated int32 celt_versions = 13;
|
||||||
|
// Client IP address.
|
||||||
|
optional bytes address = 14;
|
||||||
|
// Bandwidth used by this client.
|
||||||
|
optional uint32 bandwidth = 15;
|
||||||
|
// Connection duration.
|
||||||
|
optional uint32 onlinesecs = 16;
|
||||||
|
// Duration since last activity.
|
||||||
|
optional uint32 idlesecs = 17;
|
||||||
|
// True if the user has a strong certificate.
|
||||||
|
optional bool strong_certificate = 18 [default = false];
|
||||||
|
optional bool opus = 19 [default = false];
|
||||||
|
|
||||||
|
// Rolling packet statistics
|
||||||
|
optional RollingStats rolling_stats = 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Used by the client to request binary data from the server. By default large
|
||||||
|
// comments or textures are not sent within standard messages but instead the
|
||||||
|
// hash is. If the client does not recognize the hash it may request the
|
||||||
|
// resource when it needs it. The client does so by sending a RequestBlob
|
||||||
|
// message with the correct fields filled with the user sessions or channel_ids
|
||||||
|
// it wants to receive. The server replies to this by sending a new
|
||||||
|
// UserState/ChannelState message with the resources filled even if they would
|
||||||
|
// normally be transmitted as hashes.
|
||||||
|
message RequestBlob {
|
||||||
|
// sessions of the requested UserState textures.
|
||||||
|
repeated uint32 session_texture = 1;
|
||||||
|
// sessions of the requested UserState comments.
|
||||||
|
repeated uint32 session_comment = 2;
|
||||||
|
// channel_ids of the requested ChannelState descriptions.
|
||||||
|
repeated uint32 channel_description = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sent by the server when it informs the clients on server configuration
|
||||||
|
// details.
|
||||||
|
message ServerConfig {
|
||||||
|
// The maximum bandwidth the clients should use.
|
||||||
|
optional uint32 max_bandwidth = 1;
|
||||||
|
// Server welcome text.
|
||||||
|
optional string welcome_text = 2;
|
||||||
|
// True if the server allows HTML.
|
||||||
|
optional bool allow_html = 3;
|
||||||
|
// Maximum text message length.
|
||||||
|
optional uint32 message_length = 4;
|
||||||
|
// Maximum image message length.
|
||||||
|
optional uint32 image_message_length = 5;
|
||||||
|
// The maximum number of users allowed on the server.
|
||||||
|
optional uint32 max_users = 6;
|
||||||
|
// Whether using Mumble's recording feature is allowed on the server
|
||||||
|
optional bool recording_allowed = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sent by the server to inform the clients of suggested client configuration
|
||||||
|
// specified by the server administrator.
|
||||||
|
message SuggestConfig {
|
||||||
|
// Suggested client version in the legacy format.
|
||||||
|
optional uint32 version_v1 = 1;
|
||||||
|
// Suggested client version in the new format.
|
||||||
|
// Necessary since patch level may exceed 255. (See https://github.com/mumble-voip/mumble/issues/5827)
|
||||||
|
optional uint64 version_v2 = 4;
|
||||||
|
|
||||||
|
// True if the administrator suggests positional audio to be used on this
|
||||||
|
// server.
|
||||||
|
optional bool positional = 2;
|
||||||
|
// True if the administrator suggests push to talk to be used on this server.
|
||||||
|
optional bool push_to_talk = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Used to send plugin messages between clients
|
||||||
|
message PluginDataTransmission {
|
||||||
|
// The session ID of the client this message was sent from
|
||||||
|
optional uint32 senderSession = 1;
|
||||||
|
// The session IDs of the clients that should receive this message
|
||||||
|
repeated uint32 receiverSessions = 2 [packed = true];
|
||||||
|
// The data that is sent
|
||||||
|
optional bytes data = 3;
|
||||||
|
// The ID of the sent data. This will be used by plugins to check whether they will
|
||||||
|
// process it or not
|
||||||
|
optional string dataID = 4;
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// Copyright The Mumble Developers. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-style license
|
||||||
|
// that can be found in the LICENSE file at the root of the
|
||||||
|
// Mumble source tree or at <https://www.mumble.info/LICENSE>.
|
||||||
|
|
||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package MumbleUDP;
|
||||||
|
|
||||||
|
option optimize_for = SPEED;
|
||||||
|
|
||||||
|
message Audio {
|
||||||
|
oneof Header {
|
||||||
|
// When this audio is sent by the client to the server, this is set to the target of the audio data. This target
|
||||||
|
// is a number in the range [0, 2^{32} - 1], where 0 means "normal talking", 2^{5} - 1 means "server loopback"
|
||||||
|
// and all other targets are understood as shout/whisper targets that have previously been registered via a
|
||||||
|
// VoiceTarget message (via TCP).
|
||||||
|
uint32 target = 1;
|
||||||
|
// When this audio is sent by the server to the client, this indicates the context in which the audio has been sent.
|
||||||
|
// 0: Normal speech
|
||||||
|
// 1: Shout to channel
|
||||||
|
// 2: Whisper to user
|
||||||
|
// 3: Received via channel listener
|
||||||
|
uint32 context = 2;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The session of the client (sender) this audio was originally sent from. This field is not required when sending
|
||||||
|
// audio to the server, but will always be set when receiving audio from the server.
|
||||||
|
uint32 sender_session = 3;
|
||||||
|
|
||||||
|
// The number of the first contained audio frame (indicating the position of that frame in the overall audio stream)
|
||||||
|
uint64 frame_number = 4;
|
||||||
|
|
||||||
|
// The actual voice data payload in the Opus format.
|
||||||
|
bytes opus_data = 5;
|
||||||
|
|
||||||
|
// Optional positional data indicating the speaker's position in a virtual world (in meters). This "list" is really
|
||||||
|
// expected to be an array of size 3 containing the X, Y and Z coordinates of the position (in that order).
|
||||||
|
repeated float positional_data = 6;
|
||||||
|
|
||||||
|
// A volume adjustment determined by the server for this audio packet. It is up to the client to apply this adjustment to
|
||||||
|
// the resulting audio (or not). Note: A value of 0 means that this field is unset.
|
||||||
|
float volume_adjustment = 7;
|
||||||
|
|
||||||
|
// Note that we skip the field indices up to (including) 15 in order to have them available for future extensions of the
|
||||||
|
// protocol with fields that are encountered very often. The reason is that all field indices <= 15 require only a single
|
||||||
|
// byte of encoding overhead, whereas the once > 15 require (at least) two bytes. The reason lies in the Protobuf encoding
|
||||||
|
// scheme that uses 1 bit for a varint continuation flag, 3 bit to encode a field's type and the remaining 4 bit of the
|
||||||
|
// first byte are thus available for the field index. Therefore the first 2^4 = 16 field indices (aka values 0 to 15) can
|
||||||
|
// be encoded using only a single byte. For details see https://developers.google.com/protocol-buffers/docs/encoding
|
||||||
|
|
||||||
|
// A flag indicating whether this audio packet represents the end of transmission for the current audio stream
|
||||||
|
bool is_terminator = 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ping message for checking UDP connectivity (and roundtrip ping) and potentially obtaining further server
|
||||||
|
* details (e.g. version).
|
||||||
|
*/
|
||||||
|
message Ping {
|
||||||
|
// Timestamp as encoded by the client. A server is not supposed to attempt to decode or modify this field. Therefore,
|
||||||
|
// clients may choose an arbitrary format for this timestamp (as long as it fits into a uint64 field).
|
||||||
|
uint64 timestamp = 1;
|
||||||
|
|
||||||
|
// A flag set by the sending client, if it wants to obtain additional information about the server.
|
||||||
|
bool request_extended_information = 2;
|
||||||
|
|
||||||
|
|
||||||
|
// Below are the fields for the "additional information" that are filled out by the server on request.
|
||||||
|
|
||||||
|
// The version of the server in the new version format.
|
||||||
|
// The new protobuf Ping packet introduced with 1.5 drops support for the legacy version format
|
||||||
|
// since both server and client have to support this new format.
|
||||||
|
// (See https://github.com/mumble-voip/mumble/issues/5827)
|
||||||
|
uint64 server_version_v2 = 3;
|
||||||
|
|
||||||
|
// The amount of users currently connected to the server
|
||||||
|
uint32 user_count = 4;
|
||||||
|
|
||||||
|
// The maximum amount of users permitted on this server
|
||||||
|
uint32 max_user_count = 5;
|
||||||
|
|
||||||
|
// The maximum bandwidth each user is allowed to use for sending audio to the server
|
||||||
|
uint32 max_bandwidth_per_user = 6;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import * as esbuild from 'esbuild';
|
||||||
|
|
||||||
|
// Bundles the Electron main and preload scripts to CommonJS in dist-electron/.
|
||||||
|
export const options = {
|
||||||
|
entryPoints: { main: 'electron/main.ts', preload: 'electron/preload.ts' },
|
||||||
|
outdir: 'dist-electron',
|
||||||
|
outExtension: { '.js': '.cjs' },
|
||||||
|
bundle: true,
|
||||||
|
platform: 'node',
|
||||||
|
format: 'cjs',
|
||||||
|
target: 'node22',
|
||||||
|
external: ['electron'],
|
||||||
|
sourcemap: true,
|
||||||
|
logLevel: 'info'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (import.meta.url === `file://${process.argv[1]}`) await esbuild.build(options);
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import * as esbuild from 'esbuild';
|
||||||
|
import { createServer } from 'vite';
|
||||||
|
import electronPath from 'electron';
|
||||||
|
import { options } from './build-electron.mjs';
|
||||||
|
|
||||||
|
// Vite dev server for the renderer, esbuild watch for main/preload, Electron restarted on main changes.
|
||||||
|
const server = await createServer();
|
||||||
|
await server.listen();
|
||||||
|
const url = server.resolvedUrls.local[0];
|
||||||
|
|
||||||
|
let child = null;
|
||||||
|
const startElectron = () => {
|
||||||
|
if (child) {
|
||||||
|
child.removeAllListeners('exit');
|
||||||
|
child.kill();
|
||||||
|
}
|
||||||
|
// ELECTRON_RUN_AS_NODE (set by some editor terminals) would start Electron as plain Node
|
||||||
|
const { ELECTRON_RUN_AS_NODE, ...env } = process.env;
|
||||||
|
child = spawn(electronPath, ['.'], { stdio: 'inherit', env: { ...env, VITE_DEV_SERVER_URL: url } });
|
||||||
|
child.on('exit', () => { server.close(); process.exit(0); });
|
||||||
|
};
|
||||||
|
|
||||||
|
const ctx = await esbuild.context({
|
||||||
|
...options,
|
||||||
|
plugins: [{ name: 'restart', setup(b) { b.onEnd(r => { if (!r.errors.length) startElectron(); }); } }]
|
||||||
|
});
|
||||||
|
await ctx.watch();
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ServerRail from './ui/ServerRail.svelte';
|
||||||
|
import Sidebar from './ui/Sidebar.svelte';
|
||||||
|
import Chat from './ui/Chat.svelte';
|
||||||
|
import RightPanel from './ui/RightPanel.svelte';
|
||||||
|
import ConnectDialog from './ui/ConnectDialog.svelte';
|
||||||
|
import SettingsDialog from './ui/SettingsDialog.svelte';
|
||||||
|
import CertDialog from './ui/CertDialog.svelte';
|
||||||
|
import CertViewer from './ui/CertViewer.svelte';
|
||||||
|
import PromptDialog from './ui/PromptDialog.svelte';
|
||||||
|
import ContextMenu from './ui/ContextMenu.svelte';
|
||||||
|
import IdentityWizard from './ui/IdentityWizard.svelte';
|
||||||
|
import QuickAudio from './ui/QuickAudio.svelte';
|
||||||
|
import UserInfoDialog from './ui/UserInfoDialog.svelte';
|
||||||
|
import { identities } from './lib/identities.svelte.ts';
|
||||||
|
import { session } from './lib/session.svelte.ts';
|
||||||
|
import { ui } from './lib/ui.svelte.ts';
|
||||||
|
import type { SavedServer } from './lib/settings.svelte.ts';
|
||||||
|
|
||||||
|
let width = $state(window.innerWidth);
|
||||||
|
// wide: rail + sidebar + chat + panel; medium: panel as an overlay; narrow: everything but chat in drawers
|
||||||
|
const layout = $derived(width >= 1100 ? 'wide' : width >= 760 ? 'medium' : 'narrow');
|
||||||
|
|
||||||
|
let navOpen = $state(false);
|
||||||
|
let editing = $state<{ server: SavedServer | null } | null>(null);
|
||||||
|
|
||||||
|
const showPanel = $derived(ui.panelOpen && session.status === 'connected');
|
||||||
|
|
||||||
|
// The panel starts open on wide layouts only
|
||||||
|
let lastLayout = '';
|
||||||
|
$effect(() => {
|
||||||
|
if (layout === lastLayout) return;
|
||||||
|
ui.panelOpen = layout === 'wide';
|
||||||
|
lastLayout = layout;
|
||||||
|
});
|
||||||
|
// On phones one drawer at a time
|
||||||
|
$effect(() => { if (layout === 'narrow' && ui.panelOpen) navOpen = false; });
|
||||||
|
|
||||||
|
function togglePanel() {
|
||||||
|
if (!ui.panelOpen) ui.panelOpen = true;
|
||||||
|
else if (session.panel.kind !== 'members') session.closePanel();
|
||||||
|
else ui.panelOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeOverlays = () => {
|
||||||
|
navOpen = false;
|
||||||
|
if (layout !== 'wide') ui.panelOpen = false;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window bind:innerWidth={width} />
|
||||||
|
|
||||||
|
<div class="app {layout}">
|
||||||
|
{#if layout !== 'narrow' || navOpen}
|
||||||
|
<div class="nav" class:drawer={layout === 'narrow'}>
|
||||||
|
<ServerRail onadd={() => (editing = { server: null })} onedit={s => (editing = { server: s })}
|
||||||
|
onsettings={() => (ui.settingsOpen = true)} />
|
||||||
|
<Sidebar onnavigate={() => { if (layout === 'narrow') navOpen = false; }} />
|
||||||
|
</div>
|
||||||
|
{#if layout === 'narrow'}<button class="scrim" aria-label="Close" onclick={() => (navOpen = false)}></button>{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Chat showMenu={layout === 'narrow'} onmenu={() => (navOpen = true)} onpanel={togglePanel} />
|
||||||
|
|
||||||
|
{#if showPanel}
|
||||||
|
{#if layout === 'wide'}
|
||||||
|
<RightPanel onnavigate={() => {}} onclose={() => (ui.panelOpen = false)} />
|
||||||
|
{:else}
|
||||||
|
<button class="scrim" aria-label="Close" onclick={() => (ui.panelOpen = false)}></button>
|
||||||
|
<div class="panel-drawer"><RightPanel onnavigate={closeOverlays} onclose={() => (ui.panelOpen = false)} /></div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if editing}<ConnectDialog server={editing.server} onclose={() => (editing = null)} />{/if}
|
||||||
|
{#if ui.settingsOpen}<SettingsDialog tab={ui.settingsTab} onclose={() => { ui.settingsOpen = false; ui.settingsTab = 'voice'; }} />{/if}
|
||||||
|
{#if ui.quickAudio}<QuickAudio />{/if}
|
||||||
|
{#if ui.userInfo != null && session.status === 'connected'}
|
||||||
|
{#key ui.userInfo}<UserInfoDialog target={ui.userInfo} onclose={() => (ui.userInfo = null)} />{/key}
|
||||||
|
{/if}
|
||||||
|
{#if session.certPrompt}<CertDialog />{/if}
|
||||||
|
{#if ui.cert}<CertViewer {...ui.cert} onclose={() => (ui.cert = null)} />{/if}
|
||||||
|
{#if identities.loaded && !identities.setupDone}<IdentityWizard mode="setup" onclose={() => {}} />
|
||||||
|
{:else if ui.wizard}<IdentityWizard mode={ui.wizard} onclose={() => (ui.wizard = null)} />{/if}
|
||||||
|
{#if ui.prompt}<PromptDialog {...ui.prompt} onclose={() => (ui.prompt = null)} />{/if}
|
||||||
|
<ContextMenu />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.app { position: relative; height: 100%; display: flex; overflow: hidden; }
|
||||||
|
.nav { display: flex; flex: none; min-height: 0; }
|
||||||
|
.nav.drawer { position: absolute; z-index: 20; top: 0; bottom: 0; left: 0; max-width: calc(100% - 48px); box-shadow: 8px 0 32px rgb(0 0 0 / 0.4); }
|
||||||
|
.nav.drawer :global(.sidebar) { width: min(260px, calc(100vw - 68px - 48px)); }
|
||||||
|
.scrim { position: absolute; inset: 0; z-index: 15; background: rgb(0 0 0 / 0.5); }
|
||||||
|
.panel-drawer { position: absolute; z-index: 20; top: 0; right: 0; bottom: 0; max-width: calc(100% - 48px); display: flex; box-shadow: -8px 0 32px rgb(0 0 0 / 0.4); }
|
||||||
|
</style>
|
||||||
+81
@@ -0,0 +1,81 @@
|
|||||||
|
:root {
|
||||||
|
--bg-0: #0d0e10;
|
||||||
|
--bg-1: #151619;
|
||||||
|
--bg-2: #1b1c20;
|
||||||
|
--bg-3: #24262b;
|
||||||
|
--bg-hover: #2a2c32;
|
||||||
|
--bg-active: #33363d;
|
||||||
|
--line: #2c2e34;
|
||||||
|
--text: #e3e4e8;
|
||||||
|
--text-dim: #9a9ca5;
|
||||||
|
--text-faint: #686a73;
|
||||||
|
--accent: #7c6cff;
|
||||||
|
--accent-hover: #8f81ff;
|
||||||
|
--accent-text: #ffffff;
|
||||||
|
--speaking: #3bd16f;
|
||||||
|
--danger: #f0504a;
|
||||||
|
--warn: #e8b23c;
|
||||||
|
--link: #6fb3ff;
|
||||||
|
|
||||||
|
--font: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell, sans-serif;
|
||||||
|
--mono: ui-monospace, "Cascadia Code", "JetBrains Mono", Menlo, Consolas, monospace;
|
||||||
|
--fs: 15px;
|
||||||
|
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *::before, *::after { box-sizing: border-box; border-radius: 0 !important; }
|
||||||
|
|
||||||
|
html, body, #app { height: 100%; margin: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg-2);
|
||||||
|
color: var(--text);
|
||||||
|
font: var(--fs)/1.45 var(--font);
|
||||||
|
overflow: hidden;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: var(--link); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
|
||||||
|
button:disabled { cursor: default; opacity: 0.5; }
|
||||||
|
|
||||||
|
input, textarea, select {
|
||||||
|
font: inherit;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg-0);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
padding: 8px 10px;
|
||||||
|
outline: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
input:focus, textarea:focus, select:focus { border-color: var(--accent); }
|
||||||
|
|
||||||
|
label { display: block; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-dim); margin: 14px 0 6px; }
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
|
||||||
|
padding: 8px 16px; font-weight: 600; background: var(--bg-3); border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.btn:hover:not(:disabled) { background: var(--bg-hover); }
|
||||||
|
.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
|
||||||
|
.btn.primary:hover:not(:disabled) { background: var(--accent-hover); }
|
||||||
|
.btn.danger { background: transparent; border-color: var(--danger); color: var(--danger); }
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
width: 32px; height: 32px; color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.icon-btn:hover { background: var(--bg-hover); color: var(--text); }
|
||||||
|
.icon-btn.on { color: var(--danger); }
|
||||||
|
|
||||||
|
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||||
|
::-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-track { background: transparent; }
|
||||||
|
|
||||||
|
:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||||
|
|
||||||
|
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
import { Emitter } from './emitter.ts';
|
||||||
|
import { FrameReader, frame, type Codec, type MessageName } from './proto.ts';
|
||||||
|
import type { Transport } from './transport.ts';
|
||||||
|
|
||||||
|
export const CLIENT_RELEASE = 'mumh5 0.1.0';
|
||||||
|
const VERSION = { major: 1, minor: 5, patch: 0 };
|
||||||
|
const PING_INTERVAL_MS = 15000;
|
||||||
|
|
||||||
|
// Murmur drops these messages without telling the client when they come too fast
|
||||||
|
// (leaky bucket, defaults messageburst=5 and messagelimit=1/s). Mirroring the bucket and
|
||||||
|
// queueing keeps quick actions from getting lost. Slightly slower refill for safety.
|
||||||
|
const RATE_BURST = 5;
|
||||||
|
const RATE_REFILL_MS = 1100;
|
||||||
|
const RATE_LIMITED = new Set<MessageName>(['TextMessage', 'ChannelState', 'ACL', 'Version']);
|
||||||
|
|
||||||
|
export interface Channel {
|
||||||
|
id: number;
|
||||||
|
parent: number | null;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
descriptionHash: Uint8Array | null;
|
||||||
|
position: number;
|
||||||
|
temporary: boolean;
|
||||||
|
links: Set<number>;
|
||||||
|
maxUsers: number;
|
||||||
|
canEnter: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
session: number;
|
||||||
|
name: string;
|
||||||
|
userId: number | null;
|
||||||
|
channelId: number;
|
||||||
|
mute: boolean;
|
||||||
|
deaf: boolean;
|
||||||
|
suppress: boolean;
|
||||||
|
selfMute: boolean;
|
||||||
|
selfDeaf: boolean;
|
||||||
|
prioritySpeaker: boolean;
|
||||||
|
recording: boolean;
|
||||||
|
comment: string;
|
||||||
|
commentHash: Uint8Array | null;
|
||||||
|
texture: Uint8Array | null;
|
||||||
|
textureHash: Uint8Array | null;
|
||||||
|
hash: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextMessage {
|
||||||
|
actor: number | null;
|
||||||
|
sessions: number[];
|
||||||
|
channels: number[];
|
||||||
|
trees: number[];
|
||||||
|
html: string;
|
||||||
|
time: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerConfig {
|
||||||
|
allowHtml: boolean;
|
||||||
|
messageLength: number;
|
||||||
|
imageMessageLength: number;
|
||||||
|
maxUsers: number;
|
||||||
|
recordingAllowed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Denial {
|
||||||
|
type: number;
|
||||||
|
permission: number;
|
||||||
|
reason: string;
|
||||||
|
channelId: number | null;
|
||||||
|
session: number | null;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ACL permission bits, as used in PermissionDenied.permission
|
||||||
|
export const PERMISSIONS: Record<number, string> = {
|
||||||
|
0x1: 'Write ACL', 0x2: 'Traverse', 0x4: 'Enter', 0x8: 'Speak', 0x10: 'Mute/Deafen', 0x20: 'Move',
|
||||||
|
0x40: 'Make channel', 0x80: 'Link channel', 0x100: 'Whisper', 0x200: 'Text message',
|
||||||
|
0x400: 'Make temporary channel', 0x800: 'Listen', 0x10000: 'Kick', 0x20000: 'Ban',
|
||||||
|
0x40000: 'Register', 0x80000: 'Register self', 0x100000: 'Reset user content'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Human-readable text for a PermissionDenied message
|
||||||
|
export function describeDenial(d: Denial, channelName?: string): string {
|
||||||
|
switch (d.type) {
|
||||||
|
case 0: return d.reason || 'Denied by the server';
|
||||||
|
case 1: {
|
||||||
|
const what = PERMISSIONS[d.permission] ?? 'this action';
|
||||||
|
return `You do not have permission for ${what}${channelName ? ` in ${channelName}` : ''}`;
|
||||||
|
}
|
||||||
|
case 2: return 'The SuperUser cannot be modified';
|
||||||
|
case 3: return 'Invalid channel name';
|
||||||
|
case 4: return 'Message too long for this server';
|
||||||
|
case 6: return 'Not allowed in a temporary channel';
|
||||||
|
case 7: return 'This needs a registered certificate';
|
||||||
|
case 8: return `Invalid user name${d.name ? `: ${d.name}` : ''}`;
|
||||||
|
case 9: return 'The channel is full';
|
||||||
|
case 10: return 'Channels are nested too deeply';
|
||||||
|
case 11: return 'The server has reached its channel limit';
|
||||||
|
default: return d.reason || 'Denied by the server';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectOptions {
|
||||||
|
username: string;
|
||||||
|
password?: string;
|
||||||
|
tokens?: string[];
|
||||||
|
os?: string;
|
||||||
|
osVersion?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClientEvents = {
|
||||||
|
synced: () => void;
|
||||||
|
channel: (ch: Channel) => void;
|
||||||
|
channelRemove: (id: number) => void;
|
||||||
|
user: (user: User, changed: string[], actor: number | null, isNew: boolean) => void;
|
||||||
|
userRemove: (user: User, actor: number | null, reason: string, ban: boolean) => void;
|
||||||
|
text: (msg: TextMessage) => void;
|
||||||
|
pluginData: (sender: number, dataId: string, data: Uint8Array) => void;
|
||||||
|
permissionDenied: (msg: Denial) => void;
|
||||||
|
reject: (type: number, reason: string) => void;
|
||||||
|
voice: (packet: Uint8Array) => void;
|
||||||
|
userStats: (stats: any) => void;
|
||||||
|
ping: (rttMs: number) => void;
|
||||||
|
close: (reason: string) => void;
|
||||||
|
message: (name: MessageName, msg: any) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class MumbleClient extends Emitter<ClientEvents> {
|
||||||
|
readonly channels = new Map<number, Channel>();
|
||||||
|
readonly users = new Map<number, User>();
|
||||||
|
session: number | null = null;
|
||||||
|
synced = false;
|
||||||
|
welcomeText = '';
|
||||||
|
maxBandwidth = 0;
|
||||||
|
serverVersion = '';
|
||||||
|
// Numeric server version (major << 16 | minor << 8 | patch), 0 until known
|
||||||
|
serverVersionNum = 0;
|
||||||
|
rtt = 0;
|
||||||
|
config: ServerConfig = { allowHtml: true, messageLength: 5000, imageMessageLength: 131072, maxUsers: 0, recordingAllowed: true };
|
||||||
|
|
||||||
|
private transport: Transport | null = null;
|
||||||
|
private reader = new FrameReader();
|
||||||
|
private pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private closed = false;
|
||||||
|
private tokens = RATE_BURST;
|
||||||
|
private lastRefill = Date.now();
|
||||||
|
private queue: Uint8Array[] = [];
|
||||||
|
private queueTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
private codec: Codec;
|
||||||
|
|
||||||
|
constructor(codec: Codec) {
|
||||||
|
super();
|
||||||
|
this.codec = codec;
|
||||||
|
}
|
||||||
|
|
||||||
|
get self(): User | null {
|
||||||
|
return this.session == null ? null : this.users.get(this.session) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
connect(transport: Transport, opts: ConnectOptions): void {
|
||||||
|
this.transport = transport;
|
||||||
|
transport.onData = chunk => {
|
||||||
|
try {
|
||||||
|
this.reader.push(chunk, (id, body) => this.handleFrame(id, body));
|
||||||
|
} catch (e) {
|
||||||
|
this.disconnect(`Protocol error: ${(e as Error).message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
transport.onClose = reason => this.handleClose(reason);
|
||||||
|
|
||||||
|
const { major, minor, patch } = VERSION;
|
||||||
|
this.send('Version', {
|
||||||
|
version_v1: (major << 16) | (minor << 8) | patch,
|
||||||
|
version_v2: major * 2 ** 48 + minor * 2 ** 32 + patch * 2 ** 16,
|
||||||
|
release: CLIENT_RELEASE,
|
||||||
|
os: opts.os ?? 'unknown',
|
||||||
|
os_version: opts.osVersion ?? ''
|
||||||
|
});
|
||||||
|
this.send('Authenticate', {
|
||||||
|
username: opts.username,
|
||||||
|
password: opts.password ?? '',
|
||||||
|
tokens: opts.tokens ?? [],
|
||||||
|
opus: true,
|
||||||
|
client_type: 0
|
||||||
|
});
|
||||||
|
this.pingTimer = setInterval(() => this.ping(), PING_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect(reason = 'Disconnected'): void {
|
||||||
|
this.transport?.close();
|
||||||
|
this.handleClose(reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
send(name: MessageName, payload: Record<string, unknown>): void {
|
||||||
|
if (!this.transport || this.closed) return;
|
||||||
|
const bytes = this.codec.encode(name, payload);
|
||||||
|
// Only changes to our own user state count against the bucket
|
||||||
|
const ownState = name === 'UserState' && (payload.session == null || payload.session === this.session);
|
||||||
|
if (RATE_LIMITED.has(name) || ownState) this.sendPaced(bytes);
|
||||||
|
else this.transport.send(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Messages still waiting for the rate limit
|
||||||
|
get queued(): number {
|
||||||
|
return this.queue.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
private refill(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const gained = Math.floor((now - this.lastRefill) / RATE_REFILL_MS);
|
||||||
|
if (gained > 0) {
|
||||||
|
this.tokens = Math.min(RATE_BURST, this.tokens + gained);
|
||||||
|
this.lastRefill += gained * RATE_REFILL_MS;
|
||||||
|
}
|
||||||
|
if (this.tokens === RATE_BURST) this.lastRefill = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendPaced(bytes: Uint8Array): void {
|
||||||
|
this.queue.push(bytes);
|
||||||
|
this.drain();
|
||||||
|
}
|
||||||
|
|
||||||
|
private drain(): void {
|
||||||
|
if (this.queueTimer || this.closed) return;
|
||||||
|
this.refill();
|
||||||
|
while (this.queue.length && this.tokens > 0) {
|
||||||
|
this.tokens--;
|
||||||
|
this.transport?.send(this.queue.shift()!);
|
||||||
|
}
|
||||||
|
if (this.queue.length) {
|
||||||
|
this.queueTimer = setTimeout(() => { this.queueTimer = null; this.drain(); }, RATE_REFILL_MS - (Date.now() - this.lastRefill) + 5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Actions ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
sendText(target: { channels?: number[]; users?: number[]; trees?: number[] }, html: string): void {
|
||||||
|
this.send('TextMessage', {
|
||||||
|
session: target.users ?? [],
|
||||||
|
channel_id: target.channels ?? [],
|
||||||
|
tree_id: target.trees ?? [],
|
||||||
|
message: html
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
joinChannel(channelId: number): void {
|
||||||
|
if (this.session == null) return;
|
||||||
|
this.send('UserState', { session: this.session, channel_id: channelId });
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelfMute(mute: boolean): void {
|
||||||
|
// Unmuting also undeafens, matching the desktop client
|
||||||
|
this.send('UserState', mute ? { self_mute: true } : { self_mute: false, self_deaf: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelfDeaf(deaf: boolean): void {
|
||||||
|
// Deafening implies muting
|
||||||
|
this.send('UserState', deaf ? { self_mute: true, self_deaf: true } : { self_deaf: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
setComment(comment: string): void {
|
||||||
|
if (this.session == null) return;
|
||||||
|
this.send('UserState', { session: this.session, comment });
|
||||||
|
}
|
||||||
|
|
||||||
|
kick(session: number, reason: string, ban = false): void {
|
||||||
|
this.send('UserRemove', { session, reason, ban });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server-side (admin) state of another user
|
||||||
|
setUserState(session: number, state: { mute?: boolean; deaf?: boolean; priority_speaker?: boolean; channel_id?: number }): void {
|
||||||
|
this.send('UserState', { session, ...state });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connection details of a user; the server decides how much we may see
|
||||||
|
requestUserStats(session: number, statsOnly = false): void {
|
||||||
|
this.send('UserStats', { session, stats_only: statsOnly });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch comments/descriptions/textures that the server only announced by hash.
|
||||||
|
requestBlob(req: { textures?: number[]; comments?: number[]; descriptions?: number[] }): void {
|
||||||
|
this.send('RequestBlob', {
|
||||||
|
session_texture: req.textures ?? [],
|
||||||
|
session_comment: req.comments ?? [],
|
||||||
|
channel_description: req.descriptions ?? []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
sendPluginData(receivers: number[], dataId: string, data: Uint8Array): void {
|
||||||
|
if (this.session == null || !receivers.length) return;
|
||||||
|
this.send('PluginDataTransmission', {
|
||||||
|
senderSession: this.session,
|
||||||
|
receiverSessions: receivers,
|
||||||
|
data,
|
||||||
|
dataID: dataId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Voice over TCP: the UDPTunnel body is the raw voice packet, not a protobuf message
|
||||||
|
sendVoiceTunnel(packet: Uint8Array): void {
|
||||||
|
if (!this.transport || this.closed) return;
|
||||||
|
this.transport.send(frame(1, packet));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1.5+ servers talk to 1.5+ clients in the protobuf voice format
|
||||||
|
get protobufVoice(): boolean {
|
||||||
|
return this.serverVersionNum >= 0x010500;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registers a user (ourselves or, with the Register permission, someone else) on the server
|
||||||
|
register(session: number): void {
|
||||||
|
this.send('UserState', { session, user_id: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Incoming ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private ping(): void {
|
||||||
|
this.send('Ping', { timestamp: Date.now() });
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleClose(reason: string): void {
|
||||||
|
if (this.closed) return;
|
||||||
|
this.closed = true;
|
||||||
|
if (this.pingTimer) clearInterval(this.pingTimer);
|
||||||
|
if (this.queueTimer) clearTimeout(this.queueTimer);
|
||||||
|
this.pingTimer = null;
|
||||||
|
this.queueTimer = null;
|
||||||
|
this.queue = [];
|
||||||
|
this.emit('close', reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleFrame(typeId: number, body: Uint8Array): void {
|
||||||
|
// UDPTunnel carries a raw voice packet, not a protobuf message
|
||||||
|
if (typeId === 1) {
|
||||||
|
this.emit('voice', body.slice());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const decoded = this.codec.decode(typeId, body);
|
||||||
|
if (!decoded) return;
|
||||||
|
const { name, msg } = decoded;
|
||||||
|
|
||||||
|
switch (name) {
|
||||||
|
case 'Version':
|
||||||
|
this.serverVersion = msg.release ?? '';
|
||||||
|
if (msg.version_v2) {
|
||||||
|
const v = Number(msg.version_v2);
|
||||||
|
this.serverVersionNum = (Math.floor(v / 2 ** 48) << 16) | ((Math.floor(v / 2 ** 32) & 0xffff) << 8) | (Math.floor(v / 2 ** 16) & 0xffff);
|
||||||
|
} else if (msg.version_v1) {
|
||||||
|
this.serverVersionNum = msg.version_v1;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'Ping':
|
||||||
|
if (msg.timestamp) {
|
||||||
|
this.rtt = Date.now() - Number(msg.timestamp);
|
||||||
|
this.emit('ping', this.rtt);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'Reject':
|
||||||
|
this.emit('reject', msg.type ?? 0, msg.reason ?? 'Rejected');
|
||||||
|
this.disconnect(msg.reason || 'Connection rejected');
|
||||||
|
break;
|
||||||
|
case 'ServerSync':
|
||||||
|
this.session = msg.session;
|
||||||
|
this.maxBandwidth = msg.max_bandwidth ?? 0;
|
||||||
|
this.welcomeText = msg.welcome_text ?? '';
|
||||||
|
this.synced = true;
|
||||||
|
this.ping();
|
||||||
|
this.emit('synced');
|
||||||
|
break;
|
||||||
|
case 'ServerConfig':
|
||||||
|
if (msg.allow_html != null) this.config.allowHtml = msg.allow_html;
|
||||||
|
if (msg.message_length != null) this.config.messageLength = msg.message_length;
|
||||||
|
if (msg.image_message_length != null) this.config.imageMessageLength = msg.image_message_length;
|
||||||
|
if (msg.max_users != null) this.config.maxUsers = msg.max_users;
|
||||||
|
if (msg.recording_allowed != null) this.config.recordingAllowed = msg.recording_allowed;
|
||||||
|
break;
|
||||||
|
case 'ChannelState':
|
||||||
|
this.applyChannelState(msg);
|
||||||
|
break;
|
||||||
|
case 'ChannelRemove':
|
||||||
|
this.channels.delete(msg.channel_id);
|
||||||
|
this.emit('channelRemove', msg.channel_id);
|
||||||
|
break;
|
||||||
|
case 'UserState':
|
||||||
|
this.applyUserState(msg);
|
||||||
|
break;
|
||||||
|
case 'UserRemove': {
|
||||||
|
const user = this.users.get(msg.session);
|
||||||
|
if (user) {
|
||||||
|
this.users.delete(msg.session);
|
||||||
|
this.emit('userRemove', user, msg.actor ?? null, msg.reason ?? '', !!msg.ban);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'TextMessage':
|
||||||
|
this.emit('text', {
|
||||||
|
actor: msg.actor ?? null,
|
||||||
|
sessions: msg.session ?? [],
|
||||||
|
channels: msg.channel_id ?? [],
|
||||||
|
trees: msg.tree_id ?? [],
|
||||||
|
html: msg.message ?? '',
|
||||||
|
time: Date.now()
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'PermissionDenied':
|
||||||
|
this.emit('permissionDenied', {
|
||||||
|
type: msg.type ?? 0,
|
||||||
|
permission: msg.permission ?? 0,
|
||||||
|
reason: msg.reason ?? '',
|
||||||
|
channelId: msg.channel_id ?? null,
|
||||||
|
session: msg.session ?? null,
|
||||||
|
name: msg.name ?? ''
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'UserStats':
|
||||||
|
this.emit('userStats', msg);
|
||||||
|
break;
|
||||||
|
case 'PluginDataTransmission':
|
||||||
|
if (msg.senderSession != null && msg.dataID) {
|
||||||
|
this.emit('pluginData', msg.senderSession, msg.dataID, msg.data ?? new Uint8Array(0));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
this.emit('message', name, msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyChannelState(msg: any): void {
|
||||||
|
const id: number = msg.channel_id;
|
||||||
|
let ch = this.channels.get(id);
|
||||||
|
if (!ch) {
|
||||||
|
ch = {
|
||||||
|
id, parent: null, name: '', description: '', descriptionHash: null, position: 0,
|
||||||
|
temporary: false, links: new Set(), maxUsers: 0, canEnter: true
|
||||||
|
};
|
||||||
|
this.channels.set(id, ch);
|
||||||
|
}
|
||||||
|
if (msg.parent != null) ch.parent = msg.parent;
|
||||||
|
if (msg.name != null) ch.name = msg.name;
|
||||||
|
if (msg.description_hash != null && msg.description == null) ch.description = '';
|
||||||
|
if (msg.description != null) ch.description = msg.description;
|
||||||
|
if (msg.description_hash != null) ch.descriptionHash = msg.description_hash;
|
||||||
|
if (msg.position != null) ch.position = msg.position;
|
||||||
|
if (msg.temporary != null) ch.temporary = msg.temporary;
|
||||||
|
if (msg.max_users != null) ch.maxUsers = msg.max_users;
|
||||||
|
if (msg.can_enter != null) ch.canEnter = msg.can_enter;
|
||||||
|
if (msg.links?.length) ch.links = new Set(msg.links);
|
||||||
|
for (const l of msg.links_add ?? []) ch.links.add(l);
|
||||||
|
for (const l of msg.links_remove ?? []) ch.links.delete(l);
|
||||||
|
this.emit('channel', ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyUserState(msg: any): void {
|
||||||
|
const session: number = msg.session ?? this.session;
|
||||||
|
let user = this.users.get(session);
|
||||||
|
const isNew = !user;
|
||||||
|
if (!user) {
|
||||||
|
user = {
|
||||||
|
session, name: '', userId: null, channelId: 0, mute: false, deaf: false, suppress: false,
|
||||||
|
selfMute: false, selfDeaf: false, prioritySpeaker: false, recording: false,
|
||||||
|
comment: '', commentHash: null, texture: null, textureHash: null, hash: ''
|
||||||
|
};
|
||||||
|
this.users.set(session, user);
|
||||||
|
}
|
||||||
|
const fields: [string, keyof User][] = [
|
||||||
|
['name', 'name'], ['user_id', 'userId'], ['channel_id', 'channelId'], ['mute', 'mute'],
|
||||||
|
['deaf', 'deaf'], ['suppress', 'suppress'], ['self_mute', 'selfMute'], ['self_deaf', 'selfDeaf'],
|
||||||
|
['priority_speaker', 'prioritySpeaker'], ['recording', 'recording'], ['comment', 'comment'],
|
||||||
|
['comment_hash', 'commentHash'], ['texture', 'texture'], ['texture_hash', 'textureHash'], ['hash', 'hash']
|
||||||
|
];
|
||||||
|
// A new hash without text means the text changed and must be fetched (RequestBlob)
|
||||||
|
if (msg.comment_hash != null && msg.comment == null) user.comment = '';
|
||||||
|
const changed: string[] = [];
|
||||||
|
for (const [src, dst] of fields) {
|
||||||
|
if (msg[src] != null) {
|
||||||
|
(user as any)[dst] = msg[src];
|
||||||
|
changed.push(dst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.emit('user', user, changed, msg.actor ?? null, isNew);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// Minimal typed event emitter that works in both browser and Node.
|
||||||
|
export class Emitter<Events extends Record<string, (...args: any[]) => void>> {
|
||||||
|
private handlers = new Map<keyof Events, Set<Function>>();
|
||||||
|
|
||||||
|
on<K extends keyof Events>(event: K, fn: Events[K]): () => void {
|
||||||
|
let set = this.handlers.get(event);
|
||||||
|
if (!set) this.handlers.set(event, (set = new Set()));
|
||||||
|
set.add(fn);
|
||||||
|
return () => set!.delete(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit<K extends keyof Events>(event: K, ...args: Parameters<Events[K]>): void {
|
||||||
|
for (const fn of this.handlers.get(event) ?? []) fn(...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
// Types for the generated static module; the codec in proto.ts adds the real shape.
|
||||||
|
export const MumbleProto: Record<string, unknown>;
|
||||||
File diff suppressed because it is too large
Load Diff
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
export const MumbleUDP: Record<string, any>;
|
||||||
@@ -0,0 +1,639 @@
|
|||||||
|
/*eslint-disable block-scoped-var, id-length, no-control-regex, no-magic-numbers, no-mixed-operators, no-prototype-builtins, no-redeclare, no-shadow, no-var, sort-vars, default-case, jsdoc/require-param*/
|
||||||
|
import $protobuf from "protobufjs/minimal.js";
|
||||||
|
|
||||||
|
const $Reader = $protobuf.Reader, $Writer = $protobuf.Writer, $util = $protobuf.util;
|
||||||
|
const $Object = $util.global.Object, $undefined = $util.global.undefined, $Error = $util.global.Error, $RangeError = $util.global.RangeError, $Array = $util.global.Array, $TypeError = $util.global.TypeError, $Number = $util.global.Number, $parseInt = $util.global.parseInt, $Boolean = $util.global.Boolean, $String = $util.global.String, $BigInt = $util.global.BigInt, $isFinite = $util.global.isFinite;
|
||||||
|
|
||||||
|
const $root = $protobuf.roots["default"] || ($protobuf.roots["default"] = {});
|
||||||
|
|
||||||
|
export const MumbleUDP = $root.MumbleUDP = (() => {
|
||||||
|
|
||||||
|
const MumbleUDP = {};
|
||||||
|
|
||||||
|
MumbleUDP.Audio = (function() {
|
||||||
|
|
||||||
|
const Audio = function (properties) {
|
||||||
|
this.positional_data = [];
|
||||||
|
if (properties)
|
||||||
|
for (let keys = $Object.keys(properties), i = 0; i < keys.length; ++i)
|
||||||
|
if (properties[keys[i]] != null && keys[i] !== "__proto__")
|
||||||
|
this[keys[i]] = properties[keys[i]];
|
||||||
|
};
|
||||||
|
|
||||||
|
Audio.prototype.target = null;
|
||||||
|
Audio.prototype.context = null;
|
||||||
|
Audio.prototype.sender_session = 0;
|
||||||
|
Audio.prototype.frame_number = $util.Long ? $util.Long.fromBits(0,0,true) : 0;
|
||||||
|
Audio.prototype.opus_data = $util.newBuffer([]);
|
||||||
|
Audio.prototype.positional_data = $util.emptyArray;
|
||||||
|
Audio.prototype.volume_adjustment = 0;
|
||||||
|
Audio.prototype.is_terminator = false;
|
||||||
|
|
||||||
|
let $oneOfFields;
|
||||||
|
|
||||||
|
$Object.defineProperty(Audio.prototype, "Header", {
|
||||||
|
get: $util.oneOfGetter($oneOfFields = ["target", "context"]),
|
||||||
|
set: $util.oneOfSetter($oneOfFields)
|
||||||
|
});
|
||||||
|
|
||||||
|
Audio.create = function(properties) {
|
||||||
|
return new Audio(properties);
|
||||||
|
};
|
||||||
|
|
||||||
|
Audio.encode = function (message, writer, _depth) {
|
||||||
|
if (!writer)
|
||||||
|
writer = $Writer.create();
|
||||||
|
if (_depth === $undefined)
|
||||||
|
_depth = 0;
|
||||||
|
if (_depth > $util.recursionLimit)
|
||||||
|
throw $Error("max depth exceeded");
|
||||||
|
if (message.target != null && $Object.hasOwnProperty.call(message, "target"))
|
||||||
|
writer.uint32(8).uint32(message.target);
|
||||||
|
if (message.context != null && $Object.hasOwnProperty.call(message, "context"))
|
||||||
|
writer.uint32(16).uint32(message.context);
|
||||||
|
if (message.sender_session != null && $Object.hasOwnProperty.call(message, "sender_session") && message.sender_session !== 0)
|
||||||
|
writer.uint32(24).uint32(message.sender_session);
|
||||||
|
if (message.frame_number != null && $Object.hasOwnProperty.call(message, "frame_number") && (typeof message.frame_number === "object" ? message.frame_number.low || message.frame_number.high : message.frame_number !== 0))
|
||||||
|
writer.uint32(32).uint64(message.frame_number);
|
||||||
|
if (message.opus_data != null && $Object.hasOwnProperty.call(message, "opus_data") && message.opus_data.length)
|
||||||
|
writer.uint32(42).bytes(message.opus_data);
|
||||||
|
if (message.positional_data != null && message.positional_data.length)
|
||||||
|
writer.uint32(50).floats(message.positional_data);
|
||||||
|
if (message.volume_adjustment != null && $Object.hasOwnProperty.call(message, "volume_adjustment") && !$Object.is(message.volume_adjustment, 0))
|
||||||
|
writer.uint32(61).float(message.volume_adjustment);
|
||||||
|
if (message.is_terminator != null && $Object.hasOwnProperty.call(message, "is_terminator") && message.is_terminator !== false)
|
||||||
|
writer.uint32(128).bool(message.is_terminator);
|
||||||
|
if (message.$unknowns != null && $Object.hasOwnProperty.call(message, "$unknowns"))
|
||||||
|
for (let i = 0; i < message.$unknowns.length; ++i)
|
||||||
|
writer.raw(message.$unknowns[i]);
|
||||||
|
return writer;
|
||||||
|
};
|
||||||
|
|
||||||
|
Audio.decode = function (reader, length, _end, _depth, _target) {
|
||||||
|
if (!(reader instanceof $Reader))
|
||||||
|
reader = $Reader.create(reader);
|
||||||
|
if (_depth === $undefined)
|
||||||
|
_depth = 0;
|
||||||
|
if (_depth > $Reader.recursionLimit)
|
||||||
|
throw $Error("max depth exceeded");
|
||||||
|
let end, message, value;
|
||||||
|
if (length === $undefined)
|
||||||
|
end = reader.len;
|
||||||
|
else {
|
||||||
|
end = reader.pos + length;
|
||||||
|
if (end > reader.len)
|
||||||
|
throw $RangeError("index out of range");
|
||||||
|
length = reader.len;
|
||||||
|
reader.len = end;
|
||||||
|
}
|
||||||
|
message = _target || new $root.MumbleUDP.Audio();
|
||||||
|
while (reader.pos < end) {
|
||||||
|
let start = reader.pos;
|
||||||
|
let tag = reader.tag();
|
||||||
|
if (tag === _end) {
|
||||||
|
_end = $undefined;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let wireType = tag & 7;
|
||||||
|
switch (tag >>>= 3) {
|
||||||
|
case 1: {
|
||||||
|
if (wireType !== 0)
|
||||||
|
break;
|
||||||
|
message.target = reader.uint32();
|
||||||
|
message.Header = "target";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
case 2: {
|
||||||
|
if (wireType !== 0)
|
||||||
|
break;
|
||||||
|
message.context = reader.uint32();
|
||||||
|
message.Header = "context";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
case 3: {
|
||||||
|
if (wireType !== 0)
|
||||||
|
break;
|
||||||
|
if (value = reader.uint32())
|
||||||
|
message.sender_session = value;
|
||||||
|
else
|
||||||
|
delete message.sender_session;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
case 4: {
|
||||||
|
if (wireType !== 0)
|
||||||
|
break;
|
||||||
|
if (typeof (value = reader.uint64()) === "object" ? value.low || value.high : value !== 0)
|
||||||
|
message.frame_number = value;
|
||||||
|
else
|
||||||
|
delete message.frame_number;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
case 5: {
|
||||||
|
if (wireType !== 2)
|
||||||
|
break;
|
||||||
|
if ((value = reader.bytes()).length)
|
||||||
|
message.opus_data = value;
|
||||||
|
else
|
||||||
|
delete message.opus_data;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
case 6: {
|
||||||
|
if (wireType === 2) {
|
||||||
|
if (!(message.positional_data && message.positional_data.length))
|
||||||
|
message.positional_data = [];
|
||||||
|
reader.floats(message.positional_data);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (wireType !== 5)
|
||||||
|
break;
|
||||||
|
if (!(message.positional_data && message.positional_data.length))
|
||||||
|
message.positional_data = [];
|
||||||
|
message.positional_data.push(reader.float());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
case 7: {
|
||||||
|
if (wireType !== 5)
|
||||||
|
break;
|
||||||
|
if (!$Object.is(value = reader.float(), 0))
|
||||||
|
message.volume_adjustment = value;
|
||||||
|
else
|
||||||
|
delete message.volume_adjustment;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
case 16: {
|
||||||
|
if (wireType !== 0)
|
||||||
|
break;
|
||||||
|
if (value = reader.bool())
|
||||||
|
message.is_terminator = value;
|
||||||
|
else
|
||||||
|
delete message.is_terminator;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.skipType(wireType, _depth, tag);
|
||||||
|
if (!reader.discardUnknown) {
|
||||||
|
$util.makeProp(message, "$unknowns", false);
|
||||||
|
(message.$unknowns || (message.$unknowns = [])).push(reader.raw(start, reader.pos));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (length !== $undefined) {
|
||||||
|
if (reader.pos !== end)
|
||||||
|
throw $RangeError("index out of range");
|
||||||
|
reader.len = length;
|
||||||
|
}
|
||||||
|
if (_end !== $undefined)
|
||||||
|
throw $Error("missing end group");
|
||||||
|
return message;
|
||||||
|
};
|
||||||
|
|
||||||
|
Audio.verify = function (message, _depth) {
|
||||||
|
if (typeof message !== "object" || message === null)
|
||||||
|
return "object expected";
|
||||||
|
if (_depth === $undefined)
|
||||||
|
_depth = 0;
|
||||||
|
if (_depth > $util.recursionLimit)
|
||||||
|
return "max depth exceeded";
|
||||||
|
let properties = {};
|
||||||
|
if (message.target != null && $Object.hasOwnProperty.call(message, "target")) {
|
||||||
|
properties.Header = 1;
|
||||||
|
if (!$util.isInteger(message.target))
|
||||||
|
return "target: integer expected";
|
||||||
|
}
|
||||||
|
if (message.context != null && $Object.hasOwnProperty.call(message, "context")) {
|
||||||
|
if (properties.Header === 1)
|
||||||
|
return "Header: multiple values";
|
||||||
|
properties.Header = 1;
|
||||||
|
if (!$util.isInteger(message.context))
|
||||||
|
return "context: integer expected";
|
||||||
|
}
|
||||||
|
if (message.sender_session != null && $Object.hasOwnProperty.call(message, "sender_session"))
|
||||||
|
if (!$util.isInteger(message.sender_session))
|
||||||
|
return "sender_session: integer expected";
|
||||||
|
if (message.frame_number != null && $Object.hasOwnProperty.call(message, "frame_number"))
|
||||||
|
if (!$util.isInteger(message.frame_number) && !(message.frame_number && $util.isInteger(message.frame_number.low) && $util.isInteger(message.frame_number.high)))
|
||||||
|
return "frame_number: integer|Long expected";
|
||||||
|
if (message.opus_data != null && $Object.hasOwnProperty.call(message, "opus_data"))
|
||||||
|
if (!(message.opus_data && typeof message.opus_data.length === "number" || $util.isString(message.opus_data)))
|
||||||
|
return "opus_data: buffer expected";
|
||||||
|
if (message.positional_data != null && $Object.hasOwnProperty.call(message, "positional_data")) {
|
||||||
|
if (!$Array.isArray(message.positional_data))
|
||||||
|
return "positional_data: array expected";
|
||||||
|
for (let i = 0; i < message.positional_data.length; ++i)
|
||||||
|
if (typeof message.positional_data[i] !== "number")
|
||||||
|
return "positional_data: number[] expected";
|
||||||
|
}
|
||||||
|
if (message.volume_adjustment != null && $Object.hasOwnProperty.call(message, "volume_adjustment"))
|
||||||
|
if (typeof message.volume_adjustment !== "number")
|
||||||
|
return "volume_adjustment: number expected";
|
||||||
|
if (message.is_terminator != null && $Object.hasOwnProperty.call(message, "is_terminator"))
|
||||||
|
if (typeof message.is_terminator !== "boolean")
|
||||||
|
return "is_terminator: boolean expected";
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
Audio.fromObject = function (object, _depth) {
|
||||||
|
if (object instanceof $root.MumbleUDP.Audio)
|
||||||
|
return object;
|
||||||
|
if (!$util.isObject(object))
|
||||||
|
throw $TypeError(".MumbleUDP.Audio: object expected");
|
||||||
|
if (_depth === $undefined)
|
||||||
|
_depth = 0;
|
||||||
|
if (_depth > $util.recursionLimit)
|
||||||
|
throw $Error("max depth exceeded");
|
||||||
|
let message = new $root.MumbleUDP.Audio();
|
||||||
|
if (object.target != null)
|
||||||
|
message.target = object.target >>> 0;
|
||||||
|
if (object.context != null)
|
||||||
|
message.context = object.context >>> 0;
|
||||||
|
if (object.sender_session != null)
|
||||||
|
if ($Number(object.sender_session) !== 0)
|
||||||
|
message.sender_session = object.sender_session >>> 0;
|
||||||
|
if (object.frame_number != null)
|
||||||
|
if (typeof object.frame_number === "object" ? object.frame_number.low || object.frame_number.high : $Number(object.frame_number) !== 0)
|
||||||
|
if ($util.Long)
|
||||||
|
message.frame_number = $util.Long.fromValue(object.frame_number, true);
|
||||||
|
else if (typeof object.frame_number === "string")
|
||||||
|
message.frame_number = $parseInt(object.frame_number, 10);
|
||||||
|
else if (typeof object.frame_number === "number")
|
||||||
|
message.frame_number = object.frame_number;
|
||||||
|
else if (typeof object.frame_number === "object")
|
||||||
|
message.frame_number = new $util.LongBits(object.frame_number.low >>> 0, object.frame_number.high >>> 0).toNumber(true);
|
||||||
|
if (object.opus_data != null)
|
||||||
|
if (object.opus_data.length)
|
||||||
|
if (typeof object.opus_data === "string")
|
||||||
|
$util.base64.decode(object.opus_data, message.opus_data = $util.newBuffer($util.base64.length(object.opus_data)), 0);
|
||||||
|
else if (object.opus_data.length >= 0)
|
||||||
|
message.opus_data = object.opus_data;
|
||||||
|
if (object.positional_data) {
|
||||||
|
if (!$Array.isArray(object.positional_data))
|
||||||
|
throw $TypeError(".MumbleUDP.Audio.positional_data: array expected");
|
||||||
|
message.positional_data = $Array(object.positional_data.length);
|
||||||
|
for (let i = 0; i < object.positional_data.length; ++i)
|
||||||
|
message.positional_data[i] = $Number(object.positional_data[i]);
|
||||||
|
}
|
||||||
|
if (object.volume_adjustment != null)
|
||||||
|
if (!$Object.is($Number(object.volume_adjustment), 0))
|
||||||
|
message.volume_adjustment = $Number(object.volume_adjustment);
|
||||||
|
if (object.is_terminator != null)
|
||||||
|
if (object.is_terminator)
|
||||||
|
message.is_terminator = $Boolean(object.is_terminator);
|
||||||
|
return message;
|
||||||
|
};
|
||||||
|
|
||||||
|
Audio.toObject = function (message, options, _depth) {
|
||||||
|
if (!options)
|
||||||
|
options = {};
|
||||||
|
if (_depth === $undefined)
|
||||||
|
_depth = 0;
|
||||||
|
if (_depth > $util.recursionLimit)
|
||||||
|
throw $Error("max depth exceeded");
|
||||||
|
let object = {};
|
||||||
|
if (options.arrays || options.defaults)
|
||||||
|
object.positional_data = [];
|
||||||
|
if (options.defaults) {
|
||||||
|
object.sender_session = 0;
|
||||||
|
if ($util.Long) {
|
||||||
|
let long = new $util.Long(0, 0, true);
|
||||||
|
object.frame_number = options.longs === $String ? long.toString() : options.longs === $Number ? long.toNumber() : typeof $BigInt !== "undefined" && options.longs === $BigInt ? long.toBigInt() : long;
|
||||||
|
} else
|
||||||
|
object.frame_number = options.longs === $String ? "0" : typeof $BigInt !== "undefined" && options.longs === $BigInt ? $BigInt("0") : 0;
|
||||||
|
if (options.bytes === $String)
|
||||||
|
object.opus_data = "";
|
||||||
|
else {
|
||||||
|
object.opus_data = [];
|
||||||
|
if (options.bytes !== $Array)
|
||||||
|
object.opus_data = $util.newBuffer(object.opus_data);
|
||||||
|
}
|
||||||
|
object.volume_adjustment = 0;
|
||||||
|
object.is_terminator = false;
|
||||||
|
}
|
||||||
|
if (message.target != null && $Object.hasOwnProperty.call(message, "target")) {
|
||||||
|
object.target = message.target;
|
||||||
|
if (options.oneofs)
|
||||||
|
object.Header = "target";
|
||||||
|
}
|
||||||
|
if (message.context != null && $Object.hasOwnProperty.call(message, "context")) {
|
||||||
|
object.context = message.context;
|
||||||
|
if (options.oneofs)
|
||||||
|
object.Header = "context";
|
||||||
|
}
|
||||||
|
if (message.sender_session != null && $Object.hasOwnProperty.call(message, "sender_session"))
|
||||||
|
object.sender_session = message.sender_session;
|
||||||
|
if (message.frame_number != null && $Object.hasOwnProperty.call(message, "frame_number"))
|
||||||
|
if (typeof $BigInt !== "undefined" && options.longs === $BigInt)
|
||||||
|
object.frame_number = typeof message.frame_number === "number" ? $BigInt(message.frame_number) : $util.Long.fromBits(message.frame_number.low >>> 0, message.frame_number.high >>> 0, true).toBigInt();
|
||||||
|
else if (typeof message.frame_number === "number")
|
||||||
|
object.frame_number = options.longs === $String ? $String(message.frame_number) : message.frame_number;
|
||||||
|
else
|
||||||
|
object.frame_number = options.longs === $String ? $util.Long.prototype.toString.call(message.frame_number) : options.longs === $Number ? new $util.LongBits(message.frame_number.low >>> 0, message.frame_number.high >>> 0).toNumber(true) : message.frame_number;
|
||||||
|
if (message.opus_data != null && $Object.hasOwnProperty.call(message, "opus_data"))
|
||||||
|
object.opus_data = options.bytes === $String ? $util.base64.encode(message.opus_data, 0, message.opus_data.length) : options.bytes === $Array ? $Array.prototype.slice.call(message.opus_data) : message.opus_data;
|
||||||
|
if (message.positional_data && message.positional_data.length) {
|
||||||
|
object.positional_data = $Array(message.positional_data.length);
|
||||||
|
for (let j = 0; j < message.positional_data.length; ++j)
|
||||||
|
object.positional_data[j] = options.json && !$isFinite(message.positional_data[j]) ? $String(message.positional_data[j]) : message.positional_data[j];
|
||||||
|
}
|
||||||
|
if (message.volume_adjustment != null && $Object.hasOwnProperty.call(message, "volume_adjustment"))
|
||||||
|
object.volume_adjustment = options.json && !$isFinite(message.volume_adjustment) ? $String(message.volume_adjustment) : message.volume_adjustment;
|
||||||
|
if (message.is_terminator != null && $Object.hasOwnProperty.call(message, "is_terminator"))
|
||||||
|
object.is_terminator = message.is_terminator;
|
||||||
|
return object;
|
||||||
|
};
|
||||||
|
|
||||||
|
Audio.prototype.toJSON = function() {
|
||||||
|
return Audio.toObject(this, $protobuf.util.toJSONOptions);
|
||||||
|
};
|
||||||
|
|
||||||
|
Audio.getTypeUrl = function(prefix) {
|
||||||
|
if (prefix === $undefined)
|
||||||
|
prefix = "type.googleapis.com";
|
||||||
|
return prefix + "/MumbleUDP.Audio";
|
||||||
|
};
|
||||||
|
|
||||||
|
return Audio;
|
||||||
|
})();
|
||||||
|
|
||||||
|
MumbleUDP.Ping = (function() {
|
||||||
|
|
||||||
|
const Ping = function (properties) {
|
||||||
|
if (properties)
|
||||||
|
for (let keys = $Object.keys(properties), i = 0; i < keys.length; ++i)
|
||||||
|
if (properties[keys[i]] != null && keys[i] !== "__proto__")
|
||||||
|
this[keys[i]] = properties[keys[i]];
|
||||||
|
};
|
||||||
|
|
||||||
|
Ping.prototype.timestamp = $util.Long ? $util.Long.fromBits(0,0,true) : 0;
|
||||||
|
Ping.prototype.request_extended_information = false;
|
||||||
|
Ping.prototype.server_version_v2 = $util.Long ? $util.Long.fromBits(0,0,true) : 0;
|
||||||
|
Ping.prototype.user_count = 0;
|
||||||
|
Ping.prototype.max_user_count = 0;
|
||||||
|
Ping.prototype.max_bandwidth_per_user = 0;
|
||||||
|
|
||||||
|
Ping.create = function(properties) {
|
||||||
|
return new Ping(properties);
|
||||||
|
};
|
||||||
|
|
||||||
|
Ping.encode = function (message, writer, _depth) {
|
||||||
|
if (!writer)
|
||||||
|
writer = $Writer.create();
|
||||||
|
if (_depth === $undefined)
|
||||||
|
_depth = 0;
|
||||||
|
if (_depth > $util.recursionLimit)
|
||||||
|
throw $Error("max depth exceeded");
|
||||||
|
if (message.timestamp != null && $Object.hasOwnProperty.call(message, "timestamp") && (typeof message.timestamp === "object" ? message.timestamp.low || message.timestamp.high : message.timestamp !== 0))
|
||||||
|
writer.uint32(8).uint64(message.timestamp);
|
||||||
|
if (message.request_extended_information != null && $Object.hasOwnProperty.call(message, "request_extended_information") && message.request_extended_information !== false)
|
||||||
|
writer.uint32(16).bool(message.request_extended_information);
|
||||||
|
if (message.server_version_v2 != null && $Object.hasOwnProperty.call(message, "server_version_v2") && (typeof message.server_version_v2 === "object" ? message.server_version_v2.low || message.server_version_v2.high : message.server_version_v2 !== 0))
|
||||||
|
writer.uint32(24).uint64(message.server_version_v2);
|
||||||
|
if (message.user_count != null && $Object.hasOwnProperty.call(message, "user_count") && message.user_count !== 0)
|
||||||
|
writer.uint32(32).uint32(message.user_count);
|
||||||
|
if (message.max_user_count != null && $Object.hasOwnProperty.call(message, "max_user_count") && message.max_user_count !== 0)
|
||||||
|
writer.uint32(40).uint32(message.max_user_count);
|
||||||
|
if (message.max_bandwidth_per_user != null && $Object.hasOwnProperty.call(message, "max_bandwidth_per_user") && message.max_bandwidth_per_user !== 0)
|
||||||
|
writer.uint32(48).uint32(message.max_bandwidth_per_user);
|
||||||
|
if (message.$unknowns != null && $Object.hasOwnProperty.call(message, "$unknowns"))
|
||||||
|
for (let i = 0; i < message.$unknowns.length; ++i)
|
||||||
|
writer.raw(message.$unknowns[i]);
|
||||||
|
return writer;
|
||||||
|
};
|
||||||
|
|
||||||
|
Ping.decode = function (reader, length, _end, _depth, _target) {
|
||||||
|
if (!(reader instanceof $Reader))
|
||||||
|
reader = $Reader.create(reader);
|
||||||
|
if (_depth === $undefined)
|
||||||
|
_depth = 0;
|
||||||
|
if (_depth > $Reader.recursionLimit)
|
||||||
|
throw $Error("max depth exceeded");
|
||||||
|
let end, message, value;
|
||||||
|
if (length === $undefined)
|
||||||
|
end = reader.len;
|
||||||
|
else {
|
||||||
|
end = reader.pos + length;
|
||||||
|
if (end > reader.len)
|
||||||
|
throw $RangeError("index out of range");
|
||||||
|
length = reader.len;
|
||||||
|
reader.len = end;
|
||||||
|
}
|
||||||
|
message = _target || new $root.MumbleUDP.Ping();
|
||||||
|
while (reader.pos < end) {
|
||||||
|
let start = reader.pos;
|
||||||
|
let tag = reader.tag();
|
||||||
|
if (tag === _end) {
|
||||||
|
_end = $undefined;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let wireType = tag & 7;
|
||||||
|
switch (tag >>>= 3) {
|
||||||
|
case 1: {
|
||||||
|
if (wireType !== 0)
|
||||||
|
break;
|
||||||
|
if (typeof (value = reader.uint64()) === "object" ? value.low || value.high : value !== 0)
|
||||||
|
message.timestamp = value;
|
||||||
|
else
|
||||||
|
delete message.timestamp;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
case 2: {
|
||||||
|
if (wireType !== 0)
|
||||||
|
break;
|
||||||
|
if (value = reader.bool())
|
||||||
|
message.request_extended_information = value;
|
||||||
|
else
|
||||||
|
delete message.request_extended_information;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
case 3: {
|
||||||
|
if (wireType !== 0)
|
||||||
|
break;
|
||||||
|
if (typeof (value = reader.uint64()) === "object" ? value.low || value.high : value !== 0)
|
||||||
|
message.server_version_v2 = value;
|
||||||
|
else
|
||||||
|
delete message.server_version_v2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
case 4: {
|
||||||
|
if (wireType !== 0)
|
||||||
|
break;
|
||||||
|
if (value = reader.uint32())
|
||||||
|
message.user_count = value;
|
||||||
|
else
|
||||||
|
delete message.user_count;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
case 5: {
|
||||||
|
if (wireType !== 0)
|
||||||
|
break;
|
||||||
|
if (value = reader.uint32())
|
||||||
|
message.max_user_count = value;
|
||||||
|
else
|
||||||
|
delete message.max_user_count;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
case 6: {
|
||||||
|
if (wireType !== 0)
|
||||||
|
break;
|
||||||
|
if (value = reader.uint32())
|
||||||
|
message.max_bandwidth_per_user = value;
|
||||||
|
else
|
||||||
|
delete message.max_bandwidth_per_user;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.skipType(wireType, _depth, tag);
|
||||||
|
if (!reader.discardUnknown) {
|
||||||
|
$util.makeProp(message, "$unknowns", false);
|
||||||
|
(message.$unknowns || (message.$unknowns = [])).push(reader.raw(start, reader.pos));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (length !== $undefined) {
|
||||||
|
if (reader.pos !== end)
|
||||||
|
throw $RangeError("index out of range");
|
||||||
|
reader.len = length;
|
||||||
|
}
|
||||||
|
if (_end !== $undefined)
|
||||||
|
throw $Error("missing end group");
|
||||||
|
return message;
|
||||||
|
};
|
||||||
|
|
||||||
|
Ping.verify = function (message, _depth) {
|
||||||
|
if (typeof message !== "object" || message === null)
|
||||||
|
return "object expected";
|
||||||
|
if (_depth === $undefined)
|
||||||
|
_depth = 0;
|
||||||
|
if (_depth > $util.recursionLimit)
|
||||||
|
return "max depth exceeded";
|
||||||
|
if (message.timestamp != null && $Object.hasOwnProperty.call(message, "timestamp"))
|
||||||
|
if (!$util.isInteger(message.timestamp) && !(message.timestamp && $util.isInteger(message.timestamp.low) && $util.isInteger(message.timestamp.high)))
|
||||||
|
return "timestamp: integer|Long expected";
|
||||||
|
if (message.request_extended_information != null && $Object.hasOwnProperty.call(message, "request_extended_information"))
|
||||||
|
if (typeof message.request_extended_information !== "boolean")
|
||||||
|
return "request_extended_information: boolean expected";
|
||||||
|
if (message.server_version_v2 != null && $Object.hasOwnProperty.call(message, "server_version_v2"))
|
||||||
|
if (!$util.isInteger(message.server_version_v2) && !(message.server_version_v2 && $util.isInteger(message.server_version_v2.low) && $util.isInteger(message.server_version_v2.high)))
|
||||||
|
return "server_version_v2: integer|Long expected";
|
||||||
|
if (message.user_count != null && $Object.hasOwnProperty.call(message, "user_count"))
|
||||||
|
if (!$util.isInteger(message.user_count))
|
||||||
|
return "user_count: integer expected";
|
||||||
|
if (message.max_user_count != null && $Object.hasOwnProperty.call(message, "max_user_count"))
|
||||||
|
if (!$util.isInteger(message.max_user_count))
|
||||||
|
return "max_user_count: integer expected";
|
||||||
|
if (message.max_bandwidth_per_user != null && $Object.hasOwnProperty.call(message, "max_bandwidth_per_user"))
|
||||||
|
if (!$util.isInteger(message.max_bandwidth_per_user))
|
||||||
|
return "max_bandwidth_per_user: integer expected";
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
Ping.fromObject = function (object, _depth) {
|
||||||
|
if (object instanceof $root.MumbleUDP.Ping)
|
||||||
|
return object;
|
||||||
|
if (!$util.isObject(object))
|
||||||
|
throw $TypeError(".MumbleUDP.Ping: object expected");
|
||||||
|
if (_depth === $undefined)
|
||||||
|
_depth = 0;
|
||||||
|
if (_depth > $util.recursionLimit)
|
||||||
|
throw $Error("max depth exceeded");
|
||||||
|
let message = new $root.MumbleUDP.Ping();
|
||||||
|
if (object.timestamp != null)
|
||||||
|
if (typeof object.timestamp === "object" ? object.timestamp.low || object.timestamp.high : $Number(object.timestamp) !== 0)
|
||||||
|
if ($util.Long)
|
||||||
|
message.timestamp = $util.Long.fromValue(object.timestamp, true);
|
||||||
|
else if (typeof object.timestamp === "string")
|
||||||
|
message.timestamp = $parseInt(object.timestamp, 10);
|
||||||
|
else if (typeof object.timestamp === "number")
|
||||||
|
message.timestamp = object.timestamp;
|
||||||
|
else if (typeof object.timestamp === "object")
|
||||||
|
message.timestamp = new $util.LongBits(object.timestamp.low >>> 0, object.timestamp.high >>> 0).toNumber(true);
|
||||||
|
if (object.request_extended_information != null)
|
||||||
|
if (object.request_extended_information)
|
||||||
|
message.request_extended_information = $Boolean(object.request_extended_information);
|
||||||
|
if (object.server_version_v2 != null)
|
||||||
|
if (typeof object.server_version_v2 === "object" ? object.server_version_v2.low || object.server_version_v2.high : $Number(object.server_version_v2) !== 0)
|
||||||
|
if ($util.Long)
|
||||||
|
message.server_version_v2 = $util.Long.fromValue(object.server_version_v2, true);
|
||||||
|
else if (typeof object.server_version_v2 === "string")
|
||||||
|
message.server_version_v2 = $parseInt(object.server_version_v2, 10);
|
||||||
|
else if (typeof object.server_version_v2 === "number")
|
||||||
|
message.server_version_v2 = object.server_version_v2;
|
||||||
|
else if (typeof object.server_version_v2 === "object")
|
||||||
|
message.server_version_v2 = new $util.LongBits(object.server_version_v2.low >>> 0, object.server_version_v2.high >>> 0).toNumber(true);
|
||||||
|
if (object.user_count != null)
|
||||||
|
if ($Number(object.user_count) !== 0)
|
||||||
|
message.user_count = object.user_count >>> 0;
|
||||||
|
if (object.max_user_count != null)
|
||||||
|
if ($Number(object.max_user_count) !== 0)
|
||||||
|
message.max_user_count = object.max_user_count >>> 0;
|
||||||
|
if (object.max_bandwidth_per_user != null)
|
||||||
|
if ($Number(object.max_bandwidth_per_user) !== 0)
|
||||||
|
message.max_bandwidth_per_user = object.max_bandwidth_per_user >>> 0;
|
||||||
|
return message;
|
||||||
|
};
|
||||||
|
|
||||||
|
Ping.toObject = function (message, options, _depth) {
|
||||||
|
if (!options)
|
||||||
|
options = {};
|
||||||
|
if (_depth === $undefined)
|
||||||
|
_depth = 0;
|
||||||
|
if (_depth > $util.recursionLimit)
|
||||||
|
throw $Error("max depth exceeded");
|
||||||
|
let object = {};
|
||||||
|
if (options.defaults) {
|
||||||
|
if ($util.Long) {
|
||||||
|
let long = new $util.Long(0, 0, true);
|
||||||
|
object.timestamp = options.longs === $String ? long.toString() : options.longs === $Number ? long.toNumber() : typeof $BigInt !== "undefined" && options.longs === $BigInt ? long.toBigInt() : long;
|
||||||
|
} else
|
||||||
|
object.timestamp = options.longs === $String ? "0" : typeof $BigInt !== "undefined" && options.longs === $BigInt ? $BigInt("0") : 0;
|
||||||
|
object.request_extended_information = false;
|
||||||
|
if ($util.Long) {
|
||||||
|
let long = new $util.Long(0, 0, true);
|
||||||
|
object.server_version_v2 = options.longs === $String ? long.toString() : options.longs === $Number ? long.toNumber() : typeof $BigInt !== "undefined" && options.longs === $BigInt ? long.toBigInt() : long;
|
||||||
|
} else
|
||||||
|
object.server_version_v2 = options.longs === $String ? "0" : typeof $BigInt !== "undefined" && options.longs === $BigInt ? $BigInt("0") : 0;
|
||||||
|
object.user_count = 0;
|
||||||
|
object.max_user_count = 0;
|
||||||
|
object.max_bandwidth_per_user = 0;
|
||||||
|
}
|
||||||
|
if (message.timestamp != null && $Object.hasOwnProperty.call(message, "timestamp"))
|
||||||
|
if (typeof $BigInt !== "undefined" && options.longs === $BigInt)
|
||||||
|
object.timestamp = typeof message.timestamp === "number" ? $BigInt(message.timestamp) : $util.Long.fromBits(message.timestamp.low >>> 0, message.timestamp.high >>> 0, true).toBigInt();
|
||||||
|
else if (typeof message.timestamp === "number")
|
||||||
|
object.timestamp = options.longs === $String ? $String(message.timestamp) : message.timestamp;
|
||||||
|
else
|
||||||
|
object.timestamp = options.longs === $String ? $util.Long.prototype.toString.call(message.timestamp) : options.longs === $Number ? new $util.LongBits(message.timestamp.low >>> 0, message.timestamp.high >>> 0).toNumber(true) : message.timestamp;
|
||||||
|
if (message.request_extended_information != null && $Object.hasOwnProperty.call(message, "request_extended_information"))
|
||||||
|
object.request_extended_information = message.request_extended_information;
|
||||||
|
if (message.server_version_v2 != null && $Object.hasOwnProperty.call(message, "server_version_v2"))
|
||||||
|
if (typeof $BigInt !== "undefined" && options.longs === $BigInt)
|
||||||
|
object.server_version_v2 = typeof message.server_version_v2 === "number" ? $BigInt(message.server_version_v2) : $util.Long.fromBits(message.server_version_v2.low >>> 0, message.server_version_v2.high >>> 0, true).toBigInt();
|
||||||
|
else if (typeof message.server_version_v2 === "number")
|
||||||
|
object.server_version_v2 = options.longs === $String ? $String(message.server_version_v2) : message.server_version_v2;
|
||||||
|
else
|
||||||
|
object.server_version_v2 = options.longs === $String ? $util.Long.prototype.toString.call(message.server_version_v2) : options.longs === $Number ? new $util.LongBits(message.server_version_v2.low >>> 0, message.server_version_v2.high >>> 0).toNumber(true) : message.server_version_v2;
|
||||||
|
if (message.user_count != null && $Object.hasOwnProperty.call(message, "user_count"))
|
||||||
|
object.user_count = message.user_count;
|
||||||
|
if (message.max_user_count != null && $Object.hasOwnProperty.call(message, "max_user_count"))
|
||||||
|
object.max_user_count = message.max_user_count;
|
||||||
|
if (message.max_bandwidth_per_user != null && $Object.hasOwnProperty.call(message, "max_bandwidth_per_user"))
|
||||||
|
object.max_bandwidth_per_user = message.max_bandwidth_per_user;
|
||||||
|
return object;
|
||||||
|
};
|
||||||
|
|
||||||
|
Ping.prototype.toJSON = function() {
|
||||||
|
return Ping.toObject(this, $protobuf.util.toJSONOptions);
|
||||||
|
};
|
||||||
|
|
||||||
|
Ping.getTypeUrl = function(prefix) {
|
||||||
|
if (prefix === $undefined)
|
||||||
|
prefix = "type.googleapis.com";
|
||||||
|
return prefix + "/MumbleUDP.Ping";
|
||||||
|
};
|
||||||
|
|
||||||
|
return Ping;
|
||||||
|
})();
|
||||||
|
|
||||||
|
return MumbleUDP;
|
||||||
|
})();
|
||||||
|
|
||||||
|
export {
|
||||||
|
$root as default
|
||||||
|
};
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// Generated from proto/Mumble.proto by `npm run proto` (static code, no eval, so it runs under a strict CSP)
|
||||||
|
import { MumbleProto } from './mumble-pb.js';
|
||||||
|
|
||||||
|
// TCP control message type ids, in wire order (index = type id).
|
||||||
|
export const MESSAGE_TYPES = [
|
||||||
|
'Version', 'UDPTunnel', 'Authenticate', 'Ping', 'Reject', 'ServerSync',
|
||||||
|
'ChannelRemove', 'ChannelState', 'UserRemove', 'UserState', 'BanList',
|
||||||
|
'TextMessage', 'PermissionDenied', 'ACL', 'QueryUsers', 'CryptSetup',
|
||||||
|
'ContextActionModify', 'ContextAction', 'UserList', 'VoiceTarget',
|
||||||
|
'PermissionQuery', 'CodecVersion', 'UserStats', 'RequestBlob',
|
||||||
|
'ServerConfig', 'SuggestConfig', 'PluginDataTransmission'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type MessageName = (typeof MESSAGE_TYPES)[number];
|
||||||
|
|
||||||
|
export interface Codec {
|
||||||
|
encode(name: MessageName, payload: Record<string, unknown>): Uint8Array;
|
||||||
|
decode(typeId: number, body: Uint8Array): { name: MessageName; msg: any } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GeneratedType {
|
||||||
|
verify(obj: Record<string, unknown>): string | null;
|
||||||
|
fromObject(obj: Record<string, unknown>): unknown;
|
||||||
|
encode(msg: unknown): { finish(): Uint8Array };
|
||||||
|
decode(body: Uint8Array): unknown;
|
||||||
|
toObject(msg: unknown, opts: { longs: NumberConstructor }): any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Field names keep their snake_case spelling from the .proto file; 64-bit integers become numbers.
|
||||||
|
export function createCodec(): Codec {
|
||||||
|
const types = MESSAGE_TYPES.map(n => (MumbleProto as unknown as Record<string, GeneratedType>)[n]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
encode(name, payload) {
|
||||||
|
const id = MESSAGE_TYPES.indexOf(name);
|
||||||
|
const type = types[id];
|
||||||
|
const err = type.verify(payload);
|
||||||
|
if (err) throw new Error(`${name}: ${err}`);
|
||||||
|
const body = type.encode(type.fromObject(payload)).finish();
|
||||||
|
return frame(id, body);
|
||||||
|
},
|
||||||
|
decode(typeId, body) {
|
||||||
|
const type = types[typeId];
|
||||||
|
if (!type) return null;
|
||||||
|
const msg = type.toObject(type.decode(body), { longs: Number });
|
||||||
|
return { name: MESSAGE_TYPES[typeId], msg };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6-byte header: uint16 type, uint32 length (big endian), then the body.
|
||||||
|
export function frame(typeId: number, body: Uint8Array): Uint8Array {
|
||||||
|
const out = new Uint8Array(6 + body.length);
|
||||||
|
const view = new DataView(out.buffer);
|
||||||
|
view.setUint16(0, typeId);
|
||||||
|
view.setUint32(2, body.length);
|
||||||
|
out.set(body, 6);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reassembles framed messages from an arbitrary chunked byte stream.
|
||||||
|
export class FrameReader {
|
||||||
|
private buf = new Uint8Array(0);
|
||||||
|
|
||||||
|
push(chunk: Uint8Array, onFrame: (typeId: number, body: Uint8Array) => void): void {
|
||||||
|
const merged = new Uint8Array(this.buf.length + chunk.length);
|
||||||
|
merged.set(this.buf);
|
||||||
|
merged.set(chunk, this.buf.length);
|
||||||
|
let off = 0;
|
||||||
|
while (merged.length - off >= 6) {
|
||||||
|
const view = new DataView(merged.buffer, merged.byteOffset + off);
|
||||||
|
const typeId = view.getUint16(0);
|
||||||
|
const len = view.getUint32(2);
|
||||||
|
if (len > 8 * 1024 * 1024) throw new Error(`Frame too large: ${len}`);
|
||||||
|
if (merged.length - off - 6 < len) break;
|
||||||
|
onFrame(typeId, merged.subarray(off + 6, off + 6 + len));
|
||||||
|
off += 6 + len;
|
||||||
|
}
|
||||||
|
this.buf = merged.slice(off);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// A reliable, ordered byte stream to a Mumble server (TLS in Electron, WebSocket proxy on the web).
|
||||||
|
export interface Transport {
|
||||||
|
send(bytes: Uint8Array): void;
|
||||||
|
close(): void;
|
||||||
|
onData: ((chunk: Uint8Array) => void) | null;
|
||||||
|
onClose: ((reason: string) => void) | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectTarget {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identity certificate the client presents; Mumble uses it to recognize registered users.
|
||||||
|
export interface Identity {
|
||||||
|
certPem: string;
|
||||||
|
keyPem: string;
|
||||||
|
fingerprint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CertDetails {
|
||||||
|
subject: string;
|
||||||
|
issuer: string;
|
||||||
|
validFrom: string;
|
||||||
|
validTo: string;
|
||||||
|
serialNumber: string;
|
||||||
|
fingerprint: string; // SHA-1, the hash Mumble uses for users
|
||||||
|
fingerprint256: string;
|
||||||
|
subjectAltName: string;
|
||||||
|
keyType: string;
|
||||||
|
keyBits: number | null;
|
||||||
|
ca: boolean;
|
||||||
|
selfSigned: boolean;
|
||||||
|
pem: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerCertInfo {
|
||||||
|
fingerprint: string; // sha256 of the server certificate, colon-separated hex
|
||||||
|
authorized: boolean; // true if it chains to a trusted CA
|
||||||
|
authError: string | null; // why it is not trusted, e.g. DEPTH_ZERO_SELF_SIGNED_CERT
|
||||||
|
chain: CertDetails[]; // leaf first
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
// Mumble voice packets, as sent over UDP or tunneled through TCP (UDPTunnel).
|
||||||
|
// Two wire formats exist: the legacy one (header byte + Mumble varints) and, since 1.5,
|
||||||
|
// a protobuf one (type byte 0 + MumbleUDP.Audio). Servers pick per client version.
|
||||||
|
import { MumbleUDP } from './mumble-udp-pb.js';
|
||||||
|
|
||||||
|
export interface VoicePacket {
|
||||||
|
session: number; // sender (only in packets from the server)
|
||||||
|
target: number; // 0 normal talking, 1-30 whisper targets, 31 server loopback; context when received
|
||||||
|
frame: number; // sequence number, in 10 ms audio frames
|
||||||
|
opus: Uint8Array;
|
||||||
|
last: boolean; // end of this transmission
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEGACY_OPUS = 4;
|
||||||
|
const PROTOBUF_AUDIO = 0;
|
||||||
|
|
||||||
|
// ─── Mumble varint (PacketDataStream) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
export function writeVarint(out: number[], value: number): void {
|
||||||
|
if (!Number.isInteger(value) || value < 0) throw new Error(`Unsupported varint ${value}`);
|
||||||
|
if (value < 0x80) out.push(value);
|
||||||
|
else if (value < 0x4000) out.push(0x80 | (value >> 8), value & 0xff);
|
||||||
|
else if (value < 0x200000) out.push(0xc0 | (value >> 16), (value >> 8) & 0xff, value & 0xff);
|
||||||
|
else if (value < 0x10000000) out.push(0xe0 | (value >> 24), (value >> 16) & 0xff, (value >> 8) & 0xff, value & 0xff);
|
||||||
|
else if (value < 0x100000000) out.push(0xf0, (value >>> 24) & 0xff, (value >> 16) & 0xff, (value >> 8) & 0xff, value & 0xff);
|
||||||
|
else {
|
||||||
|
const hi = Math.floor(value / 0x100000000), lo = value >>> 0;
|
||||||
|
out.push(0xf4, (hi >>> 24) & 0xff, (hi >> 16) & 0xff, (hi >> 8) & 0xff, hi & 0xff,
|
||||||
|
(lo >>> 24) & 0xff, (lo >> 16) & 0xff, (lo >> 8) & 0xff, lo & 0xff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readVarint(buf: Uint8Array, pos: number): [value: number, next: number] {
|
||||||
|
const need = (n: number) => { if (pos + n > buf.length) throw new Error('Truncated varint'); };
|
||||||
|
need(1);
|
||||||
|
const b = buf[pos];
|
||||||
|
if ((b & 0x80) === 0) return [b, pos + 1];
|
||||||
|
if ((b & 0xc0) === 0x80) { need(2); return [((b & 0x3f) << 8) | buf[pos + 1], pos + 2]; }
|
||||||
|
if ((b & 0xe0) === 0xc0) { need(3); return [((b & 0x1f) << 16) | (buf[pos + 1] << 8) | buf[pos + 2], pos + 3]; }
|
||||||
|
if ((b & 0xf0) === 0xe0) { need(4); return [((b & 0x0f) << 24 | (buf[pos + 1] << 16) | (buf[pos + 2] << 8) | buf[pos + 3]) >>> 0, pos + 4]; }
|
||||||
|
const be32 = (p: number) => ((buf[p] << 24) | (buf[p + 1] << 16) | (buf[p + 2] << 8) | buf[p + 3]) >>> 0;
|
||||||
|
if ((b & 0xfc) === 0xf0) { need(5); return [be32(pos + 1), pos + 5]; }
|
||||||
|
if ((b & 0xfc) === 0xf4) { need(9); return [be32(pos + 1) * 0x100000000 + be32(pos + 5), pos + 9]; }
|
||||||
|
// Negative numbers (0xf8, 0xfc prefixes) never occur in audio headers
|
||||||
|
throw new Error('Unsupported varint prefix');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Encoding (client to server) ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function encodeVoice(p: Omit<VoicePacket, 'session'>, protobuf: boolean): Uint8Array {
|
||||||
|
if (protobuf) {
|
||||||
|
const msg = MumbleUDP.Audio.fromObject({
|
||||||
|
target: p.target,
|
||||||
|
frame_number: p.frame,
|
||||||
|
opus_data: p.opus,
|
||||||
|
is_terminator: p.last
|
||||||
|
});
|
||||||
|
const body: Uint8Array = MumbleUDP.Audio.encode(msg).finish();
|
||||||
|
const out = new Uint8Array(body.length + 1);
|
||||||
|
out[0] = PROTOBUF_AUDIO;
|
||||||
|
out.set(body, 1);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if (p.opus.length >= 0x2000) throw new Error('Opus frame too large');
|
||||||
|
const head: number[] = [(LEGACY_OPUS << 5) | (p.target & 0x1f)];
|
||||||
|
writeVarint(head, p.frame);
|
||||||
|
writeVarint(head, p.opus.length | (p.last ? 0x2000 : 0));
|
||||||
|
const out = new Uint8Array(head.length + p.opus.length);
|
||||||
|
out.set(head);
|
||||||
|
out.set(p.opus, head.length);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Decoding (server to client) ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Returns null for pings, other codecs and malformed packets.
|
||||||
|
export function decodeVoice(buf: Uint8Array): VoicePacket | null {
|
||||||
|
if (!buf.length) return null;
|
||||||
|
try {
|
||||||
|
if (buf[0] === PROTOBUF_AUDIO) {
|
||||||
|
const m = MumbleUDP.Audio.toObject(MumbleUDP.Audio.decode(buf.subarray(1)), { longs: Number, defaults: true });
|
||||||
|
return {
|
||||||
|
session: m.sender_session,
|
||||||
|
target: m.context ?? m.target ?? 0,
|
||||||
|
frame: m.frame_number,
|
||||||
|
opus: m.opus_data instanceof Uint8Array ? m.opus_data : new Uint8Array(m.opus_data ?? []),
|
||||||
|
last: !!m.is_terminator
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (buf[0] >> 5 !== LEGACY_OPUS) return null;
|
||||||
|
const target = buf[0] & 0x1f;
|
||||||
|
let pos = 1, session, frame, size;
|
||||||
|
[session, pos] = readVarint(buf, pos);
|
||||||
|
[frame, pos] = readVarint(buf, pos);
|
||||||
|
[size, pos] = readVarint(buf, pos);
|
||||||
|
const len = size & 0x1fff;
|
||||||
|
if (pos + len > buf.length) return null;
|
||||||
|
return { session, target, frame, opus: buf.slice(pos, pos + len), last: (size & 0x2000) !== 0 };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For tests and the server-side view: a client packet as the server would relay it
|
||||||
|
export function relayAsServer(clientPacket: Uint8Array, session: number, protobuf: boolean): Uint8Array {
|
||||||
|
if (protobuf) {
|
||||||
|
const m = MumbleUDP.Audio.decode(clientPacket.subarray(1));
|
||||||
|
m.sender_session = session;
|
||||||
|
m.context = 0;
|
||||||
|
const body: Uint8Array = MumbleUDP.Audio.encode(m).finish();
|
||||||
|
const out = new Uint8Array(body.length + 1);
|
||||||
|
out.set(body, 1);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
const head: number[] = [clientPacket[0] & 0xe0];
|
||||||
|
writeVarint(head, session);
|
||||||
|
const out = new Uint8Array(head.length + clientPacket.length - 1);
|
||||||
|
out.set(head);
|
||||||
|
out.set(clientPacket.subarray(1), head.length);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { session } from './session.svelte.ts';
|
||||||
|
import { ui } from './ui.svelte.ts';
|
||||||
|
import { store } from './settings.svelte.ts';
|
||||||
|
import { voice, userKey } from './audio/voice.svelte.ts';
|
||||||
|
import type { MenuItem } from './menu.svelte.ts';
|
||||||
|
import type { Channel, User } from '../core/client.ts';
|
||||||
|
|
||||||
|
// Right-click menu for a channel in the tree
|
||||||
|
export function channelMenu(c: Channel): MenuItem[] {
|
||||||
|
const here = session.self?.channelId === c.id;
|
||||||
|
return [
|
||||||
|
{ label: here ? 'You are here' : 'Join channel', action: () => session.joinChannel(c.id), disabled: here || !c.canEnter },
|
||||||
|
{ label: 'Open chat', action: () => { session.showChannel(c.id, 'chat'); ui.panelOpen = true; } },
|
||||||
|
{ label: 'Show description', action: () => { session.showChannel(c.id, 'info'); ui.panelOpen = true; } },
|
||||||
|
{ separator: true, label: '' },
|
||||||
|
{ label: session.isExpanded(c.id) ? 'Collapse' : 'Expand', action: () => session.toggleExpanded(c.id), disabled: c.id === 0 },
|
||||||
|
{ label: 'Expand all', action: () => session.setAllExpanded(true) },
|
||||||
|
{ label: 'Collapse all', action: () => session.setAllExpanded(false) },
|
||||||
|
{ separator: true, label: '' },
|
||||||
|
{ label: 'Copy channel name', action: () => navigator.clipboard.writeText(c.name || 'Root') }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right-click menu for a user. Moderation entries are always offered; the server decides
|
||||||
|
// whether we may use them and answers with a permission denial otherwise.
|
||||||
|
export function userMenu(u: User): MenuItem[] {
|
||||||
|
const self = session.self;
|
||||||
|
const isSelf = u.session === self?.session;
|
||||||
|
const items: MenuItem[] = [
|
||||||
|
{ label: 'View profile', action: () => { session.showUser(u.session); ui.panelOpen = true; } },
|
||||||
|
{ label: 'Information', action: () => (ui.userInfo = u.session) }
|
||||||
|
];
|
||||||
|
if (isSelf) {
|
||||||
|
items.push(
|
||||||
|
{ label: 'Mute', checked: u.selfMute, action: () => session.setSelfMute(!u.selfMute) },
|
||||||
|
{ label: 'Deafen', checked: u.selfDeaf, action: () => session.setSelfDeaf(!u.selfDeaf) },
|
||||||
|
{ label: 'Change name...', action: () => changeNamePrompt(u) }
|
||||||
|
);
|
||||||
|
if (u.userId == null) items.push({ label: 'Register on this server', action: () => session.register(u.session) });
|
||||||
|
} else {
|
||||||
|
items.push(
|
||||||
|
{ 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: 'Set nickname...', action: () => nicknamePrompt(u) }
|
||||||
|
);
|
||||||
|
if (self && u.channelId !== self.channelId) {
|
||||||
|
items.push({ label: 'Move to my channel', action: () => session.moveUser(u.session, self.channelId) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items.push(
|
||||||
|
{ separator: true, label: '' },
|
||||||
|
{ label: 'Server mute', checked: u.mute, action: () => session.setServerMute(u.session, !u.mute) },
|
||||||
|
{ label: 'Server deafen', checked: u.deaf, action: () => session.setServerDeaf(u.session, !u.deaf) },
|
||||||
|
{ label: 'Priority speaker', checked: u.prioritySpeaker, action: () => session.setPrioritySpeaker(u.session, !u.prioritySpeaker) }
|
||||||
|
);
|
||||||
|
if (!isSelf && u.userId == null) items.push({ label: 'Register user', action: () => session.register(u.session) });
|
||||||
|
if (!isSelf) {
|
||||||
|
items.push(
|
||||||
|
{ separator: true, label: '' },
|
||||||
|
{ label: 'Kick...', danger: true, action: () => (ui.prompt = { title: `Kick ${u.name}`, label: 'Reason (optional)', confirm: 'Kick', danger: true, onsubmit: r => session.kick(u.session, r) }) },
|
||||||
|
{ label: 'Ban...', danger: true, action: () => (ui.prompt = { title: `Ban ${u.name}`, label: 'Reason (optional)', confirm: 'Ban', danger: true, onsubmit: r => session.kick(u.session, r, true) }) }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
items.push({ separator: true, label: '' }, { label: 'Copy name', action: () => navigator.clipboard.writeText(u.name) });
|
||||||
|
if (u.hash) items.push({ label: 'Copy certificate hash', action: () => navigator.clipboard.writeText(u.hash) });
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function changeNamePrompt(u: User): void {
|
||||||
|
ui.prompt = {
|
||||||
|
title: 'Change your name', label: 'New name', confirm: 'Reconnect with this name', initial: u.name,
|
||||||
|
text: u.userId != null
|
||||||
|
? 'You are registered here, so the server keeps your registered name. The new name applies on servers where you are a guest.'
|
||||||
|
: 'Mumble cannot rename you while connected, so mumh5 reconnects with the new name and remembers it for this server.',
|
||||||
|
onsubmit: name => session.changeName(name)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nicknamePrompt(u: User): void {
|
||||||
|
ui.prompt = {
|
||||||
|
title: `Nickname for ${u.name}`, label: 'Nickname (only you see it; empty to remove)', confirm: 'Save',
|
||||||
|
initial: store.nicknames[userKey(u)] ?? '',
|
||||||
|
onsubmit: nick => store.setNickname(userKey(u), nick)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/// <reference path="./worklet-env.d.ts" />
|
||||||
|
// Cuts microphone input into 10 ms mono frames (480 samples at 48 kHz) for the encoder,
|
||||||
|
// with the RMS level of each frame for the meter and voice activity detection.
|
||||||
|
|
||||||
|
const FRAME = 480;
|
||||||
|
|
||||||
|
class CaptureProcessor extends AudioWorkletProcessor {
|
||||||
|
private buf = new Float32Array(FRAME);
|
||||||
|
private fill = 0;
|
||||||
|
|
||||||
|
process(inputs: Float32Array[][]): boolean {
|
||||||
|
const input = inputs[0]?.[0];
|
||||||
|
if (!input) return true;
|
||||||
|
let i = 0;
|
||||||
|
while (i < input.length) {
|
||||||
|
const n = Math.min(FRAME - this.fill, input.length - i);
|
||||||
|
this.buf.set(input.subarray(i, i + n), this.fill);
|
||||||
|
this.fill += n;
|
||||||
|
i += n;
|
||||||
|
if (this.fill === FRAME) {
|
||||||
|
let sum = 0;
|
||||||
|
for (let k = 0; k < FRAME; k++) sum += this.buf[k] * this.buf[k];
|
||||||
|
const frame = this.buf;
|
||||||
|
this.port.postMessage({ frame, rms: Math.sqrt(sum / FRAME) }, [frame.buffer]);
|
||||||
|
this.buf = new Float32Array(FRAME);
|
||||||
|
this.fill = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerProcessor('mumh5-capture', CaptureProcessor);
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/// <reference path="./worklet-env.d.ts" />
|
||||||
|
// Mixes decoded voice of all speakers. Each speaker has a queue that starts playing once
|
||||||
|
// `jitter` ms are buffered (absorbs network jitter), and re-buffers after running dry.
|
||||||
|
|
||||||
|
interface Speaker {
|
||||||
|
chunks: Float32Array[];
|
||||||
|
offset: number; // read position in chunks[0]
|
||||||
|
queued: number; // samples waiting
|
||||||
|
playing: boolean;
|
||||||
|
gain: number;
|
||||||
|
peak: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class PlaybackProcessor extends AudioWorkletProcessor {
|
||||||
|
private speakers = new Map<number, Speaker>();
|
||||||
|
private jitter = Math.round(sampleRate * 0.06);
|
||||||
|
private maxQueue = Math.round(sampleRate * 0.4);
|
||||||
|
private gains = new Map<number, number>();
|
||||||
|
private sinceReport = 0;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.port.onmessage = e => {
|
||||||
|
const m = e.data;
|
||||||
|
if (m.pcm) this.push(m.session, m.pcm);
|
||||||
|
else if (m.gain !== undefined) {
|
||||||
|
this.gains.set(m.session, m.gain);
|
||||||
|
const s = this.speakers.get(m.session);
|
||||||
|
if (s) s.gain = m.gain;
|
||||||
|
} else if (m.jitterMs !== undefined) this.jitter = Math.round(sampleRate * m.jitterMs / 1000);
|
||||||
|
else if (m.drop !== undefined) this.speakers.delete(m.drop);
|
||||||
|
else if (m.clear) this.speakers.clear();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private push(session: number, pcm: Float32Array) {
|
||||||
|
let s = this.speakers.get(session);
|
||||||
|
if (!s) {
|
||||||
|
s = { chunks: [], offset: 0, queued: 0, playing: false, gain: this.gains.get(session) ?? 1, peak: 0 };
|
||||||
|
this.speakers.set(session, s);
|
||||||
|
}
|
||||||
|
s.chunks.push(pcm);
|
||||||
|
s.queued += pcm.length;
|
||||||
|
// Too far behind (e.g. after a stall): drop the oldest audio to catch up
|
||||||
|
while (s.queued > this.maxQueue && s.chunks.length > 1) {
|
||||||
|
const c = s.chunks.shift()!;
|
||||||
|
s.queued -= c.length - s.offset;
|
||||||
|
s.offset = 0;
|
||||||
|
}
|
||||||
|
if (!s.playing && s.queued >= this.jitter) s.playing = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
process(_inputs: Float32Array[][], outputs: Float32Array[][]): boolean {
|
||||||
|
const out = outputs[0];
|
||||||
|
const n = out[0].length;
|
||||||
|
const mix = out[0];
|
||||||
|
mix.fill(0);
|
||||||
|
for (const s of this.speakers.values()) {
|
||||||
|
if (!s.playing) continue;
|
||||||
|
let i = 0;
|
||||||
|
while (i < n && s.chunks.length) {
|
||||||
|
const c = s.chunks[0];
|
||||||
|
const take = Math.min(n - i, c.length - s.offset);
|
||||||
|
for (let k = 0; k < take; k++) {
|
||||||
|
const v = c[s.offset + k] * s.gain;
|
||||||
|
mix[i + k] += v;
|
||||||
|
const a = v < 0 ? -v : v;
|
||||||
|
if (a > s.peak) s.peak = a;
|
||||||
|
}
|
||||||
|
i += take;
|
||||||
|
s.offset += take;
|
||||||
|
s.queued -= take;
|
||||||
|
if (s.offset === c.length) { s.chunks.shift(); s.offset = 0; }
|
||||||
|
}
|
||||||
|
if (!s.chunks.length) s.playing = false; // ran dry: buffer again before resuming
|
||||||
|
}
|
||||||
|
// Soft clip so several loud speakers do not distort harshly
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const v = mix[i];
|
||||||
|
if (v > 1 || v < -1) mix[i] = Math.tanh(v);
|
||||||
|
}
|
||||||
|
for (let ch = 1; ch < out.length; ch++) out[ch].set(mix);
|
||||||
|
|
||||||
|
this.sinceReport += n;
|
||||||
|
if (this.sinceReport >= sampleRate / 10) {
|
||||||
|
const levels: Record<number, number> = {};
|
||||||
|
for (const [id, s] of this.speakers) { levels[id] = s.peak; s.peak = 0; }
|
||||||
|
this.port.postMessage({ levels });
|
||||||
|
this.sinceReport = 0;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerProcessor('mumh5-playback', PlaybackProcessor);
|
||||||
@@ -0,0 +1,447 @@
|
|||||||
|
import captureUrl from './capture.worklet.ts?worker&url';
|
||||||
|
import playbackUrl from './playback.worklet.ts?worker&url';
|
||||||
|
import { encodeVoice, decodeVoice } from '../../core/voice-packet.ts';
|
||||||
|
import type { MumbleClient } from '../../core/client.ts';
|
||||||
|
import { load, save } from '../storage.ts';
|
||||||
|
|
||||||
|
export type TransmitMode = 'vad' | 'ptt' | 'continuous';
|
||||||
|
|
||||||
|
export interface VoiceSettings {
|
||||||
|
inputDevice: string;
|
||||||
|
outputDevice: string;
|
||||||
|
mode: TransmitMode;
|
||||||
|
pttKey: string; // KeyboardEvent.code
|
||||||
|
vadThreshold: number; // dBFS; louder frames open the voice activity gate
|
||||||
|
vadHoldMs: number; // keep sending this long after the level drops
|
||||||
|
inputGain: number; // 0..3
|
||||||
|
outputVolume: number; // 0..2
|
||||||
|
echoCancellation: boolean;
|
||||||
|
noiseSuppression: boolean;
|
||||||
|
autoGainControl: boolean;
|
||||||
|
bitrate: number; // Opus bits per second
|
||||||
|
frameMs: 10 | 20 | 40 | 60;
|
||||||
|
jitterMs: number;
|
||||||
|
// Per-user local volume and mute, keyed by certificate hash (or name without one)
|
||||||
|
userVolumes: Record<string, number>;
|
||||||
|
localMutes: Record<string, boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaults: VoiceSettings = {
|
||||||
|
inputDevice: 'default',
|
||||||
|
outputDevice: 'default',
|
||||||
|
mode: 'vad',
|
||||||
|
pttKey: 'Backquote',
|
||||||
|
vadThreshold: -45,
|
||||||
|
vadHoldMs: 350,
|
||||||
|
inputGain: 1,
|
||||||
|
outputVolume: 1,
|
||||||
|
echoCancellation: true,
|
||||||
|
noiseSuppression: true,
|
||||||
|
autoGainControl: true,
|
||||||
|
bitrate: 40000,
|
||||||
|
frameMs: 20,
|
||||||
|
jitterMs: 60,
|
||||||
|
userVolumes: {},
|
||||||
|
localMutes: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const SAMPLE_RATE = 48000;
|
||||||
|
const FRAME = 480; // 10 ms
|
||||||
|
// Rough per-packet cost of IP, TCP/TLS or UDP, and Mumble headers, for the bandwidth limit
|
||||||
|
const PACKET_OVERHEAD_BYTES = 60;
|
||||||
|
|
||||||
|
export const userKey = (u: { hash: string; name: string }) => u.hash || `name:${u.name}`;
|
||||||
|
|
||||||
|
export interface DeviceInfo { id: string; label: string }
|
||||||
|
|
||||||
|
class VoiceEngine {
|
||||||
|
settings = $state<VoiceSettings>(load('mumh5.voice', defaults));
|
||||||
|
inputDevices = $state<DeviceInfo[]>([]);
|
||||||
|
outputDevices = $state<DeviceInfo[]>([]);
|
||||||
|
running = $state(false);
|
||||||
|
error = $state('');
|
||||||
|
level = $state(-100); // current input level, dBFS
|
||||||
|
transmitting = $state(false);
|
||||||
|
pttDown = $state(false);
|
||||||
|
testing = $state(false); // hear yourself
|
||||||
|
talking = $state<Record<number, boolean>>({});
|
||||||
|
effectiveBitrate = $state(0);
|
||||||
|
stats = $state({ sent: 0, received: 0, decoded: 0, lost: 0 });
|
||||||
|
|
||||||
|
private ctx: AudioContext | null = null;
|
||||||
|
private stream: MediaStream | null = null;
|
||||||
|
private source: MediaStreamAudioSourceNode | null = null;
|
||||||
|
private inputGainNode: GainNode | null = null;
|
||||||
|
private outputGainNode: GainNode | null = null;
|
||||||
|
private capture: AudioWorkletNode | null = null;
|
||||||
|
private playback: AudioWorkletNode | null = null;
|
||||||
|
private encoder: AudioEncoder | null = null;
|
||||||
|
private decoders = new Map<number, { decoder: AudioDecoder; lastFrame: number; timer: ReturnType<typeof setTimeout> | null }>();
|
||||||
|
private client: MumbleClient | null = null;
|
||||||
|
private sessionGains = new Map<number, number>();
|
||||||
|
private unsubscribe: (() => void)[] = [];
|
||||||
|
|
||||||
|
private frameCounter = 0; // Mumble sequence, in 10 ms frames
|
||||||
|
private sampleTime = 0; // encoder timestamps, microseconds
|
||||||
|
private lastVoiceAt = 0; // for VAD hold
|
||||||
|
private open = false; // gate state
|
||||||
|
private stopping = false; // flushing the last packet of a transmission
|
||||||
|
private held: Uint8Array[] = [];
|
||||||
|
private levelRaw = -100;
|
||||||
|
private levelTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
window.addEventListener('keydown', e => this.onKey(e, true));
|
||||||
|
window.addEventListener('keyup', e => this.onKey(e, false));
|
||||||
|
// Released keys are not reported while the window is in the background
|
||||||
|
window.addEventListener('blur', () => { this.pttDown = false; });
|
||||||
|
navigator.mediaDevices?.addEventListener('devicechange', () => this.refreshDevices());
|
||||||
|
}
|
||||||
|
|
||||||
|
save(): void {
|
||||||
|
save('mumh5.voice', $state.snapshot(this.settings));
|
||||||
|
}
|
||||||
|
|
||||||
|
update(patch: Partial<VoiceSettings>): void {
|
||||||
|
Object.assign(this.settings, patch);
|
||||||
|
this.save();
|
||||||
|
if ('inputGain' in patch && this.inputGainNode) this.inputGainNode.gain.value = this.settings.inputGain;
|
||||||
|
if ('outputVolume' in patch && this.outputGainNode) this.outputGainNode.gain.value = this.settings.outputVolume;
|
||||||
|
if ('jitterMs' in patch) this.playback?.port.postMessage({ jitterMs: this.settings.jitterMs });
|
||||||
|
if ('outputDevice' in patch) this.applyOutputDevice();
|
||||||
|
if ('bitrate' in patch || 'frameMs' in patch) this.configureEncoder();
|
||||||
|
if (['inputDevice', 'echoCancellation', 'noiseSuppression', 'autoGainControl'].some(k => k in patch)) this.restartInput();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Devices ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async refreshDevices(): Promise<void> {
|
||||||
|
if (!navigator.mediaDevices?.enumerateDevices) return;
|
||||||
|
const all = await navigator.mediaDevices.enumerateDevices();
|
||||||
|
const map = (kind: MediaDeviceKind) => all.filter(d => d.kind === kind && d.deviceId !== 'communications')
|
||||||
|
.map(d => ({ id: d.deviceId, label: d.label || (d.deviceId === 'default' ? 'System default' : 'Unnamed device') }));
|
||||||
|
this.inputDevices = map('audioinput');
|
||||||
|
this.outputDevices = map('audiooutput');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Lifecycle ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Starts the audio graph; safe to call repeatedly
|
||||||
|
async start(): Promise<void> {
|
||||||
|
if (this.ctx) return;
|
||||||
|
this.error = '';
|
||||||
|
try {
|
||||||
|
if (typeof AudioEncoder === 'undefined' || !(await AudioEncoder.isConfigSupported(this.encoderConfig())).supported) {
|
||||||
|
throw new Error('This system cannot encode Opus audio');
|
||||||
|
}
|
||||||
|
const ctx = new AudioContext({ sampleRate: SAMPLE_RATE, latencyHint: 'interactive' });
|
||||||
|
this.ctx = ctx;
|
||||||
|
await ctx.audioWorklet.addModule(captureUrl);
|
||||||
|
await ctx.audioWorklet.addModule(playbackUrl);
|
||||||
|
|
||||||
|
this.outputGainNode = new GainNode(ctx, { gain: this.settings.outputVolume });
|
||||||
|
this.playback = new AudioWorkletNode(ctx, 'mumh5-playback', { numberOfInputs: 0, outputChannelCount: [2] });
|
||||||
|
this.playback.port.onmessage = e => this.onLevels(e.data.levels);
|
||||||
|
this.playback.port.postMessage({ jitterMs: this.settings.jitterMs });
|
||||||
|
for (const [s, g] of this.sessionGains) this.playback.port.postMessage({ session: s, gain: g });
|
||||||
|
this.playback.connect(this.outputGainNode).connect(ctx.destination);
|
||||||
|
|
||||||
|
this.inputGainNode = new GainNode(ctx, { gain: this.settings.inputGain });
|
||||||
|
this.capture = new AudioWorkletNode(ctx, 'mumh5-capture', { numberOfOutputs: 0 });
|
||||||
|
this.capture.port.onmessage = e => this.onFrame(e.data.frame, e.data.rms);
|
||||||
|
this.inputGainNode.connect(this.capture);
|
||||||
|
|
||||||
|
await this.applyOutputDevice();
|
||||||
|
await this.restartInput();
|
||||||
|
this.configureEncoder();
|
||||||
|
await ctx.resume();
|
||||||
|
this.levelTimer = setInterval(() => { this.level = this.levelRaw; }, 50);
|
||||||
|
this.running = true;
|
||||||
|
await this.refreshDevices();
|
||||||
|
} catch (e) {
|
||||||
|
this.error = (e as Error).message;
|
||||||
|
await this.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
this.endTransmission();
|
||||||
|
if (this.levelTimer) clearInterval(this.levelTimer);
|
||||||
|
this.levelTimer = null;
|
||||||
|
this.stream?.getTracks().forEach(t => t.stop());
|
||||||
|
this.stream = null;
|
||||||
|
for (const d of this.decoders.values()) { try { d.decoder.close(); } catch { /* closed */ } }
|
||||||
|
this.decoders.clear();
|
||||||
|
try { this.encoder?.close(); } catch { /* closed */ }
|
||||||
|
this.encoder = null;
|
||||||
|
await this.ctx?.close().catch(() => {});
|
||||||
|
this.ctx = null;
|
||||||
|
this.capture = this.playback = null;
|
||||||
|
this.running = false;
|
||||||
|
this.transmitting = false;
|
||||||
|
this.talking = {};
|
||||||
|
this.level = -100;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async restartInput(): Promise<void> {
|
||||||
|
if (!this.ctx || !this.inputGainNode) return;
|
||||||
|
this.source?.disconnect();
|
||||||
|
this.stream?.getTracks().forEach(t => t.stop());
|
||||||
|
const s = this.settings;
|
||||||
|
try {
|
||||||
|
this.stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: {
|
||||||
|
deviceId: s.inputDevice && s.inputDevice !== 'default' ? { exact: s.inputDevice } : undefined,
|
||||||
|
echoCancellation: s.echoCancellation,
|
||||||
|
noiseSuppression: s.noiseSuppression,
|
||||||
|
autoGainControl: s.autoGainControl,
|
||||||
|
channelCount: 1,
|
||||||
|
sampleRate: SAMPLE_RATE
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
this.error = `Microphone unavailable: ${(e as Error).message}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.error = '';
|
||||||
|
this.source = this.ctx.createMediaStreamSource(this.stream);
|
||||||
|
this.source.connect(this.inputGainNode);
|
||||||
|
this.refreshDevices();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async applyOutputDevice(): Promise<void> {
|
||||||
|
const ctx = this.ctx as (AudioContext & { setSinkId?: (id: string) => Promise<void> }) | null;
|
||||||
|
if (!ctx?.setSinkId) return;
|
||||||
|
try {
|
||||||
|
await ctx.setSinkId(this.settings.outputDevice === 'default' ? '' : this.settings.outputDevice);
|
||||||
|
} catch (e) {
|
||||||
|
this.error = `Output device unavailable: ${(e as Error).message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Connection ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
attach(client: MumbleClient): void {
|
||||||
|
this.unsubscribe.forEach(off => off());
|
||||||
|
this.client = client;
|
||||||
|
this.stats = { sent: 0, received: 0, decoded: 0, lost: 0 };
|
||||||
|
this.unsubscribe = [
|
||||||
|
client.on('voice', raw => { if (this.client === client) this.receive(raw); }),
|
||||||
|
client.on('userRemove', u => this.dropSession(u.session))
|
||||||
|
];
|
||||||
|
this.limitBitrate();
|
||||||
|
this.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
detach(): void {
|
||||||
|
this.endTransmission();
|
||||||
|
this.unsubscribe.forEach(off => off());
|
||||||
|
this.unsubscribe = [];
|
||||||
|
this.client = null;
|
||||||
|
for (const s of [...this.decoders.keys()]) this.dropSession(s);
|
||||||
|
this.playback?.port.postMessage({ clear: true });
|
||||||
|
this.talking = {};
|
||||||
|
this.transmitting = false;
|
||||||
|
// Release the microphone while not connected (unless testing it)
|
||||||
|
if (!this.testing) this.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opus bitrate that keeps bitrate plus packet overhead inside the server's per-user limit
|
||||||
|
private allowedBitrate(): number {
|
||||||
|
const max = this.client?.maxBandwidth ?? 0;
|
||||||
|
const overhead = PACKET_OVERHEAD_BYTES * 8 * (1000 / this.settings.frameMs);
|
||||||
|
return Math.min(this.settings.bitrate, max ? Math.max(8000, max - overhead) : Infinity);
|
||||||
|
}
|
||||||
|
|
||||||
|
private limitBitrate(): void {
|
||||||
|
if (this.allowedBitrate() !== this.effectiveBitrate) this.configureEncoder();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Sending ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private encoderConfig(): AudioEncoderConfig {
|
||||||
|
return {
|
||||||
|
codec: 'opus',
|
||||||
|
sampleRate: SAMPLE_RATE,
|
||||||
|
numberOfChannels: 1,
|
||||||
|
bitrate: this.effectiveBitrate || this.allowedBitrate(),
|
||||||
|
opus: { frameDuration: this.settings.frameMs * 1000, complexity: 10, useinbandfec: true, usedtx: false }
|
||||||
|
} as AudioEncoderConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
private configureEncoder(): void {
|
||||||
|
this.effectiveBitrate = this.allowedBitrate();
|
||||||
|
if (!this.ctx) return;
|
||||||
|
try { this.encoder?.close(); } catch { /* closed */ }
|
||||||
|
this.encoder = new AudioEncoder({
|
||||||
|
output: chunk => this.onEncoded(chunk),
|
||||||
|
error: e => { this.error = `Encoder: ${e.message}`; }
|
||||||
|
});
|
||||||
|
this.encoder.configure(this.encoderConfig());
|
||||||
|
}
|
||||||
|
|
||||||
|
private canTalk(): boolean {
|
||||||
|
const self = this.client?.self;
|
||||||
|
return !!self && this.client!.synced && !self.selfMute && !self.mute && !self.suppress && !self.selfDeaf && !self.deaf;
|
||||||
|
}
|
||||||
|
|
||||||
|
private onFrame(frame: Float32Array<ArrayBuffer>, rms: number): void {
|
||||||
|
const db = rms > 0 ? 20 * Math.log10(rms) : -100;
|
||||||
|
this.levelRaw = Math.max(-100, db);
|
||||||
|
const now = performance.now();
|
||||||
|
const s = this.settings;
|
||||||
|
|
||||||
|
if (this.testing) this.playback?.port.postMessage({ session: -1, pcm: frame.slice() });
|
||||||
|
|
||||||
|
let wanted: boolean;
|
||||||
|
if (s.mode === 'continuous') wanted = true;
|
||||||
|
else if (s.mode === 'ptt') wanted = this.pttDown;
|
||||||
|
else {
|
||||||
|
if (db > s.vadThreshold) this.lastVoiceAt = now;
|
||||||
|
wanted = now - this.lastVoiceAt < s.vadHoldMs;
|
||||||
|
}
|
||||||
|
wanted &&= this.canTalk() && !!this.encoder && this.encoder.state === 'configured';
|
||||||
|
|
||||||
|
if (wanted && !this.open) { this.open = true; this.transmitting = true; }
|
||||||
|
if (!wanted && this.open) { this.endTransmission(); return; }
|
||||||
|
if (!this.open) return;
|
||||||
|
|
||||||
|
const data = new AudioData({
|
||||||
|
format: 'f32-planar', sampleRate: SAMPLE_RATE, numberOfFrames: FRAME, numberOfChannels: 1,
|
||||||
|
timestamp: this.sampleTime, data: frame
|
||||||
|
});
|
||||||
|
this.sampleTime += 10000;
|
||||||
|
this.encoder!.encode(data);
|
||||||
|
data.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private onEncoded(chunk: EncodedAudioChunk): void {
|
||||||
|
const opus = new Uint8Array(chunk.byteLength);
|
||||||
|
chunk.copyTo(opus);
|
||||||
|
if (this.stopping) { this.held.push(opus); return; }
|
||||||
|
this.sendPacket(opus, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendPacket(opus: Uint8Array, last: boolean): void {
|
||||||
|
const client = this.client;
|
||||||
|
if (!client) return;
|
||||||
|
client.sendVoiceTunnel(encodeVoice({ target: 0, frame: this.frameCounter, opus, last }, client.protobufVoice));
|
||||||
|
this.frameCounter += this.settings.frameMs / 10;
|
||||||
|
this.stats.sent++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush what the encoder still holds and mark the final packet, so receivers end the stream cleanly
|
||||||
|
private endTransmission(): void {
|
||||||
|
if (!this.open) return;
|
||||||
|
this.open = false;
|
||||||
|
this.transmitting = false;
|
||||||
|
const enc = this.encoder;
|
||||||
|
if (!enc || enc.state !== 'configured') return;
|
||||||
|
this.stopping = true;
|
||||||
|
enc.flush().then(() => {
|
||||||
|
const held = this.held;
|
||||||
|
this.held = [];
|
||||||
|
this.stopping = false;
|
||||||
|
held.forEach((opus, i) => this.sendPacket(opus, i === held.length - 1));
|
||||||
|
if (!held.length) this.sendPacket(new Uint8Array(0), true);
|
||||||
|
}).catch(() => { this.stopping = false; this.held = []; });
|
||||||
|
}
|
||||||
|
|
||||||
|
private onKey(e: KeyboardEvent, down: boolean): void {
|
||||||
|
if (e.code !== this.settings.pttKey || e.repeat) return;
|
||||||
|
// Typing in a text field does not trigger push to talk
|
||||||
|
const t = e.target as HTMLElement | null;
|
||||||
|
if (down && t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
|
||||||
|
this.pttDown = down;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Receiving ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private receive(raw: Uint8Array): void {
|
||||||
|
const p = decodeVoice(raw);
|
||||||
|
if (!p) return;
|
||||||
|
this.stats.received++;
|
||||||
|
const self = this.client?.self;
|
||||||
|
if (!this.ctx || !this.playback || self?.selfDeaf || self?.deaf) return;
|
||||||
|
const user = this.client?.users.get(p.session);
|
||||||
|
if (user && this.settings.localMutes[userKey(user)]) return;
|
||||||
|
|
||||||
|
let d = this.decoders.get(p.session);
|
||||||
|
if (!d) {
|
||||||
|
const session = p.session;
|
||||||
|
const decoder = new AudioDecoder({
|
||||||
|
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 };
|
||||||
|
this.decoders.set(session, d);
|
||||||
|
if (user) this.setSessionGain(session, this.settings.userVolumes[userKey(user)] ?? 1);
|
||||||
|
}
|
||||||
|
if (d.lastFrame >= 0 && p.frame > d.lastFrame + 6 && p.frame - d.lastFrame < 500) this.stats.lost++;
|
||||||
|
d.lastFrame = p.frame;
|
||||||
|
|
||||||
|
if (p.opus.length) {
|
||||||
|
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 (d.timer) clearTimeout(d.timer);
|
||||||
|
// Speaking ends with the terminator, or when packets stop arriving
|
||||||
|
d.timer = setTimeout(() => { delete this.talking[p.session]; }, p.last ? this.settings.jitterMs + 60 : 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onDecoded(session: number, data: AudioData): void {
|
||||||
|
const pcm = new Float32Array(data.numberOfFrames);
|
||||||
|
data.copyTo(pcm, { planeIndex: 0, format: 'f32-planar' });
|
||||||
|
data.close();
|
||||||
|
this.stats.decoded++;
|
||||||
|
this.playback?.port.postMessage({ session, pcm }, [pcm.buffer]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private dropSession(session: number): void {
|
||||||
|
const d = this.decoders.get(session);
|
||||||
|
if (d) {
|
||||||
|
if (d.timer) clearTimeout(d.timer);
|
||||||
|
try { d.decoder.close(); } catch { /* closed */ }
|
||||||
|
this.decoders.delete(session);
|
||||||
|
}
|
||||||
|
this.playback?.port.postMessage({ drop: session });
|
||||||
|
delete this.talking[session];
|
||||||
|
}
|
||||||
|
|
||||||
|
private onLevels(_levels: Record<number, number>): void {
|
||||||
|
// Per-speaker peaks from the playback worklet, for future meters
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Per-user controls ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private setSessionGain(session: number, gain: number): void {
|
||||||
|
this.sessionGains.set(session, gain);
|
||||||
|
this.playback?.port.postMessage({ session, gain });
|
||||||
|
}
|
||||||
|
|
||||||
|
setUserVolume(user: { session: number; hash: string; name: string }, volume: number): void {
|
||||||
|
this.settings.userVolumes[userKey(user)] = volume;
|
||||||
|
this.save();
|
||||||
|
this.setSessionGain(user.session, volume);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLocalMute(user: { session: number; hash: string; name: string }, muted: boolean): void {
|
||||||
|
if (muted) this.settings.localMutes[userKey(user)] = true;
|
||||||
|
else delete this.settings.localMutes[userKey(user)];
|
||||||
|
this.save();
|
||||||
|
if (muted) this.dropSession(user.session);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTesting(on: boolean): void {
|
||||||
|
this.testing = on;
|
||||||
|
if (on) this.start();
|
||||||
|
else {
|
||||||
|
this.playback?.port.postMessage({ drop: -1 });
|
||||||
|
if (!this.client) this.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const voice = new VoiceEngine();
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
// Minimal AudioWorklet global scope typings (not part of the DOM lib)
|
||||||
|
declare abstract class AudioWorkletProcessor {
|
||||||
|
readonly port: MessagePort;
|
||||||
|
abstract process(inputs: Float32Array[][], outputs: Float32Array[][], parameters: Record<string, Float32Array>): boolean;
|
||||||
|
}
|
||||||
|
declare function registerProcessor(name: string, ctor: new () => AudioWorkletProcessor): void;
|
||||||
|
declare const sampleRate: number;
|
||||||
+167
@@ -0,0 +1,167 @@
|
|||||||
|
import DOMPurify, { type Config } from 'dompurify';
|
||||||
|
|
||||||
|
export type EmbedKind = 'image' | 'video' | 'audio' | 'youtube';
|
||||||
|
export interface Embed { kind: EmbedKind; url: string; name: string; videoId?: string; start?: number }
|
||||||
|
|
||||||
|
// YouTube links: watch, youtu.be, shorts, embed, live, music, mobile
|
||||||
|
export function youtubeVideo(href: string): { id: string; start: number } | null {
|
||||||
|
let u: URL;
|
||||||
|
try { u = new URL(href); } catch { return null; }
|
||||||
|
const host = u.hostname.replace(/^(www|m|music)\./, '');
|
||||||
|
let id = '';
|
||||||
|
if (host === 'youtu.be') id = u.pathname.slice(1).split('/')[0];
|
||||||
|
else if (host === 'youtube.com' || host === 'youtube-nocookie.com') {
|
||||||
|
if (u.pathname === '/watch') id = u.searchParams.get('v') ?? '';
|
||||||
|
else id = /^\/(?:shorts|embed|live|v)\/([^/?#]+)/.exec(u.pathname)?.[1] ?? '';
|
||||||
|
}
|
||||||
|
if (!/^[A-Za-z0-9_-]{11}$/.test(id)) return null;
|
||||||
|
// t=90, t=1m30s, start=90
|
||||||
|
const t = u.searchParams.get('t') ?? u.searchParams.get('start') ?? '';
|
||||||
|
const m = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s?)?$/.exec(t);
|
||||||
|
const start = m ? (Number(m[1] ?? 0) * 3600 + Number(m[2] ?? 0) * 60 + Number(m[3] ?? 0)) : 0;
|
||||||
|
return { id, start };
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXT: Record<string, EmbedKind> = {
|
||||||
|
png: 'image', jpg: 'image', jpeg: 'image', gif: 'image', webp: 'image', avif: 'image',
|
||||||
|
mp4: 'video', webm: 'video', mov: 'video', m4v: 'video',
|
||||||
|
mp3: 'audio', ogg: 'audio', opus: 'audio', wav: 'audio', flac: 'audio', m4a: 'audio'
|
||||||
|
};
|
||||||
|
|
||||||
|
export function mediaKind(name: string): EmbedKind | null {
|
||||||
|
const m = /\.([a-z0-9]+)$/i.exec(name);
|
||||||
|
return m ? EXT[m[1].toLowerCase()] ?? null : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const purifyConfig: Config & { RETURN_DOM_FRAGMENT: true } = {
|
||||||
|
ALLOWED_TAGS: ['a', 'b', 'i', 'u', 's', 'strong', 'em', 'br', 'p', 'span', 'div', 'ul', 'ol', 'li',
|
||||||
|
'blockquote', 'code', 'pre', 'img', 'h1', 'h2', 'h3', 'h4', 'table', 'thead', 'tbody', 'tr', 'td', 'th',
|
||||||
|
'hr', 'sub', 'sup', 'font'],
|
||||||
|
// style is reduced to a safe subset below; color is the <font color> attribute older clients send
|
||||||
|
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'style', 'color'],
|
||||||
|
ALLOW_DATA_ATTR: false,
|
||||||
|
RETURN_DOM_FRAGMENT: true
|
||||||
|
};
|
||||||
|
|
||||||
|
// Text styling that Mumble clients (Qt rich text) use; anything else is dropped
|
||||||
|
const STYLE_PROPS: Record<string, RegExp> = {
|
||||||
|
'color': /^(#[0-9a-f]{3,8}|rgba?\([\d\s.,%]+\)|[a-z]{3,20})$/i,
|
||||||
|
'background-color': /^(#[0-9a-f]{3,8}|rgba?\([\d\s.,%]+\)|[a-z]{3,20})$/i,
|
||||||
|
'font-weight': /^(normal|bold|bolder|lighter|[1-9]00)$/i,
|
||||||
|
'font-style': /^(normal|italic|oblique)$/i,
|
||||||
|
'text-decoration': /^(none|underline|line-through|overline)( (underline|line-through|overline))*$/i,
|
||||||
|
'font-size': /^(\d{1,2}(\.\d+)?(pt|px|em|%)|small|medium|large|x-large|xx-large|smaller|larger)$/i,
|
||||||
|
'text-align': /^(left|right|center|justify)$/i
|
||||||
|
};
|
||||||
|
|
||||||
|
// rgb(1, 2, 3) -> #010203, which every Mumble client understands
|
||||||
|
function normalizeColor(v: string): string {
|
||||||
|
const m = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i.exec(v);
|
||||||
|
return m ? '#' + m.slice(1, 4).map(n => Math.min(255, Number(n)).toString(16).padStart(2, '0')).join('') : v;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cleanStyle(style: string): string {
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const decl of style.split(';')) {
|
||||||
|
const i = decl.indexOf(':');
|
||||||
|
if (i < 0) continue;
|
||||||
|
const prop = decl.slice(0, i).trim().toLowerCase();
|
||||||
|
let value = decl.slice(i + 1).trim().replace(/\s*!important$/i, '');
|
||||||
|
if (!STYLE_PROPS[prop]?.test(value)) continue;
|
||||||
|
if (prop.endsWith('color')) value = normalizeColor(value);
|
||||||
|
out.push(`${prop}:${value}`);
|
||||||
|
}
|
||||||
|
return out.join(';');
|
||||||
|
}
|
||||||
|
|
||||||
|
DOMPurify.addHook('afterSanitizeAttributes', node => {
|
||||||
|
const el = node as Element;
|
||||||
|
if (el.hasAttribute('style')) {
|
||||||
|
const clean = cleanStyle(el.getAttribute('style') ?? '');
|
||||||
|
if (clean) el.setAttribute('style', clean);
|
||||||
|
else el.removeAttribute('style');
|
||||||
|
}
|
||||||
|
if (el.hasAttribute('color') && !STYLE_PROPS.color.test(el.getAttribute('color') ?? '')) el.removeAttribute('color');
|
||||||
|
if (el.tagName === 'A') {
|
||||||
|
const href = el.getAttribute('href') ?? '';
|
||||||
|
if (!/^https?:\/\//i.test(href)) el.removeAttribute('href');
|
||||||
|
el.setAttribute('target', '_blank');
|
||||||
|
el.setAttribute('rel', 'noopener noreferrer');
|
||||||
|
}
|
||||||
|
// Only inline data: images; remote images would leak our IP to arbitrary hosts
|
||||||
|
if (el.tagName === 'IMG' && !/^data:image\/(png|jpe?g|gif|webp);/i.test(el.getAttribute('src') ?? '')) {
|
||||||
|
el.replaceWith(document.createTextNode('[image]'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sanitize an incoming Mumble HTML message and pull out media links from trusted hosts.
|
||||||
|
export function renderIncoming(html: string, trustedHosts: string[]): { html: string; embeds: Embed[] } {
|
||||||
|
const frag = DOMPurify.sanitize(html, purifyConfig);
|
||||||
|
const embeds: Embed[] = [];
|
||||||
|
for (const a of frag.querySelectorAll('a[href]')) {
|
||||||
|
const yt = youtubeVideo(a.getAttribute('href')!);
|
||||||
|
if (yt) {
|
||||||
|
if (!embeds.some(e => e.videoId === yt.id) && embeds.filter(e => e.kind === 'youtube').length < 3) {
|
||||||
|
embeds.push({ kind: 'youtube', url: a.getAttribute('href')!, name: a.textContent ?? '', videoId: yt.id, start: yt.start });
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let url: URL;
|
||||||
|
try { url = new URL(a.getAttribute('href')!); } catch { continue; }
|
||||||
|
if (!trustedHosts.includes(url.host.toLowerCase())) continue;
|
||||||
|
const name = decodeURIComponent(url.pathname.split('/').pop() ?? '');
|
||||||
|
const kind = mediaKind(name);
|
||||||
|
if (kind) embeds.push({ kind, url: url.href, name });
|
||||||
|
}
|
||||||
|
// The inline thumbnail is only there for vanilla clients; we show the real media instead
|
||||||
|
if (embeds.some(e => e.kind !== 'youtube')) frag.querySelectorAll('img').forEach(img => img.remove());
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.append(frag);
|
||||||
|
return { html: div.innerHTML.replace(/^(<br\s*\/?>)+|(<br\s*\/?>)+$/g, ''), embeds };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function escapeHtml(s: string): string {
|
||||||
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain composer text to the HTML Mumble clients expect: escaped, links clickable, newlines as <br/>.
|
||||||
|
// Output must stay well-formed XML: Murmur parses long messages as XML and denies them otherwise.
|
||||||
|
export function textToHtml(text: string): string {
|
||||||
|
return escapeHtml(text)
|
||||||
|
.replace(/https?:\/\/[^\s<]+[^\s<.,:;"')\]]/g, url => `<a href="${url}">${url}</a>`)
|
||||||
|
.replace(/\r?\n/g, '<br/>');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBytes(n: number): string {
|
||||||
|
if (n < 1024) return `${n} B`;
|
||||||
|
if (n < 1048576) return `${(n / 1024).toFixed(1)} KB`;
|
||||||
|
if (n < 1073741824) return `${(n / 1048576).toFixed(1)} MB`;
|
||||||
|
return `${(n / 1073741824).toFixed(2)} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Outgoing rich text (comments) ────────────────────────────────────────────
|
||||||
|
|
||||||
|
const VOID_TAGS = new Set(['br', 'hr', 'img']);
|
||||||
|
const OUT_ATTRS = new Set(['href', 'src', 'alt', 'title', 'style', 'color']);
|
||||||
|
const escText = (t: string) => t.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
const escAttr = (t: string) => escText(t).replace(/"/g, '"');
|
||||||
|
|
||||||
|
function serialize(node: Node): string {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) return escText(node.textContent ?? '');
|
||||||
|
if (node.nodeType !== Node.ELEMENT_NODE) return '';
|
||||||
|
const el = node as Element;
|
||||||
|
const tag = el.tagName.toLowerCase();
|
||||||
|
const attrs = [...el.attributes].filter(a => OUT_ATTRS.has(a.name)).map(a => ` ${a.name}="${escAttr(a.value)}"`).join('');
|
||||||
|
if (VOID_TAGS.has(tag)) return `<${tag}${attrs}/>`;
|
||||||
|
return `<${tag}${attrs}>${[...el.childNodes].map(serialize).join('')}</${tag}>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitized, well-formed XHTML that Murmur's XML check accepts and Mumble clients render
|
||||||
|
export function toMumbleHtml(html: string): string {
|
||||||
|
const frag = DOMPurify.sanitize(html, purifyConfig);
|
||||||
|
return [...frag.childNodes].map(serialize).join('')
|
||||||
|
.replace(/(<p><br\/><\/p>)+$/, '')
|
||||||
|
.replace(/^(<br\/>)+|(<br\/>)+$/g, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// SVG path data (24x24 viewBox, square caps) shared by <Icon> and the tray icon
|
||||||
|
export const ICON_PATHS: Record<string, string> = {
|
||||||
|
mic: 'M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3zM19 10v2a7 7 0 0 1-14 0v-2M12 19v3',
|
||||||
|
'mic-off': 'M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3zM19 10v2a7 7 0 0 1-14 0v-2M12 19v3M3 3l18 18',
|
||||||
|
headphones: 'M3 14v-2a9 9 0 0 1 18 0v2M21 14v7h-4v-7zM3 14v7h4v-7z',
|
||||||
|
'headphones-off': 'M3 14v-2a9 9 0 0 1 18 0v2M21 14v7h-4v-7zM3 14v7h4v-7zM3 3l18 18',
|
||||||
|
settings: 'M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6',
|
||||||
|
plus: 'M12 5v14M5 12h14',
|
||||||
|
paperclip: 'M21.4 11.1l-9.2 9.2a6 6 0 0 1-8.5-8.5l9.2-9.2a4 4 0 0 1 5.7 5.7l-9.2 9.2a2 2 0 0 1-2.8-2.8l8.5-8.5',
|
||||||
|
hash: 'M4 9h16M4 15h16M10 3L8 21M16 3l-2 18',
|
||||||
|
channel: 'M11 5L6 9H2v6h4l5 4zM15.5 8.5a5 5 0 0 1 0 7',
|
||||||
|
users: 'M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM23 21v-2a4 4 0 0 0-3-3.9M16 3.1a4 4 0 0 1 0 7.8',
|
||||||
|
menu: 'M3 6h18M3 12h18M3 18h18',
|
||||||
|
x: 'M18 6L6 18M6 6l12 12',
|
||||||
|
power: 'M18.4 6.6a9 9 0 1 1-12.8 0M12 2v10',
|
||||||
|
lock: 'M5 11h14v10H5zM8 11V7a4 4 0 0 1 8 0v4',
|
||||||
|
file: 'M14 2H6v20h12V8zM14 2v6h6',
|
||||||
|
send: 'M22 2L11 13M22 2l-7 20-4-9-9-4z',
|
||||||
|
message: 'M21 15H7l-4 4V3h18z',
|
||||||
|
trash: 'M3 6h18M8 6V3h8v3M19 6l-1 15H6L5 6',
|
||||||
|
server: 'M2 3h20v8H2zM2 13h20v8H2zM6 7h.01M6 17h.01',
|
||||||
|
shield: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z',
|
||||||
|
download: 'M12 3v12M7 10l5 5 5-5M4 21h16',
|
||||||
|
list: 'M9 6h12M9 12h12M9 18h12M4 6h.01M4 12h.01M4 18h.01',
|
||||||
|
'list-ordered': 'M10 6h11M10 12h11M10 18h11M4 4v4M3 18h3l-3 3h3M3 10h2l-2 3h2',
|
||||||
|
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',
|
||||||
|
code: 'M16 18l6-6-6-6M8 6l-6 6 6 6',
|
||||||
|
eraser: 'M20 20H7L3 16l10-10 7 7-6.5 7M6 11l7 7'
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { native, type IdentitySummary } from './native.ts';
|
||||||
|
|
||||||
|
// Client certificates kept by the desktop app's main process
|
||||||
|
class Identities {
|
||||||
|
list = $state<IdentitySummary[]>([]);
|
||||||
|
setupDone = $state(true);
|
||||||
|
loaded = $state(false);
|
||||||
|
|
||||||
|
async refresh(): Promise<void> {
|
||||||
|
if (!native) return;
|
||||||
|
const r = await native.identities.list();
|
||||||
|
this.list = r.identities;
|
||||||
|
this.setupDone = r.setupDone;
|
||||||
|
this.loaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
get default(): IdentitySummary | undefined {
|
||||||
|
return this.list.find(i => i.isDefault) ?? this.list[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
byId(id: string | undefined): IdentitySummary | undefined {
|
||||||
|
return this.list.find(i => i.id === id) ?? this.default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const identities = new Identities();
|
||||||
|
identities.refresh();
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
export interface MenuItem {
|
||||||
|
label: string;
|
||||||
|
action?: () => void;
|
||||||
|
danger?: boolean;
|
||||||
|
checked?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
separator?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One context menu for the whole app, opened at the pointer position
|
||||||
|
class MenuStore {
|
||||||
|
open = $state<{ x: number; y: number; items: MenuItem[] } | null>(null);
|
||||||
|
|
||||||
|
show(e: MouseEvent, items: MenuItem[]): void {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
this.open = { x: e.clientX, y: e.clientY, items };
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.open = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const menu = new MenuStore();
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type { Transport, ServerCertInfo, CertDetails } from '../core/transport.ts';
|
||||||
|
|
||||||
|
interface NativeApi {
|
||||||
|
platformInfo(): Promise<{ os: string; osVersion: string }>;
|
||||||
|
describeCerts(ders: Uint8Array[]): Promise<CertDetails[]>;
|
||||||
|
updateTray(state: { icon: string; tooltip: string; connected: boolean; server: string; muted: boolean; deafened: boolean; mode: string }): void;
|
||||||
|
onTrayAction(fn: (action: string) => void): void;
|
||||||
|
identities: {
|
||||||
|
list(): Promise<{ identities: IdentitySummary[]; setupDone: boolean }>;
|
||||||
|
create(name: string, email: string): Promise<IdentitySummary>;
|
||||||
|
importP12(bytes: Uint8Array, password: string, name: string): Promise<IdentitySummary>;
|
||||||
|
findMumble(): Promise<{ name: string } | null>;
|
||||||
|
importMumble(name: string): Promise<IdentitySummary>;
|
||||||
|
exportP12(id: string, password: string): Promise<string | null>;
|
||||||
|
setDefault(id: string): Promise<void>;
|
||||||
|
rename(id: string, name: string): Promise<void>;
|
||||||
|
remove(id: string): Promise<void>;
|
||||||
|
finishSetup(): Promise<void>;
|
||||||
|
};
|
||||||
|
open(connId: string, host: string, port: number, identityId?: string): Promise<void>;
|
||||||
|
send(connId: string, bytes: Uint8Array): void;
|
||||||
|
close(connId: string): void;
|
||||||
|
on(event: 'secure' | 'data' | 'close', fn: (connId: string, ...args: any[]) => void): () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IdentitySummary {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
fingerprint: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
cert: CertDetails;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window { mumh5Native?: NativeApi }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const native: NativeApi | null = typeof window !== 'undefined' ? window.mumh5Native ?? null : null;
|
||||||
|
|
||||||
|
// Transport backed by a TLS socket in the Electron main process.
|
||||||
|
// `secure` resolves once the TLS handshake is done, before any Mumble data is sent,
|
||||||
|
// so the caller can check the server certificate before sending a password.
|
||||||
|
export class ElectronTransport implements Transport {
|
||||||
|
onData: ((chunk: Uint8Array) => void) | null = null;
|
||||||
|
onClose: ((reason: string) => void) | null = null;
|
||||||
|
readonly secure: Promise<ServerCertInfo>;
|
||||||
|
private id = crypto.randomUUID();
|
||||||
|
private offs: (() => void)[] = [];
|
||||||
|
private api: NativeApi;
|
||||||
|
|
||||||
|
constructor(api: NativeApi, host: string, port: number, identityId?: string) {
|
||||||
|
this.api = api;
|
||||||
|
let resolveSecure!: (i: ServerCertInfo) => void;
|
||||||
|
let rejectSecure!: (e: Error) => void;
|
||||||
|
this.secure = new Promise((res, rej) => { resolveSecure = res; rejectSecure = rej; });
|
||||||
|
const id = this.id;
|
||||||
|
this.offs.push(
|
||||||
|
api.on('secure', (cid, info) => { if (cid === id) resolveSecure(info); }),
|
||||||
|
api.on('data', (cid, chunk) => { if (cid === id) this.onData?.(chunk); }),
|
||||||
|
api.on('close', (cid, reason) => {
|
||||||
|
if (cid !== id) return;
|
||||||
|
this.offs.forEach(off => off());
|
||||||
|
rejectSecure(new Error(reason));
|
||||||
|
this.onClose?.(reason);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
api.open(id, host, port, identityId).catch(e => rejectSecure(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
send(bytes: Uint8Array): void { this.api.send(this.id, bytes); }
|
||||||
|
close(): void { this.api.close(this.id); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,678 @@
|
|||||||
|
import { createCodec } from '../core/proto.ts';
|
||||||
|
import { MumbleClient, describeDenial, type Channel, type User, type TextMessage } from '../core/client.ts';
|
||||||
|
import { native, ElectronTransport } from './native.ts';
|
||||||
|
import type { ServerCertInfo } from '../core/transport.ts';
|
||||||
|
import { store, type SavedServer } from './settings.svelte.ts';
|
||||||
|
import { renderIncoming, textToHtml, escapeHtml, formatBytes, type Embed } from './html.ts';
|
||||||
|
import { uploadFile, makeThumbnail, makeInlineImage, fetchUploadInfo, checkFile, type UploadInfo } from './upload.ts';
|
||||||
|
import { voice } from './audio/voice.svelte.ts';
|
||||||
|
|
||||||
|
const codec = createCodec();
|
||||||
|
|
||||||
|
// A chat view is a channel ("ch:<id>") or a direct conversation with a user ("dm:<session>").
|
||||||
|
export type ViewKey = `ch:${number}` | `dm:${number}`;
|
||||||
|
|
||||||
|
export interface ChatEntry {
|
||||||
|
id: number;
|
||||||
|
kind: 'text' | 'system';
|
||||||
|
author: string;
|
||||||
|
authorSession: number | null;
|
||||||
|
self: boolean;
|
||||||
|
html: string;
|
||||||
|
embeds: Embed[];
|
||||||
|
time: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadJob {
|
||||||
|
id: number;
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
progress: number;
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CertPrompt {
|
||||||
|
server: SavedServer;
|
||||||
|
hostPort: string;
|
||||||
|
fingerprint: string;
|
||||||
|
previous: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right-hand panel: member list, a channel (description preview or its side chat), or a user profile
|
||||||
|
export type Panel =
|
||||||
|
| { kind: 'members' }
|
||||||
|
| { kind: 'channel'; id: number; tab: 'info' | 'chat' }
|
||||||
|
| { kind: 'user'; session: number };
|
||||||
|
|
||||||
|
let nextId = 1;
|
||||||
|
|
||||||
|
export class Session {
|
||||||
|
status = $state<'idle' | 'connecting' | 'connected'>('idle');
|
||||||
|
error = $state('');
|
||||||
|
server = $state<SavedServer | null>(null);
|
||||||
|
// Bumped on every client state change; views read it to re-derive from the client's maps
|
||||||
|
tick = $state(0);
|
||||||
|
view = $state<ViewKey>('ch:0');
|
||||||
|
panel = $state<Panel>({ kind: 'members' });
|
||||||
|
logs = $state<Record<string, ChatEntry[]>>({});
|
||||||
|
unread = $state<Record<string, number>>({});
|
||||||
|
uploads = $state<UploadJob[]>([]);
|
||||||
|
certPrompt = $state<CertPrompt | null>(null);
|
||||||
|
rtt = $state(0);
|
||||||
|
// TLS details of the current server, for the certificate viewer and the trusted badge
|
||||||
|
serverCert = $state<ServerCertInfo | null>(null);
|
||||||
|
uploadInfo = $state<UploadInfo | null>(null);
|
||||||
|
// Channel tree expansion the user chose; channels not in here follow the default
|
||||||
|
expanded = $state<Record<number, boolean>>({});
|
||||||
|
|
||||||
|
client: MumbleClient | null = null;
|
||||||
|
// Incremented per connect so a slow, superseded attempt can tell it is stale
|
||||||
|
private attempt = 0;
|
||||||
|
private uploadInfoFor = '';
|
||||||
|
// Blob hashes already requested, so a preview does not ask the server repeatedly
|
||||||
|
private requested = new Set<string>();
|
||||||
|
// Last message with an inline image, resent as a plain link if the server refuses it
|
||||||
|
private lastImageSend: { key: string; fallback: string; at: number } | null = null;
|
||||||
|
// Channel we asked to move to; the chat view switches when the server confirms it
|
||||||
|
private pendingJoin: number | null = null;
|
||||||
|
|
||||||
|
// ─── Derived helpers ────────────────────────────────────────────────────────
|
||||||
|
// The client mutates its objects in place. These return shallow copies and read `tick`,
|
||||||
|
// so every state change hands Svelte new objects and dependent views update.
|
||||||
|
|
||||||
|
get self(): User | null {
|
||||||
|
void this.tick;
|
||||||
|
const u = this.client?.self;
|
||||||
|
return u ? { ...u } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
channel(id: number): Channel | undefined {
|
||||||
|
void this.tick;
|
||||||
|
const c = this.client?.channels.get(id);
|
||||||
|
return c && { ...c };
|
||||||
|
}
|
||||||
|
|
||||||
|
user(session: number): User | undefined {
|
||||||
|
void this.tick;
|
||||||
|
const u = this.client?.users.get(session);
|
||||||
|
return u && { ...u };
|
||||||
|
}
|
||||||
|
|
||||||
|
childrenOf(parent: number | null): Channel[] {
|
||||||
|
void this.tick;
|
||||||
|
if (!this.client) return [];
|
||||||
|
return [...this.client.channels.values()]
|
||||||
|
.filter(c => (parent === null ? c.id === 0 : c.parent === parent && c.id !== 0))
|
||||||
|
.sort((a, b) => a.position - b.position || a.name.localeCompare(b.name))
|
||||||
|
.map(c => ({ ...c }));
|
||||||
|
}
|
||||||
|
|
||||||
|
usersIn(channelId: number): User[] {
|
||||||
|
void this.tick;
|
||||||
|
if (!this.client) return [];
|
||||||
|
return [...this.client.users.values()]
|
||||||
|
.filter(u => u.channelId === channelId)
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
.map(u => ({ ...u }));
|
||||||
|
}
|
||||||
|
|
||||||
|
allUsers(): User[] {
|
||||||
|
void this.tick;
|
||||||
|
return this.client ? [...this.client.users.values()].sort((a, b) => a.name.localeCompare(b.name)).map(u => ({ ...u })) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Users in a channel and all of its subchannels
|
||||||
|
subtreeUserCount(id: number): number {
|
||||||
|
void this.tick;
|
||||||
|
const client = this.client;
|
||||||
|
if (!client) return 0;
|
||||||
|
const ids = new Set([id]);
|
||||||
|
let grew = true;
|
||||||
|
while (grew) {
|
||||||
|
grew = false;
|
||||||
|
for (const c of client.channels.values()) {
|
||||||
|
if (c.parent != null && ids.has(c.parent) && !ids.has(c.id)) { ids.add(c.id); grew = true; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let n = 0;
|
||||||
|
for (const u of client.users.values()) if (ids.has(u.channelId)) n++;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collapsed by default; channels with people in them open up, like the desktop client
|
||||||
|
isExpanded(id: number): boolean {
|
||||||
|
if (id === 0) return true;
|
||||||
|
return this.expanded[id] ?? this.subtreeUserCount(id) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleExpanded(id: number): void {
|
||||||
|
this.expanded[id] = !this.isExpanded(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
setAllExpanded(open: boolean): void {
|
||||||
|
const all: Record<number, boolean> = {};
|
||||||
|
for (const id of this.client?.channels.keys() ?? []) all[id] = open;
|
||||||
|
this.expanded = all;
|
||||||
|
}
|
||||||
|
|
||||||
|
viewTitle(key: string = this.view): string {
|
||||||
|
const [kind, id] = key.split(':');
|
||||||
|
if (kind === 'ch') return this.channel(Number(id))?.name || 'Root';
|
||||||
|
return this.user(Number(id))?.name ?? 'Offline user';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keys whose chat is on screen right now (main view and an open side chat)
|
||||||
|
private visibleKeys(): string[] {
|
||||||
|
const keys: string[] = [this.view];
|
||||||
|
if (this.panel.kind === 'channel' && this.panel.tab === 'chat') keys.push(`ch:${this.panel.id}`);
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Connection ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async connect(server: SavedServer): Promise<void> {
|
||||||
|
this.disconnect();
|
||||||
|
const attempt = ++this.attempt;
|
||||||
|
this.server = server;
|
||||||
|
this.error = '';
|
||||||
|
this.logs = {};
|
||||||
|
this.unread = {};
|
||||||
|
this.expanded = {};
|
||||||
|
this.panel = { kind: 'members' };
|
||||||
|
this.requested.clear();
|
||||||
|
this.serverCert = null;
|
||||||
|
this.status = 'connecting';
|
||||||
|
|
||||||
|
if (!native) {
|
||||||
|
this.fail('Connecting needs the desktop app for now. The web version will connect through a proxy.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transport = new ElectronTransport(native, server.host, server.port, server.identityId);
|
||||||
|
let info;
|
||||||
|
try {
|
||||||
|
info = await transport.secure;
|
||||||
|
} catch (e) {
|
||||||
|
if (attempt === this.attempt) this.fail((e as Error).message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (attempt !== this.attempt) return transport.close();
|
||||||
|
this.serverCert = info;
|
||||||
|
|
||||||
|
// Trust on first use: pin the certificate, and stop before sending credentials if it changed
|
||||||
|
const hostPort = `${server.host.toLowerCase()}:${server.port}`;
|
||||||
|
const pinned = store.pins[hostPort];
|
||||||
|
if (!pinned) {
|
||||||
|
store.pin(hostPort, info.fingerprint);
|
||||||
|
} else if (pinned !== info.fingerprint && !info.authorized) {
|
||||||
|
transport.close();
|
||||||
|
this.certPrompt = { server, hostPort, fingerprint: info.fingerprint, previous: pinned };
|
||||||
|
this.fail('The server certificate changed.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new MumbleClient(codec);
|
||||||
|
this.client = client;
|
||||||
|
this.wire(client);
|
||||||
|
// TLS is up but the server may still never finish the login
|
||||||
|
setTimeout(() => { if (this.client === client && !client.synced) client.disconnect('Login timed out'); }, 20000);
|
||||||
|
const platform = await native.platformInfo();
|
||||||
|
if (attempt !== this.attempt) return transport.close();
|
||||||
|
client.connect(transport, {
|
||||||
|
username: server.username,
|
||||||
|
password: server.password,
|
||||||
|
os: platform.os,
|
||||||
|
osVersion: platform.osVersion
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
acceptCertificate(): void {
|
||||||
|
const p = this.certPrompt;
|
||||||
|
if (!p) return;
|
||||||
|
store.pin(p.hostPort, p.fingerprint);
|
||||||
|
this.certPrompt = null;
|
||||||
|
this.connect(p.server);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect(): void {
|
||||||
|
this.attempt++;
|
||||||
|
const client = this.client;
|
||||||
|
this.client = null;
|
||||||
|
client?.disconnect();
|
||||||
|
sessions.onClosed(this);
|
||||||
|
this.status = 'idle';
|
||||||
|
this.tick++;
|
||||||
|
}
|
||||||
|
|
||||||
|
private fail(msg: string): void {
|
||||||
|
this.status = 'idle';
|
||||||
|
this.error = msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
private wire(client: MumbleClient): void {
|
||||||
|
const live = () => this.client === client;
|
||||||
|
const bump = () => { if (live()) this.tick++; };
|
||||||
|
|
||||||
|
client.on('synced', () => {
|
||||||
|
if (!live()) return;
|
||||||
|
this.status = 'connected';
|
||||||
|
this.view = `ch:${client.self?.channelId ?? 0}`;
|
||||||
|
sessions.onSynced(this);
|
||||||
|
if (client.welcomeText) this.system(this.view, client.welcomeText, true);
|
||||||
|
bump();
|
||||||
|
});
|
||||||
|
client.on('channel', bump);
|
||||||
|
client.on('channelRemove', id => {
|
||||||
|
if (!live()) return;
|
||||||
|
if (this.panel.kind === 'channel' && this.panel.id === id) this.panel = { kind: 'members' };
|
||||||
|
bump();
|
||||||
|
});
|
||||||
|
client.on('ping', rtt => { if (live()) this.rtt = rtt; });
|
||||||
|
|
||||||
|
client.on('user', (user, changed, _actor, isNew) => {
|
||||||
|
if (!live()) return;
|
||||||
|
const self = client.self;
|
||||||
|
if (client.synced && self) {
|
||||||
|
if (isNew) {
|
||||||
|
if (user.channelId === self.channelId) this.system(`ch:${self.channelId}`, `${escapeHtml(user.name)} connected`);
|
||||||
|
} else if (changed.includes('userId') && user.userId != null) {
|
||||||
|
const who = user.session === self.session ? 'You are' : `${escapeHtml(user.name)} is`;
|
||||||
|
this.system(this.view, `${who} now registered on this server`);
|
||||||
|
} else if (changed.includes('channelId')) {
|
||||||
|
if (user.session === self.session) {
|
||||||
|
// Follow our own channel moves in the chat view (also from a DM when we asked to join)
|
||||||
|
if (this.view.startsWith('ch:') || this.pendingJoin === user.channelId) this.selectView(`ch:${user.channelId}`);
|
||||||
|
this.pendingJoin = null;
|
||||||
|
this.system(`ch:${user.channelId}`, `You joined ${escapeHtml(this.channel(user.channelId)?.name || 'Root')}`);
|
||||||
|
} else if (user.channelId === self.channelId) {
|
||||||
|
this.system(`ch:${self.channelId}`, `${escapeHtml(user.name)} joined the channel`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bump();
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('userRemove', (user, actor, reason, ban) => {
|
||||||
|
if (!live()) return;
|
||||||
|
const self = client.self;
|
||||||
|
if (self && user.channelId === self.channelId) {
|
||||||
|
const by = actor != null && actor !== user.session ? client.users.get(actor)?.name : null;
|
||||||
|
const what = by ? `${ban ? 'was banned' : 'was kicked'} by ${escapeHtml(by)}` : 'disconnected';
|
||||||
|
this.system(`ch:${self.channelId}`, `${escapeHtml(user.name)} ${what}${reason ? `: ${escapeHtml(reason)}` : ''}`);
|
||||||
|
}
|
||||||
|
if (this.panel.kind === 'user' && this.panel.session === user.session) this.panel = { kind: 'members' };
|
||||||
|
bump();
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('text', msg => { if (live()) this.receive(client, msg); });
|
||||||
|
|
||||||
|
client.on('permissionDenied', d => {
|
||||||
|
if (!live()) return;
|
||||||
|
// An image preview can exceed what the server accepts; the link alone still gets through
|
||||||
|
const img = this.lastImageSend;
|
||||||
|
if (d.type === 4 && img && Date.now() - img.at < 5000) {
|
||||||
|
this.lastImageSend = null;
|
||||||
|
this.system(img.key, 'The server refused the image preview, sent the link only');
|
||||||
|
client.sendText(this.target(img.key), img.fallback);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (d.channelId != null && d.channelId === this.pendingJoin) this.pendingJoin = null;
|
||||||
|
const ch = d.channelId != null ? client.channels.get(d.channelId)?.name : undefined;
|
||||||
|
this.system(this.view, escapeHtml(describeDenial(d, ch)));
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('close', reason => {
|
||||||
|
if (this.client !== client) return;
|
||||||
|
this.client = null;
|
||||||
|
sessions.onClosed(this);
|
||||||
|
this.status = 'idle';
|
||||||
|
this.error = reason === 'Disconnected' ? '' : reason;
|
||||||
|
this.tick++;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Panels, descriptions, comments ─────────────────────────────────────────
|
||||||
|
|
||||||
|
showChannel(id: number, tab: 'info' | 'chat' = 'info'): void {
|
||||||
|
this.panel = { kind: 'channel', id, tab };
|
||||||
|
if (tab === 'chat') delete this.unread[`ch:${id}`];
|
||||||
|
this.loadDescription(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
showUser(session: number): void {
|
||||||
|
this.panel = { kind: 'user', session };
|
||||||
|
this.loadComment(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
closePanel(): void {
|
||||||
|
this.panel = { kind: 'members' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Long descriptions and comments are only announced by hash; fetch the text on demand
|
||||||
|
loadDescription(id: number): void {
|
||||||
|
const c = this.client?.channels.get(id);
|
||||||
|
if (!c?.descriptionHash || c.description) return;
|
||||||
|
const tag = `d:${id}:${[...c.descriptionHash].join(',')}`;
|
||||||
|
if (this.requested.has(tag)) return;
|
||||||
|
this.requested.add(tag);
|
||||||
|
this.client!.requestBlob({ descriptions: [id] });
|
||||||
|
}
|
||||||
|
|
||||||
|
loadComment(session: number): void {
|
||||||
|
const u = this.client?.users.get(session);
|
||||||
|
if (!u?.commentHash || u.comment) return;
|
||||||
|
const tag = `c:${session}:${[...u.commentHash].join(',')}`;
|
||||||
|
if (this.requested.has(tag)) return;
|
||||||
|
this.requested.add(tag);
|
||||||
|
this.client!.requestBlob({ comments: [session] });
|
||||||
|
}
|
||||||
|
|
||||||
|
register(session: number): void {
|
||||||
|
this.client?.register(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mumble has no rename while connected: reconnect under the new name (saved for this server)
|
||||||
|
changeName(name: string): void {
|
||||||
|
const server = this.server;
|
||||||
|
if (!server || !name.trim()) return;
|
||||||
|
const updated = { ...$state.snapshot(server), username: name.trim() };
|
||||||
|
store.upsertServer(updated);
|
||||||
|
this.connect(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
// html comes from the rich editor, already sanitized XHTML
|
||||||
|
setComment(html: string): void {
|
||||||
|
this.client?.setComment(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Largest description or image message the server accepts (0: no limit)
|
||||||
|
get htmlLimit(): number {
|
||||||
|
void this.tick;
|
||||||
|
return this.client?.config.imageMessageLength ?? 131072;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Chat ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private push(key: string, entry: Omit<ChatEntry, 'id' | 'time'>): void {
|
||||||
|
// Assign first, then push through the stored proxy; pushing onto the raw array would not be reactive
|
||||||
|
if (!this.logs[key]) this.logs[key] = [];
|
||||||
|
this.logs[key].push({ ...entry, id: nextId++, time: Date.now() });
|
||||||
|
if (sessions.active !== this || !this.visibleKeys().includes(key)) this.unread[key] = (this.unread[key] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private system(key: string, html: string, trusted = false): void {
|
||||||
|
const rendered = trusted ? renderIncoming(html, store.trustedHosts) : { html, embeds: [] };
|
||||||
|
this.push(key, { kind: 'system', author: '', authorSession: null, self: false, ...rendered });
|
||||||
|
}
|
||||||
|
|
||||||
|
private receive(client: MumbleClient, msg: TextMessage): void {
|
||||||
|
const self = client.self;
|
||||||
|
const sender = msg.actor != null ? client.users.get(msg.actor) : undefined;
|
||||||
|
let key: string;
|
||||||
|
if (msg.actor == null) key = this.view;
|
||||||
|
else if (msg.channels.length || msg.trees.length) key = `ch:${msg.channels[0] ?? self?.channelId ?? 0}`;
|
||||||
|
else key = `dm:${msg.actor}`;
|
||||||
|
|
||||||
|
const rendered = renderIncoming(msg.html, store.trustedHosts);
|
||||||
|
if (msg.actor == null) {
|
||||||
|
this.push(key, { kind: 'system', author: '', authorSession: null, self: false, ...rendered });
|
||||||
|
} else {
|
||||||
|
this.push(key, { kind: 'text', author: sender ? store.displayName(sender) : `#${msg.actor}`, authorSession: msg.actor, self: false, ...rendered });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opening a server marks what is on screen as read
|
||||||
|
markVisibleRead(): void {
|
||||||
|
for (const k of this.visibleKeys()) delete this.unread[k];
|
||||||
|
}
|
||||||
|
|
||||||
|
selectView(key: ViewKey): void {
|
||||||
|
this.view = key;
|
||||||
|
delete this.unread[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
// The view follows once the server confirms the move (see the 'user' handler). Murmur
|
||||||
|
// silently drops own state changes sent too quickly (leaky bucket, about one per second),
|
||||||
|
// so an unconfirmed join is sent once more before giving up.
|
||||||
|
joinChannel(id: number): void {
|
||||||
|
const client = this.client;
|
||||||
|
if (!client) return;
|
||||||
|
// Joining a channel is where you want to talk: voice moves to this server
|
||||||
|
sessions.setVoice(this);
|
||||||
|
if (this.self?.channelId === id) {
|
||||||
|
this.selectView(`ch:${id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.pendingJoin = id;
|
||||||
|
client.joinChannel(id);
|
||||||
|
const moved = () => this.client !== client || this.pendingJoin !== id || client.self?.channelId === id;
|
||||||
|
setTimeout(() => {
|
||||||
|
if (moved()) return;
|
||||||
|
client.joinChannel(id);
|
||||||
|
setTimeout(() => {
|
||||||
|
if (moved()) return;
|
||||||
|
this.pendingJoin = null;
|
||||||
|
this.system(this.view, `The server did not move you to ${escapeHtml(client.channels.get(id)?.name || 'Root')}. Try again in a moment.`);
|
||||||
|
}, 1500);
|
||||||
|
}, 1200);
|
||||||
|
}
|
||||||
|
|
||||||
|
private target(key: string): { channels?: number[]; users?: number[] } {
|
||||||
|
const [kind, id] = key.split(':');
|
||||||
|
return kind === 'ch' ? { channels: [Number(id)] } : { users: [Number(id)] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send HTML to a view and echo it locally (the server does not echo our messages).
|
||||||
|
sendHtml(html: string, key: string = this.view): boolean {
|
||||||
|
const client = this.client;
|
||||||
|
if (!client || this.status !== 'connected') return false;
|
||||||
|
const limit = /<img/i.test(html) ? client.config.imageMessageLength : client.config.messageLength;
|
||||||
|
if (limit && html.length > limit) {
|
||||||
|
this.system(key, `Message too long for this server (${html.length} / ${limit} characters)`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
client.sendText(this.target(key), html);
|
||||||
|
const rendered = renderIncoming(html, store.trustedHosts);
|
||||||
|
this.push(key, { kind: 'text', author: client.self?.name ?? '', authorSession: client.session, self: true, ...rendered });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendText(text: string, key: string = this.view): boolean {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
return trimmed ? this.sendHtml(textToHtml(trimmed), key) : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadFiles(files: File[], key: string = this.view): Promise<void> {
|
||||||
|
const { uploadHost, uploadKey, uploadExpiry, sendThumbnails } = store.settings;
|
||||||
|
if (!uploadHost || !uploadKey) return this.sendInlineImages(files, key);
|
||||||
|
let info: UploadInfo;
|
||||||
|
try {
|
||||||
|
info = await this.loadUploadInfo();
|
||||||
|
} catch (e) {
|
||||||
|
this.system(key, `File sharing unavailable: ${escapeHtml((e as Error).message)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const f of files) {
|
||||||
|
const problem = checkFile(info, f);
|
||||||
|
if (problem) this.system(key, escapeHtml(problem));
|
||||||
|
}
|
||||||
|
for (const file of files.filter(f => !checkFile(info, f))) {
|
||||||
|
const job = { id: nextId++, key, name: file.name, progress: 0, error: '' };
|
||||||
|
this.uploads.push(job);
|
||||||
|
const live = () => this.uploads.find(j => j.id === job.id)!;
|
||||||
|
try {
|
||||||
|
const res = await uploadFile(uploadHost, uploadKey, file, uploadExpiry, p => { live().progress = p; });
|
||||||
|
// Well-formed XHTML (<br/>, <img/>): Murmur rejects long messages that do not parse as XML
|
||||||
|
const link = `<a href="${escapeHtml(res.url)}">${escapeHtml(res.name)}</a> (${formatBytes(res.size)})`;
|
||||||
|
let html = link;
|
||||||
|
const cfg = this.client?.config;
|
||||||
|
if (sendThumbnails && cfg?.allowHtml !== false) {
|
||||||
|
const budget = Math.min(cfg?.imageMessageLength || 131072, 100000) - link.length - 64;
|
||||||
|
const thumb = await makeThumbnail(file, budget);
|
||||||
|
if (thumb) html += `<br/><img src="${thumb}"/>`;
|
||||||
|
}
|
||||||
|
if (html !== link) this.lastImageSend = { key, fallback: link, at: Date.now() };
|
||||||
|
this.sendHtml(html, key);
|
||||||
|
this.uploads = this.uploads.filter(j => j.id !== job.id);
|
||||||
|
} catch (e) {
|
||||||
|
live().error = (e as Error).message;
|
||||||
|
setTimeout(() => { this.uploads = this.uploads.filter(j => j.id !== job.id); }, 6000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without file hosting: images go inline like a pasted picture in the desktop client,
|
||||||
|
// scaled to the server's image limit. Other files need an upload host.
|
||||||
|
private async sendInlineImages(files: File[], key: string): Promise<void> {
|
||||||
|
const cfg = this.client?.config;
|
||||||
|
for (const file of files) {
|
||||||
|
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.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (cfg?.allowHtml === false) {
|
||||||
|
this.system(key, 'This server does not allow images in messages.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 0 means no limit; stay reasonable anyway
|
||||||
|
const limit = cfg?.imageMessageLength || 1024 * 1024;
|
||||||
|
const uri = await makeInlineImage(file, limit - 64);
|
||||||
|
if (!uri) {
|
||||||
|
this.system(key, `${escapeHtml(file.name)}: could not make this image small enough for the server (limit ${formatBytes(limit)})`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
this.sendHtml(`<img src="${uri}"/>`, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cached per host+key; refreshed when settings change
|
||||||
|
async loadUploadInfo(): Promise<UploadInfo> {
|
||||||
|
const { uploadHost, uploadKey } = store.settings;
|
||||||
|
const id = `${uploadHost}|${uploadKey}`;
|
||||||
|
if (this.uploadInfo && this.uploadInfoFor === id) return this.uploadInfo;
|
||||||
|
const info = await fetchUploadInfo(uploadHost, uploadKey);
|
||||||
|
this.uploadInfo = info;
|
||||||
|
this.uploadInfoFor = id;
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Voice state and moderation ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
setSelfMute(v: boolean) { this.client?.setSelfMute(v); }
|
||||||
|
setSelfDeaf(v: boolean) { this.client?.setSelfDeaf(v); }
|
||||||
|
|
||||||
|
kick(session: number, reason: string, ban = false) { this.client?.kick(session, reason, ban); }
|
||||||
|
setServerMute(session: number, v: boolean) { this.client?.setUserState(session, { mute: v }); }
|
||||||
|
setServerDeaf(session: number, v: boolean) { this.client?.setUserState(session, { deaf: v }); }
|
||||||
|
setPrioritySpeaker(session: number, v: boolean) { this.client?.setUserState(session, { priority_speaker: v }); }
|
||||||
|
moveUser(session: number, channelId: number) { this.client?.setUserState(session, { channel_id: channelId }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Several servers at once ───────────────────────────────────────────────────
|
||||||
|
// Every server has its own Session. Text works on all of them; voice runs on one (like
|
||||||
|
// Discord): the server where you last joined a channel. On the others you are shown as
|
||||||
|
// deafened, so nobody expects you to hear them, and your state is restored when voice returns.
|
||||||
|
|
||||||
|
class Sessions {
|
||||||
|
list = $state<Session[]>([]);
|
||||||
|
activeId = $state<string | null>(null);
|
||||||
|
voiceId = $state<string | null>(null);
|
||||||
|
private idle = new Session();
|
||||||
|
// Self mute/deaf before we deafened a background server, to restore later
|
||||||
|
private saved = new Map<Session, { mute: boolean; deaf: boolean }>();
|
||||||
|
|
||||||
|
get active(): Session {
|
||||||
|
return this.list.find(s => s.server?.id === this.activeId) ?? this.idle;
|
||||||
|
}
|
||||||
|
|
||||||
|
byServer(id: string): Session | undefined {
|
||||||
|
return this.list.find(s => s.server?.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show a server, connecting if needed
|
||||||
|
open(server: SavedServer): void {
|
||||||
|
let s = this.byServer(server.id);
|
||||||
|
this.activeId = server.id;
|
||||||
|
s?.markVisibleRead();
|
||||||
|
if (!s) {
|
||||||
|
s = new Session();
|
||||||
|
this.list.push(s);
|
||||||
|
s = this.list[this.list.length - 1];
|
||||||
|
}
|
||||||
|
if (s.status === 'idle') s.connect(server);
|
||||||
|
}
|
||||||
|
|
||||||
|
select(id: string): void {
|
||||||
|
this.activeId = id;
|
||||||
|
this.active.markVisibleRead();
|
||||||
|
}
|
||||||
|
|
||||||
|
close(id: string): void {
|
||||||
|
const s = this.byServer(id);
|
||||||
|
if (!s) return;
|
||||||
|
s.disconnect();
|
||||||
|
this.list = this.list.filter(x => x !== s);
|
||||||
|
if (this.activeId === id) this.activeId = this.list.find(x => x.status !== 'idle')?.server?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
get voiceSession(): Session | undefined {
|
||||||
|
return this.list.find(s => s.server?.id === this.voiceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSynced(s: Session): void {
|
||||||
|
if (!this.voiceId || !this.voiceSession?.client) this.setVoice(s, true);
|
||||||
|
else this.toBackground(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
onClosed(s: Session): void {
|
||||||
|
this.saved.delete(s);
|
||||||
|
if (this.voiceId !== s.server?.id) return;
|
||||||
|
voice.detach();
|
||||||
|
// Hand voice to another connected server, if any
|
||||||
|
const next = this.list.find(x => x !== s && x.client && x.status === 'connected');
|
||||||
|
this.voiceId = null;
|
||||||
|
if (next) this.setVoice(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
setVoice(s: Session, first = false): void {
|
||||||
|
const id = s.server?.id ?? null;
|
||||||
|
if (!id || (this.voiceId === id && !first)) return;
|
||||||
|
const prev = this.voiceSession;
|
||||||
|
if (prev && prev !== s) {
|
||||||
|
voice.detach();
|
||||||
|
this.toBackground(prev);
|
||||||
|
}
|
||||||
|
this.voiceId = id;
|
||||||
|
this.fromBackground(s);
|
||||||
|
if (s.client) voice.attach(s.client);
|
||||||
|
}
|
||||||
|
|
||||||
|
private toBackground(s: Session): void {
|
||||||
|
const self = s.client?.self;
|
||||||
|
if (!s.client || !self || this.saved.has(s)) return;
|
||||||
|
this.saved.set(s, { mute: self.selfMute, deaf: self.selfDeaf });
|
||||||
|
if (!self.selfDeaf) s.client.setSelfDeaf(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private fromBackground(s: Session): void {
|
||||||
|
const prev = this.saved.get(s);
|
||||||
|
this.saved.delete(s);
|
||||||
|
if (!prev || !s.client || prev.deaf) return;
|
||||||
|
s.client.send('UserState', { self_deaf: false, self_mute: prev.mute });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sessions = new Sessions();
|
||||||
|
|
||||||
|
// The session of the server on screen. Forwards to the active Session so components keep
|
||||||
|
// using `session.x`; methods are bound to the real instance so async work stays with its server.
|
||||||
|
export const session = new Proxy({} as Session, {
|
||||||
|
get(_t, prop) {
|
||||||
|
const a = sessions.active;
|
||||||
|
const v = Reflect.get(a, prop, a);
|
||||||
|
return typeof v === 'function' ? v.bind(a) : v;
|
||||||
|
},
|
||||||
|
set(_t, prop, value) {
|
||||||
|
return Reflect.set(sessions.active, prop, value, sessions.active);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { load, loadList, save } from './storage.ts';
|
||||||
|
|
||||||
|
export interface SavedServer {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
identityId?: string; // certificate to connect with; the default identity when unset
|
||||||
|
icon?: string; // custom rail icon, a small square data: image
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Settings {
|
||||||
|
uploadHost: string; // f0ckm base URL, e.g. https://f0ck.dev
|
||||||
|
uploadKey: string; // upload-only key from scripts/chat-upload-key.mjs
|
||||||
|
uploadExpiry: string; // 1h / 24h / 7d / 30d
|
||||||
|
embedHosts: string[]; // extra hosts whose media links render inline
|
||||||
|
sendThumbnails: boolean; // include a small inline image for vanilla Mumble clients
|
||||||
|
compact: boolean;
|
||||||
|
youtube: 'off' | 'click' | 'thumbnail'; // YouTube links: plain, click-to-play card, card with thumbnail
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaults: Settings = {
|
||||||
|
uploadHost: '',
|
||||||
|
uploadKey: '',
|
||||||
|
uploadExpiry: '30d',
|
||||||
|
embedHosts: [],
|
||||||
|
sendThumbnails: true,
|
||||||
|
compact: false,
|
||||||
|
youtube: 'click'
|
||||||
|
};
|
||||||
|
|
||||||
|
class SettingsStore {
|
||||||
|
settings = $state<Settings>(load('mumh5.settings', defaults));
|
||||||
|
servers = $state<SavedServer[]>(loadList('mumh5.servers'));
|
||||||
|
// Pinned sha256 fingerprints of server certificates, keyed by host:port (trust on first use)
|
||||||
|
pins = $state<Record<string, string>>(load('mumh5.pins', {}));
|
||||||
|
// Local nicknames for other users, only visible to us; keyed by certificate hash (or name)
|
||||||
|
nicknames = $state<Record<string, string>>(load('mumh5.nicknames', {}));
|
||||||
|
|
||||||
|
saveSettings() { save('mumh5.settings', $state.snapshot(this.settings)); }
|
||||||
|
|
||||||
|
upsertServer(s: SavedServer) {
|
||||||
|
const i = this.servers.findIndex(x => x.id === s.id);
|
||||||
|
if (i === -1) this.servers.push(s);
|
||||||
|
else this.servers[i] = s;
|
||||||
|
save('mumh5.servers', $state.snapshot(this.servers));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reorder the server list (drag and drop in the rail)
|
||||||
|
moveServer(id: string, toIndex: number) {
|
||||||
|
const from = this.servers.findIndex(s => s.id === id);
|
||||||
|
if (from < 0) return;
|
||||||
|
const list = [...this.servers];
|
||||||
|
const [item] = list.splice(from, 1);
|
||||||
|
list.splice(Math.max(0, Math.min(list.length, toIndex)), 0, item);
|
||||||
|
this.servers = list;
|
||||||
|
save('mumh5.servers', $state.snapshot(this.servers));
|
||||||
|
}
|
||||||
|
|
||||||
|
setServerIcon(id: string, icon: string | undefined) {
|
||||||
|
const s = this.servers.find(x => x.id === id);
|
||||||
|
if (!s) return;
|
||||||
|
if (icon) s.icon = icon;
|
||||||
|
else delete s.icon;
|
||||||
|
save('mumh5.servers', $state.snapshot(this.servers));
|
||||||
|
}
|
||||||
|
|
||||||
|
removeServer(id: string) {
|
||||||
|
this.servers = this.servers.filter(s => s.id !== id);
|
||||||
|
save('mumh5.servers', $state.snapshot(this.servers));
|
||||||
|
}
|
||||||
|
|
||||||
|
setNickname(key: string, nick: string) {
|
||||||
|
if (nick.trim()) this.nicknames[key] = nick.trim();
|
||||||
|
else delete this.nicknames[key];
|
||||||
|
save('mumh5.nicknames', $state.snapshot(this.nicknames));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name to show for a user: our local nickname, else their name
|
||||||
|
displayName(u: { hash: string; name: string }): string {
|
||||||
|
return this.nicknames[u.hash || `name:${u.name}`] ?? u.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
pin(hostPort: string, fingerprint: string) {
|
||||||
|
this.pins[hostPort] = fingerprint;
|
||||||
|
save('mumh5.pins', $state.snapshot(this.pins));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hosts whose media links are embedded: the upload host plus user-added ones.
|
||||||
|
// Other links stay plain links, so opening a chat never pings arbitrary servers.
|
||||||
|
get trustedHosts(): string[] {
|
||||||
|
const hosts = [...this.settings.embedHosts];
|
||||||
|
try { if (this.settings.uploadHost) hosts.push(new URL(this.settings.uploadHost).host); } catch { /* invalid URL */ }
|
||||||
|
return hosts.map(h => h.trim().toLowerCase()).filter(Boolean);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const store = new SettingsStore();
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// localStorage wrapper that tolerates missing or broken storage.
|
||||||
|
export function load<T>(key: string, fallback: T): T {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(key);
|
||||||
|
return raw ? { ...fallback, ...JSON.parse(raw) } : fallback;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadList<T>(key: string): T[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(key);
|
||||||
|
const v = raw ? JSON.parse(raw) : [];
|
||||||
|
return Array.isArray(v) ? v : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function save(key: string, value: unknown): void {
|
||||||
|
try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* storage unavailable */ }
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { native } from './native.ts';
|
||||||
|
import { ICON_PATHS } from './icons.ts';
|
||||||
|
import { session, sessions } from './session.svelte.ts';
|
||||||
|
import { voice, type TransmitMode } from './audio/voice.svelte.ts';
|
||||||
|
import { ui } from './ui.svelte.ts';
|
||||||
|
|
||||||
|
type TrayLook = 'offline' | 'idle' | 'talking' | 'muted' | 'deafened';
|
||||||
|
|
||||||
|
const LOOK: Record<TrayLook, { bg: string; glyph: string }> = {
|
||||||
|
offline: { bg: '#4a4c54', glyph: 'mic' },
|
||||||
|
idle: { bg: '#7c6cff', glyph: 'mic' },
|
||||||
|
talking: { bg: '#2fb863', glyph: 'mic' },
|
||||||
|
muted: { bg: '#e0443e', glyph: 'mic-off' },
|
||||||
|
deafened: { bg: '#e0443e', glyph: 'headphones-off' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const cache = new Map<TrayLook, string>();
|
||||||
|
|
||||||
|
// Square tile with a white glyph, like the rail tiles; 64 px so the OS can scale it down sharply
|
||||||
|
function drawIcon(look: TrayLook): string {
|
||||||
|
const hit = cache.get(look);
|
||||||
|
if (hit) return hit;
|
||||||
|
const size = 64;
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = canvas.height = size;
|
||||||
|
const ctx = canvas.getContext('2d')!;
|
||||||
|
ctx.fillStyle = LOOK[look].bg;
|
||||||
|
ctx.fillRect(0, 0, size, size);
|
||||||
|
ctx.translate(8, 8);
|
||||||
|
ctx.scale(2, 2); // 24 unit glyph into 48 px
|
||||||
|
ctx.strokeStyle = '#ffffff';
|
||||||
|
ctx.lineWidth = 2.6;
|
||||||
|
ctx.lineCap = 'square';
|
||||||
|
ctx.lineJoin = 'miter';
|
||||||
|
ctx.stroke(new Path2D(ICON_PATHS[LOOK[look].glyph]));
|
||||||
|
const url = canvas.toDataURL('image/png');
|
||||||
|
cache.set(look, url);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors voice state into the system tray and handles its menu
|
||||||
|
export function startTray(): void {
|
||||||
|
if (!native) return;
|
||||||
|
const api = native;
|
||||||
|
|
||||||
|
$effect.root(() => {
|
||||||
|
$effect(() => {
|
||||||
|
const self = session.self;
|
||||||
|
const voiceSession = sessions.voiceSession;
|
||||||
|
const connected = session.status === 'connected';
|
||||||
|
const vSelf = voiceSession?.self;
|
||||||
|
const muted = !!(vSelf ? vSelf.selfMute || vSelf.mute : self?.selfMute);
|
||||||
|
const deafened = !!(vSelf ? vSelf.selfDeaf || vSelf.deaf : self?.selfDeaf);
|
||||||
|
const look: TrayLook = !connected && !voiceSession ? 'offline'
|
||||||
|
: deafened ? 'deafened' : muted ? 'muted' : voice.transmitting ? 'talking' : 'idle';
|
||||||
|
const server = voiceSession?.server?.label ?? session.server?.label ?? '';
|
||||||
|
const state = { offline: 'not connected', idle: 'connected', talking: 'talking', muted: 'muted', deafened: 'deafened' }[look];
|
||||||
|
api.updateTray({
|
||||||
|
icon: drawIcon(look),
|
||||||
|
tooltip: server ? `mumh5: ${state} on ${server}` : 'mumh5: not connected',
|
||||||
|
connected: !!(voiceSession?.status === 'connected' || connected),
|
||||||
|
server,
|
||||||
|
muted,
|
||||||
|
deafened,
|
||||||
|
mode: voice.settings.mode
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
api.onTrayAction(action => {
|
||||||
|
// Tray actions apply where voice runs
|
||||||
|
const target = sessions.voiceSession ?? sessions.active;
|
||||||
|
const self = target.self;
|
||||||
|
if (action === 'toggle-mute' && self) target.setSelfMute(!self.selfMute);
|
||||||
|
else if (action === 'toggle-deafen' && self) target.setSelfDeaf(!self.selfDeaf);
|
||||||
|
else if (action.startsWith('mode:')) voice.update({ mode: action.slice(5) as TransmitMode });
|
||||||
|
else if (action === 'settings') { ui.settingsTab = 'voice'; ui.settingsOpen = true; }
|
||||||
|
else if (action === 'disconnect' && target.server) sessions.close(target.server.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { CertDetails } from '../core/transport.ts';
|
||||||
|
|
||||||
|
export interface PromptRequest {
|
||||||
|
title: string;
|
||||||
|
label: string;
|
||||||
|
confirm: string;
|
||||||
|
danger?: boolean;
|
||||||
|
input?: 'text' | 'password' | 'none';
|
||||||
|
initial?: string;
|
||||||
|
text?: string;
|
||||||
|
onsubmit: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CertRequest {
|
||||||
|
title: string;
|
||||||
|
chain: CertDetails[];
|
||||||
|
status?: { ok: boolean; text: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
// App-wide dialogs that can be opened from anywhere (menus, panels)
|
||||||
|
class Ui {
|
||||||
|
prompt = $state<PromptRequest | null>(null);
|
||||||
|
cert = $state<CertRequest | null>(null);
|
||||||
|
settingsOpen = $state(false);
|
||||||
|
wizard = $state<'setup' | 'add' | null>(null);
|
||||||
|
// Quick audio popover from right-clicking mute (input) or deafen (output)
|
||||||
|
quickAudio = $state<{ kind: 'input' | 'output'; x: number; y: number } | null>(null);
|
||||||
|
settingsTab = $state<'voice' | 'files' | 'identities'>('voice');
|
||||||
|
// Session whose connection information is shown
|
||||||
|
userInfo = $state<number | null>(null);
|
||||||
|
// Set when a panel was opened on a layout where it is an overlay
|
||||||
|
panelOpen = $state(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ui = new Ui();
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
export interface UploadResult {
|
||||||
|
url: string;
|
||||||
|
name: string;
|
||||||
|
mime: string;
|
||||||
|
inline: boolean;
|
||||||
|
size: number;
|
||||||
|
expires_at: number;
|
||||||
|
delete_token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadInfo {
|
||||||
|
allowed_mimes: string[];
|
||||||
|
allowed_extensions: string[];
|
||||||
|
max_bytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint = (host: string) => host.replace(/\/+$/, '') + '/api/chat/upload';
|
||||||
|
|
||||||
|
// Allowed types and limits of the upload host; also serves as a key check.
|
||||||
|
export async function fetchUploadInfo(host: string, key: string): Promise<UploadInfo> {
|
||||||
|
const r = await fetch(endpoint(host), { headers: { 'X-API-Key': key } });
|
||||||
|
const body = await r.json().catch(() => null);
|
||||||
|
if (r.status === 401) throw new Error('Key rejected');
|
||||||
|
if (!r.ok || !body?.success) throw new Error(body?.msg || `Unexpected response (HTTP ${r.status})`);
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client-side pre-check; the host verifies the real type again after upload
|
||||||
|
export function checkFile(info: UploadInfo, file: File): string | null {
|
||||||
|
const ext = /\.([a-z0-9]+)$/i.exec(file.name)?.[1]?.toLowerCase() ?? '';
|
||||||
|
const typeOk = file.type ? info.allowed_mimes.includes(file.type) : info.allowed_extensions.includes(ext);
|
||||||
|
if (!typeOk) return `${file.name}: file type not allowed on this upload host`;
|
||||||
|
if (file.size > info.max_bytes) return `${file.name}: too large (max ${Math.round(info.max_bytes / 1048576)} MB)`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value for <input type=file accept>
|
||||||
|
export function acceptAttr(info: UploadInfo | null): string {
|
||||||
|
if (!info) return '';
|
||||||
|
return [...info.allowed_mimes, ...info.allowed_extensions.map(e => '.' + e)].join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload one file to a f0ckm chat-upload endpoint. XHR instead of fetch for upload progress.
|
||||||
|
export function uploadFile(
|
||||||
|
host: string, key: string, file: File, expiry: string, onProgress: (fraction: number) => void
|
||||||
|
): Promise<UploadResult> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('POST', endpoint(host));
|
||||||
|
xhr.setRequestHeader('X-API-Key', key);
|
||||||
|
xhr.setRequestHeader('X-Filename', encodeURIComponent(file.name));
|
||||||
|
xhr.setRequestHeader('X-Upload-Expiry', expiry);
|
||||||
|
if (file.type) xhr.setRequestHeader('Content-Type', file.type);
|
||||||
|
xhr.upload.onprogress = e => { if (e.lengthComputable) onProgress(e.loaded / e.total); };
|
||||||
|
xhr.onerror = () => reject(new Error('Network error'));
|
||||||
|
xhr.onload = () => {
|
||||||
|
let body: any = null;
|
||||||
|
try { body = JSON.parse(xhr.responseText); } catch { /* not JSON */ }
|
||||||
|
if (xhr.status === 200 && body?.success) resolve(body);
|
||||||
|
else reject(new Error(body?.msg || `Upload failed (HTTP ${xhr.status})`));
|
||||||
|
};
|
||||||
|
xhr.send(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const readDataUrl = (file: Blob) => new Promise<string>((resolve, reject) => {
|
||||||
|
const r = new FileReader();
|
||||||
|
r.onload = () => resolve(String(r.result));
|
||||||
|
r.onerror = () => reject(r.error);
|
||||||
|
r.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
// JPEG data URI of an image, scaled down until it fits in maxChars. Mumble clients render
|
||||||
|
// inline data: images, which is also how the desktop client sends pasted pictures.
|
||||||
|
export async function makeInlineImage(file: File, maxChars: number, sizes = [1280, 1024, 800, 640, 480, 320, 200, 120]): Promise<string | null> {
|
||||||
|
if (!/^image\/(png|jpeg|gif|webp|avif|bmp)$/.test(file.type) || maxChars < 2000) return null;
|
||||||
|
// Small enough as it is: send the original (keeps GIF animation and PNG transparency)
|
||||||
|
if (/^image\/(png|jpeg|gif|webp)$/.test(file.type) && Math.ceil(file.size / 3) * 4 + 40 < maxChars) {
|
||||||
|
return readDataUrl(file);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const bmp = await createImageBitmap(file);
|
||||||
|
for (const size of sizes) {
|
||||||
|
const scale = Math.min(1, size / Math.max(bmp.width, bmp.height));
|
||||||
|
if (scale === 1 && size !== sizes[0] && Math.max(bmp.width, bmp.height) < size) continue;
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = Math.max(1, Math.round(bmp.width * scale));
|
||||||
|
canvas.height = Math.max(1, Math.round(bmp.height * scale));
|
||||||
|
canvas.getContext('2d')!.drawImage(bmp, 0, 0, canvas.width, canvas.height);
|
||||||
|
for (const q of [0.85, 0.7, 0.5]) {
|
||||||
|
const uri = canvas.toDataURL('image/jpeg', q);
|
||||||
|
if (uri.length < maxChars) return uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* undecodable image */ }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small preview next to an f0ckm link, for vanilla Mumble clients
|
||||||
|
export function makeThumbnail(file: File, maxChars: number): Promise<string | null> {
|
||||||
|
return makeInlineImage(file, maxChars, [320, 200, 120]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Square server icon: center-cropped, scaled to 128x128, as a small WebP data URI
|
||||||
|
export async function makeServerIcon(file: File, size = 128): Promise<string> {
|
||||||
|
const bmp = await createImageBitmap(file);
|
||||||
|
const side = Math.min(bmp.width, bmp.height);
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = canvas.height = size;
|
||||||
|
const ctx = canvas.getContext('2d')!;
|
||||||
|
ctx.imageSmoothingQuality = 'high';
|
||||||
|
ctx.drawImage(bmp, (bmp.width - side) / 2, (bmp.height - side) / 2, side, side, 0, 0, size, size);
|
||||||
|
bmp.close();
|
||||||
|
const webp = canvas.toDataURL('image/webp', 0.85);
|
||||||
|
// Browsers without WebP encoding fall back to PNG
|
||||||
|
return webp.startsWith('data:image/webp') ? webp : canvas.toDataURL('image/png');
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { mount } from 'svelte';
|
||||||
|
import './app.css';
|
||||||
|
import App from './App.svelte';
|
||||||
|
import { startTray } from './lib/tray.svelte.ts';
|
||||||
|
|
||||||
|
mount(App, { target: document.getElementById('app')! });
|
||||||
|
startTray();
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { name, size = 32, speaking = false }: { name: string; size?: number; speaking?: boolean } = $props();
|
||||||
|
|
||||||
|
const hue = $derived([...name].reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 7) % 360);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span class="avatar" class:speaking style:width="{size}px" style:height="{size}px"
|
||||||
|
style:font-size="{Math.round(size * 0.45)}px" style:background="hsl({hue} 45% 38%)">
|
||||||
|
{(name.trim()[0] ?? '?').toUpperCase()}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.avatar {
|
||||||
|
flex: none; display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
font-weight: 700; color: #fff; user-select: none; box-shadow: 0 0 0 2px transparent;
|
||||||
|
transition: box-shadow 80ms;
|
||||||
|
}
|
||||||
|
.speaking { box-shadow: 0 0 0 2px var(--speaking); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Modal from './Modal.svelte';
|
||||||
|
import { session } from '../lib/session.svelte.ts';
|
||||||
|
|
||||||
|
const p = $derived(session.certPrompt!);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal title="Server certificate changed" onclose={() => (session.certPrompt = null)} width={520}>
|
||||||
|
<p>The certificate of <strong>{p.hostPort}</strong> is different from the one it had before. This happens when a server is reinstalled, but it can also mean someone is intercepting the connection. Your password has not been sent.</p>
|
||||||
|
<p class="k">Previous</p><code>{p.previous}</code>
|
||||||
|
<p class="k">Now</p><code>{p.fingerprint}</code>
|
||||||
|
{#snippet footer()}
|
||||||
|
<button class="btn" onclick={() => (session.certPrompt = null)}>Cancel</button>
|
||||||
|
<button class="btn danger" onclick={() => session.acceptCertificate()}>Trust new certificate</button>
|
||||||
|
{/snippet}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
p { color: var(--text-dim); }
|
||||||
|
.k { margin: 12px 0 4px; font-size: 12px; font-weight: 600; text-transform: uppercase; }
|
||||||
|
code { display: block; padding: 8px; background: var(--bg-0); font: 12px var(--mono); overflow-wrap: anywhere; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Modal from './Modal.svelte';
|
||||||
|
import type { CertDetails } from '../core/transport.ts';
|
||||||
|
|
||||||
|
let { title, chain, status, onclose }:
|
||||||
|
{ title: string; chain: CertDetails[]; status?: { ok: boolean; text: string }; onclose: () => void } = $props();
|
||||||
|
|
||||||
|
let index = $state(0);
|
||||||
|
const cert = $derived(chain[index]);
|
||||||
|
|
||||||
|
// "CN=foo\nO=bar" -> [["CN","foo"],["O","bar"]]
|
||||||
|
const parts = (dn: string) => dn.split('\n').filter(Boolean).map(l => {
|
||||||
|
const i = l.indexOf('=');
|
||||||
|
return [l.slice(0, i), l.slice(i + 1)];
|
||||||
|
});
|
||||||
|
const names: Record<string, string> = { CN: 'Common name', O: 'Organization', OU: 'Unit', C: 'Country', ST: 'State', L: 'Locality', emailAddress: 'Email' };
|
||||||
|
const date = (iso: string) => new Date(iso).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' });
|
||||||
|
const expired = $derived(cert ? new Date(cert.validTo) < new Date() : false);
|
||||||
|
|
||||||
|
function copyPem() { navigator.clipboard.writeText(cert.pem); }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal {title} {onclose} width={620}>
|
||||||
|
{#if status}<p class="status" class:ok={status.ok}>{status.text}</p>{/if}
|
||||||
|
{#if chain.length > 1}
|
||||||
|
<div class="tabs" role="tablist">
|
||||||
|
{#each chain as c, i (c.fingerprint256)}
|
||||||
|
<button role="tab" aria-selected={i === index} class:active={i === index} onclick={() => (index = i)}>
|
||||||
|
{parts(c.subject).find(p => p[0] === 'CN')?.[1] ?? `Certificate ${i + 1}`}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if cert}
|
||||||
|
<h3>Subject</h3>
|
||||||
|
<dl>{#each parts(cert.subject) as [k, v] (k + v)}<dt>{names[k] ?? k}</dt><dd>{v}</dd>{/each}</dl>
|
||||||
|
<h3>Issuer {#if cert.selfSigned}<span class="tag">self-signed</span>{/if}</h3>
|
||||||
|
<dl>{#each parts(cert.issuer) as [k, v] (k + v)}<dt>{names[k] ?? k}</dt><dd>{v}</dd>{/each}</dl>
|
||||||
|
<h3>Validity {#if expired}<span class="tag bad">expired</span>{/if}</h3>
|
||||||
|
<dl>
|
||||||
|
<dt>Not before</dt><dd>{date(cert.validFrom)}</dd>
|
||||||
|
<dt>Not after</dt><dd>{date(cert.validTo)}</dd>
|
||||||
|
</dl>
|
||||||
|
<h3>Details</h3>
|
||||||
|
<dl>
|
||||||
|
<dt>Public key</dt><dd>{cert.keyType.toUpperCase()}{cert.keyBits ? ` ${cert.keyBits} bit` : ''}</dd>
|
||||||
|
{#if cert.subjectAltName}<dt>Alt names</dt><dd>{cert.subjectAltName}</dd>{/if}
|
||||||
|
<dt>Serial</dt><dd class="mono">{cert.serialNumber}</dd>
|
||||||
|
<dt>SHA-1</dt><dd class="mono">{cert.fingerprint}</dd>
|
||||||
|
<dt>SHA-256</dt><dd class="mono">{cert.fingerprint256}</dd>
|
||||||
|
</dl>
|
||||||
|
{/if}
|
||||||
|
{#snippet footer()}
|
||||||
|
<button class="btn" onclick={copyPem}>Copy PEM</button>
|
||||||
|
<button class="btn primary" onclick={onclose}>Close</button>
|
||||||
|
{/snippet}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.status { margin: 8px 0 0; padding: 8px 10px; border-left: 3px solid var(--warn); background: var(--bg-0); font-size: 14px; }
|
||||||
|
.status.ok { border-color: var(--speaking); }
|
||||||
|
.tabs { display: flex; gap: 2px; margin-top: 12px; overflow-x: auto; }
|
||||||
|
.tabs button { padding: 6px 12px; background: var(--bg-3); color: var(--text-dim); white-space: nowrap; font-size: 13px; }
|
||||||
|
.tabs button.active { background: var(--accent); color: var(--accent-text); }
|
||||||
|
h3 { margin: 18px 0 6px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-dim); display: flex; align-items: center; gap: 8px; }
|
||||||
|
.tag { text-transform: none; letter-spacing: 0; font-weight: 600; padding: 0 6px; border: 1px solid var(--warn); color: var(--warn); }
|
||||||
|
.tag.bad { border-color: var(--danger); color: var(--danger); }
|
||||||
|
dl { display: grid; grid-template-columns: 120px 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>
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from './Icon.svelte';
|
||||||
|
import Avatar from './Avatar.svelte';
|
||||||
|
import ChannelNode from './ChannelNode.svelte';
|
||||||
|
import { session } from '../lib/session.svelte.ts';
|
||||||
|
import { menu } 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 { store } from '../lib/settings.svelte.ts';
|
||||||
|
import type { Channel } from '../core/client.ts';
|
||||||
|
|
||||||
|
let { channel, depth = 0, onnavigate }: { channel: Channel; depth?: number; onnavigate: () => void } = $props();
|
||||||
|
|
||||||
|
const users = $derived(session.usersIn(channel.id));
|
||||||
|
const children = $derived(session.childrenOf(channel.id));
|
||||||
|
const expanded = $derived(session.isExpanded(channel.id));
|
||||||
|
const here = $derived(session.self?.channelId === channel.id);
|
||||||
|
const selected = $derived(session.panel.kind === 'channel' && session.panel.id === channel.id);
|
||||||
|
const unread = $derived(session.unread[`ch:${channel.id}`] ?? 0);
|
||||||
|
const total = $derived(session.subtreeUserCount(channel.id));
|
||||||
|
|
||||||
|
// Single click previews, double click acts. The single action waits briefly so a
|
||||||
|
// double click does not also flash the preview.
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
function clickOrDouble(single: () => void, double: () => void) {
|
||||||
|
return {
|
||||||
|
onclick: () => { clearTimeout(timer); timer = setTimeout(single, 220); },
|
||||||
|
ondblclick: () => { clearTimeout(timer); double(); }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const channelClicks = clickOrDouble(
|
||||||
|
() => { session.showChannel(channel.id); ui.panelOpen = true; },
|
||||||
|
() => { session.joinChannel(channel.id); onnavigate(); }
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<div class="row channel" class:here class:selected style:padding-left="{8 + depth * 14}px"
|
||||||
|
oncontextmenu={e => menu.show(e, channelMenu(channel))} role="presentation">
|
||||||
|
{#if children.length || users.length}
|
||||||
|
<button class="caret" class:collapsed={!expanded} onclick={() => session.toggleExpanded(channel.id)}
|
||||||
|
aria-label={expanded ? 'Collapse' : 'Expand'} aria-expanded={expanded}>
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10"><path d="M2 3l3 4 3-4" fill="none" stroke="currentColor" stroke-width="1.6" /></svg>
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<span class="caret"></span>
|
||||||
|
{/if}
|
||||||
|
<button class="name" title="Click for details, double-click to join" {...channelClicks}>
|
||||||
|
<Icon name={channel.canEnter ? 'channel' : 'lock'} size={16} />
|
||||||
|
<span class="label">{channel.name || 'Root'}</span>
|
||||||
|
{#if !expanded && total > 0}<span class="count" title="{total} {total === 1 ? 'person' : 'people'} inside">{total}</span>{/if}
|
||||||
|
{#if unread}<span class="badge">{unread}</span>{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if users.length && expanded}
|
||||||
|
<ul class="users">
|
||||||
|
{#each users as u (u.session)}
|
||||||
|
{@const isSelf = u.session === session.self?.session}
|
||||||
|
{@const clicks = clickOrDouble(
|
||||||
|
() => { session.showUser(u.session); ui.panelOpen = true; },
|
||||||
|
() => { if (!isSelf) { session.selectView(`dm:${u.session}`); onnavigate(); } }
|
||||||
|
)}
|
||||||
|
<li>
|
||||||
|
<button class="row user" class:self={isSelf} style:padding-left="{30 + depth * 14}px"
|
||||||
|
title={isSelf ? 'You' : `${store.displayName(u)} (double-click to message)`} {...clicks}
|
||||||
|
oncontextmenu={e => menu.show(e, userMenu(u))}>
|
||||||
|
<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>
|
||||||
|
{#if u.userId != null}<span class="reg" title="Registered">R</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.recording}<span class="rec" title="Recording">REC</span>{/if}
|
||||||
|
{#if u.deaf || u.selfDeaf}
|
||||||
|
<span class="state" class:admin={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="state" class:admin={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>
|
||||||
|
{/if}
|
||||||
|
{#if children.length && expanded}
|
||||||
|
<ul>
|
||||||
|
{#each children as c (c.id)}
|
||||||
|
<ChannelNode channel={c} depth={depth + 1} {onnavigate} />
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
ul { list-style: none; margin: 0; padding: 0; }
|
||||||
|
li { margin: 0; }
|
||||||
|
.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.selected { background: var(--bg-active); color: var(--text); }
|
||||||
|
.channel.here .label { color: var(--text); font-weight: 600; }
|
||||||
|
.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.collapsed svg { transform: rotate(-90deg); }
|
||||||
|
.name { flex: 1; min-width: 0; display: flex; align-items: center; gap: 6px; padding: 4px 0; text-align: left; color: inherit; user-select: none; }
|
||||||
|
.label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.count { font-size: 11px; color: var(--text-faint); border: 1px solid var(--line); padding: 0 5px; line-height: 16px; }
|
||||||
|
.badge { background: var(--danger); color: #fff; font-size: 11px; font-weight: 700; padding: 0 5px; line-height: 16px; }
|
||||||
|
.user { gap: 8px; text-align: left; font-size: 14px; user-select: none; }
|
||||||
|
.user.self .label { color: var(--text); }
|
||||||
|
.label.talking { color: var(--speaking); }
|
||||||
|
.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.admin { color: var(--danger); }
|
||||||
|
.rec { font-size: 10px; font-weight: 700; color: var(--danger); border: 1px solid var(--danger); padding: 0 3px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from './Icon.svelte';
|
||||||
|
import ChatLog from './ChatLog.svelte';
|
||||||
|
import Composer from './Composer.svelte';
|
||||||
|
import { session } from '../lib/session.svelte.ts';
|
||||||
|
|
||||||
|
let { onmenu, onpanel, showMenu }: { onmenu: () => void; onpanel: () => void; showMenu: boolean } = $props();
|
||||||
|
|
||||||
|
const isDm = $derived(session.view.startsWith('dm:'));
|
||||||
|
const title = $derived(session.status === 'connected' ? session.viewTitle() : 'mumh5');
|
||||||
|
const topic = $derived.by(() => {
|
||||||
|
if (isDm || session.status !== 'connected') return '';
|
||||||
|
return (session.channel(Number(session.view.slice(3)))?.description ?? '').replace(/<[^>]*>/g, ' ').trim();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="chat" aria-label="Chat">
|
||||||
|
<header>
|
||||||
|
{#if showMenu}<button class="icon-btn" onclick={onmenu} aria-label="Open channels"><Icon name="menu" /></button>{/if}
|
||||||
|
<Icon name={isDm ? 'message' : 'hash'} size={20} />
|
||||||
|
<h1>{title}</h1>
|
||||||
|
{#if topic}<span class="topic" title={topic}>{topic}</span>{/if}
|
||||||
|
<span class="grow"></span>
|
||||||
|
<button class="icon-btn" onclick={onpanel} title="Members"><Icon name="users" /></button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<ChatLog key={session.view}>
|
||||||
|
{#snippet empty()}
|
||||||
|
<div class="empty">
|
||||||
|
{#if session.status === 'connected'}
|
||||||
|
<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>
|
||||||
|
{:else if session.status === 'connecting'}
|
||||||
|
<h2>Connecting...</h2>
|
||||||
|
{:else}
|
||||||
|
<h2>mumh5</h2>
|
||||||
|
<p>Choose a server to get started.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</ChatLog>
|
||||||
|
|
||||||
|
<Composer target={session.view} placeholder={session.status === 'connected' ? `Message ${isDm ? '@' : '#'}${title}` : 'Not connected'} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.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); }
|
||||||
|
h1 { flex: 0 1 auto; max-width: 60%; font-size: 16px; margin: 0; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.topic { flex: 1 1 0; font-size: 13px; padding-left: 10px; margin-left: 4px; border-left: 1px solid var(--line); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
|
||||||
|
.grow { flex: 1; }
|
||||||
|
.topic + .grow { flex: 0; }
|
||||||
|
/* On phones the channel name matters more than its description */
|
||||||
|
@media (max-width: 600px) { .topic { display: none; } h1 { max-width: none; } .topic + .grow { flex: 1; } }
|
||||||
|
.empty { padding: 32px 16px 8px; }
|
||||||
|
.empty h2 { margin: 0 0 4px; font-size: 24px; }
|
||||||
|
.empty p { margin: 0; color: var(--text-dim); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { tick, type Snippet } from 'svelte';
|
||||||
|
import Icon from './Icon.svelte';
|
||||||
|
import Message from './Message.svelte';
|
||||||
|
import { session } from '../lib/session.svelte.ts';
|
||||||
|
|
||||||
|
// Message list for one chat (channel or DM), with drag and drop upload into that chat
|
||||||
|
let { key, empty }: { key: string; empty: Snippet } = $props();
|
||||||
|
|
||||||
|
const entries = $derived(session.logs[key] ?? []);
|
||||||
|
const title = $derived(session.viewTitle(key));
|
||||||
|
|
||||||
|
let list: HTMLDivElement;
|
||||||
|
let pinned = true; // stick to the bottom unless the user scrolled up
|
||||||
|
let dragging = $state(0);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
void entries.length;
|
||||||
|
void key;
|
||||||
|
if (pinned) tick().then(() => list && (list.scrollTop = list.scrollHeight));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Media loads after insertion and grows the list; keep the bottom pinned
|
||||||
|
function onload() { if (pinned) list.scrollTop = list.scrollHeight; }
|
||||||
|
|
||||||
|
function ondrop(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
dragging = 0;
|
||||||
|
const files = [...(e.dataTransfer?.files ?? [])];
|
||||||
|
if (files.length && session.status === 'connected') session.uploadFiles(files, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
const grouped = (i: number) => {
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="wrap" role="log"
|
||||||
|
ondragenter={e => { if (e.dataTransfer?.types.includes('Files')) dragging++; }}
|
||||||
|
ondragleave={() => (dragging = Math.max(0, dragging - 1))}
|
||||||
|
ondragover={e => e.preventDefault()} {ondrop}>
|
||||||
|
<div class="list" bind:this={list} onscroll={() => (pinned = list.scrollHeight - list.scrollTop - list.clientHeight < 40)}
|
||||||
|
onloadcapture={onload} onloadedmetadatacapture={onload}>
|
||||||
|
{#if !entries.length}{@render empty()}{/if}
|
||||||
|
{#each entries as entry, i (entry.id)}
|
||||||
|
<Message {entry} grouped={grouped(i)} />
|
||||||
|
{/each}
|
||||||
|
<div class="end"></div>
|
||||||
|
</div>
|
||||||
|
{#if dragging}
|
||||||
|
<div class="drop"><div><Icon name="download" size={40} /><p>Drop to upload to {key.startsWith('dm:') ? '@' : '#'}{title}</p></div></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.wrap { position: relative; flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
||||||
|
.list { flex: 1; overflow-y: auto; overflow-x: hidden; padding-bottom: 12px; }
|
||||||
|
.end { height: 4px; }
|
||||||
|
.drop { position: absolute; inset: 0; z-index: 10; background: rgb(13 14 16 / 0.8); display: flex; align-items: center; justify-content: center; padding: 16px; pointer-events: none; }
|
||||||
|
.drop div { border: 2px dashed var(--accent); padding: 32px 48px; text-align: center; color: var(--text); }
|
||||||
|
.drop p { margin: 8px 0 0; font-weight: 600; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from './Icon.svelte';
|
||||||
|
import { session } from '../lib/session.svelte.ts';
|
||||||
|
import { store } from '../lib/settings.svelte.ts';
|
||||||
|
import { acceptAttr } from '../lib/upload.ts';
|
||||||
|
|
||||||
|
// target: the chat this composer sends to ("ch:<id>" or "dm:<session>")
|
||||||
|
let { target, placeholder, autofocus = true }: { target: string; placeholder: string; autofocus?: boolean } = $props();
|
||||||
|
|
||||||
|
let text = $state('');
|
||||||
|
let input: HTMLTextAreaElement;
|
||||||
|
let picker: HTMLInputElement;
|
||||||
|
const disabled = $derived(session.status !== 'connected');
|
||||||
|
const uploads = $derived(session.uploads.filter(u => u.key === target));
|
||||||
|
|
||||||
|
// Know the host's allowed types before the picker opens
|
||||||
|
$effect(() => {
|
||||||
|
if (store.settings.uploadHost && store.settings.uploadKey) session.loadUploadInfo().catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
if (session.sendText(text, target)) text = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function onkeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
||||||
|
e.preventDefault();
|
||||||
|
submit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onpaste(e: ClipboardEvent) {
|
||||||
|
const files = [...(e.clipboardData?.files ?? [])];
|
||||||
|
if (files.length) {
|
||||||
|
e.preventDefault();
|
||||||
|
session.uploadFiles(files, target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grow with content up to a limit
|
||||||
|
$effect(() => {
|
||||||
|
void text;
|
||||||
|
input.style.height = 'auto';
|
||||||
|
input.style.height = Math.min(input.scrollHeight, 200) + 'px';
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
void target;
|
||||||
|
if (autofocus && !disabled && matchMedia('(pointer: fine)').matches) input.focus();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="composer">
|
||||||
|
{#if uploads.length}
|
||||||
|
<ul class="uploads">
|
||||||
|
{#each uploads as u (u.id)}
|
||||||
|
<li class:error={u.error}>
|
||||||
|
<Icon name="file" size={16} />
|
||||||
|
<span class="name">{u.name}</span>
|
||||||
|
{#if u.error}<span>{u.error}</span>{:else}<span class="bar"><span style:width="{u.progress * 100}%"></span></span>{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
<div class="box">
|
||||||
|
<button class="icon-btn" title={store.settings.uploadHost ? "Upload a file" : "Send an image"} {disabled} onclick={() => picker.click()}><Icon name="paperclip" /></button>
|
||||||
|
<input bind:this={picker} type="file" multiple hidden
|
||||||
|
accept={store.settings.uploadHost && store.settings.uploadKey ? acceptAttr(session.uploadInfo) : 'image/*'}
|
||||||
|
onchange={() => { session.uploadFiles([...(picker.files ?? [])], target); picker.value = ''; }} />
|
||||||
|
<textarea bind:this={input} bind:value={text} rows="1" {placeholder} {disabled} {onkeydown} {onpaste}
|
||||||
|
aria-label="Message"></textarea>
|
||||||
|
<button class="icon-btn send" title="Send" disabled={disabled || !text.trim()} onclick={submit}><Icon name="send" size={18} /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.composer { flex: none; padding: 0 16px 16px; }
|
||||||
|
.box { display: flex; align-items: flex-end; gap: 4px; background: var(--bg-3); padding: 6px; }
|
||||||
|
textarea { flex: 1; resize: none; border: 0; background: transparent; padding: 6px 4px; max-height: 200px; line-height: 1.4; }
|
||||||
|
.send:not(:disabled) { color: var(--accent-hover); }
|
||||||
|
.uploads { list-style: none; margin: 0 0 6px; padding: 0; display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.uploads li { display: flex; align-items: center; gap: 8px; background: var(--bg-1); border: 1px solid var(--line); padding: 6px 10px; font-size: 13px; }
|
||||||
|
.uploads li.error { color: var(--danger); border-color: var(--danger); }
|
||||||
|
.name { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.bar { flex: 1; height: 4px; background: var(--bg-3); }
|
||||||
|
.bar span { display: block; height: 100%; background: var(--accent); transition: width 120ms; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Modal from './Modal.svelte';
|
||||||
|
import { store, type SavedServer } from '../lib/settings.svelte.ts';
|
||||||
|
import { sessions } from '../lib/session.svelte.ts';
|
||||||
|
import { identities } from '../lib/identities.svelte.ts';
|
||||||
|
import { makeServerIcon } from '../lib/upload.ts';
|
||||||
|
|
||||||
|
let { server, onclose }: { server: SavedServer | null; onclose: () => void } = $props();
|
||||||
|
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
|
const initial = server;
|
||||||
|
let label = $state(initial?.label ?? '');
|
||||||
|
let host = $state(initial?.host ?? '');
|
||||||
|
let port = $state(initial?.port ?? 64738);
|
||||||
|
let username = $state(initial?.username ?? '');
|
||||||
|
let password = $state(initial?.password ?? '');
|
||||||
|
let identityId = $state(initial?.identityId ?? '');
|
||||||
|
let icon = $state(initial?.icon ?? '');
|
||||||
|
let iconError = $state('');
|
||||||
|
let iconPicker: HTMLInputElement;
|
||||||
|
|
||||||
|
async function pickIcon() {
|
||||||
|
const file = iconPicker.files?.[0];
|
||||||
|
iconPicker.value = '';
|
||||||
|
if (!file) return;
|
||||||
|
iconError = '';
|
||||||
|
try { icon = await makeServerIcon(file); } catch { iconError = 'Could not read this image.'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = $derived(host.trim() && username.trim() && port > 0 && port < 65536);
|
||||||
|
|
||||||
|
function build(): SavedServer {
|
||||||
|
// Accept "host:port" pasted into the host field
|
||||||
|
let h = host.trim(), p = Number(port);
|
||||||
|
const m = /^(.+):(\d+)$/.exec(h);
|
||||||
|
if (m && !h.includes(']')) { h = m[1]; p = Number(m[2]); }
|
||||||
|
return { id: initial?.id ?? crypto.randomUUID(), label: label.trim() || h, host: h, port: p, username: username.trim(), password, identityId: identityId || undefined, icon: icon || undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveAndConnect(e: SubmitEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!valid) return;
|
||||||
|
const s = build();
|
||||||
|
store.upsertServer(s);
|
||||||
|
// Reconnect with the new settings if it was connected
|
||||||
|
if (sessions.byServer(s.id)) sessions.close(s.id);
|
||||||
|
sessions.open(s);
|
||||||
|
onclose();
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove() {
|
||||||
|
if (!initial) return;
|
||||||
|
sessions.close(initial.id);
|
||||||
|
store.removeServer(initial.id);
|
||||||
|
onclose();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal title={initial ? 'Edit server' : 'Add a server'} {onclose}>
|
||||||
|
<form id="connect" onsubmit={saveAndConnect}>
|
||||||
|
<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>
|
||||||
|
<label for="c-user">Username</label>
|
||||||
|
<input id="c-user" bind:value={username} autocomplete="username" required />
|
||||||
|
<label for="c-pass">Password <span class="opt">(optional)</span></label>
|
||||||
|
<input id="c-pass" type="password" bind:value={password} autocomplete="current-password" />
|
||||||
|
{#if identities.list.length > 1}
|
||||||
|
<label for="c-id">Identity</label>
|
||||||
|
<select id="c-id" bind:value={identityId}>
|
||||||
|
<option value="">Default ({identities.default?.name})</option>
|
||||||
|
{#each identities.list as i (i.id)}<option value={i.id}>{i.name}</option>{/each}
|
||||||
|
</select>
|
||||||
|
{/if}
|
||||||
|
<label for="c-label">Label <span class="opt">(optional)</span></label>
|
||||||
|
<input id="c-label" bind:value={label} placeholder={host || 'My server'} />
|
||||||
|
<label for="c-icon">Icon <span class="opt">(optional)</span></label>
|
||||||
|
<div class="icon-row">
|
||||||
|
<span class="preview">
|
||||||
|
{#if icon}<img src={icon} alt="" />{:else}{(label || host || '?').trim().slice(0, 2).toUpperCase()}{/if}
|
||||||
|
</span>
|
||||||
|
<button id="c-icon" type="button" class="btn" onclick={() => iconPicker.click()}>{icon ? 'Change image' : 'Choose image'}</button>
|
||||||
|
{#if icon}<button type="button" class="btn" onclick={() => (icon = '')}>Remove</button>{/if}
|
||||||
|
<input bind:this={iconPicker} type="file" accept="image/*" hidden onchange={pickIcon} />
|
||||||
|
</div>
|
||||||
|
{#if iconError}<p class="err">{iconError}</p>{/if}
|
||||||
|
</form>
|
||||||
|
{#snippet footer()}
|
||||||
|
{#if initial}<button class="btn danger" onclick={remove}>Remove</button><span style="flex:1"></span>{/if}
|
||||||
|
<button class="btn" onclick={onclose}>Cancel</button>
|
||||||
|
<button class="btn primary" type="submit" form="connect" disabled={!valid}>Save and connect</button>
|
||||||
|
{/snippet}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.row { display: flex; gap: 8px; }
|
||||||
|
.port { width: 100px; flex: none; }
|
||||||
|
.icon-row { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.preview { width: 44px; height: 44px; flex: none; display: flex; align-items: center; justify-content: center; background: var(--bg-3); font-weight: 700; overflow: hidden; }
|
||||||
|
.preview img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
|
.err { color: var(--danger); font-size: 13px; margin: 6px 0 0; }
|
||||||
|
.opt { text-transform: none; font-weight: 400; letter-spacing: 0; color: var(--text-faint); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { menu } from '../lib/menu.svelte.ts';
|
||||||
|
|
||||||
|
let el: HTMLDivElement | undefined = $state();
|
||||||
|
let pos = $state({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
// Keep the menu on screen near the pointer
|
||||||
|
$effect(() => {
|
||||||
|
const m = menu.open;
|
||||||
|
if (!m || !el) return;
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
pos = {
|
||||||
|
x: Math.max(4, Math.min(m.x, window.innerWidth - r.width - 4)),
|
||||||
|
y: Math.max(4, Math.min(m.y, window.innerHeight - r.height - 4))
|
||||||
|
};
|
||||||
|
el.querySelector<HTMLButtonElement>('button:not(:disabled)')?.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
function onkeydown(e: KeyboardEvent) {
|
||||||
|
if (!menu.open) return;
|
||||||
|
if (e.key === 'Escape') menu.close();
|
||||||
|
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
const buttons = [...el!.querySelectorAll<HTMLButtonElement>('button:not(:disabled)')];
|
||||||
|
const i = buttons.indexOf(document.activeElement as HTMLButtonElement);
|
||||||
|
buttons[(i + (e.key === 'ArrowDown' ? 1 : -1) + buttons.length) % buttons.length]?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window {onkeydown} onblur={() => menu.close()} onresize={() => menu.close()} />
|
||||||
|
|
||||||
|
{#if menu.open}
|
||||||
|
<div class="catcher" role="presentation" onmousedown={() => menu.close()} oncontextmenu={e => { e.preventDefault(); menu.close(); }}></div>
|
||||||
|
<div class="menu" role="menu" bind:this={el} style:left="{pos.x}px" style:top="{pos.y}px">
|
||||||
|
{#each menu.open.items as item, i (i)}
|
||||||
|
{#if item.separator}
|
||||||
|
<div class="sep" role="separator"></div>
|
||||||
|
{:else}
|
||||||
|
<button role="menuitem" class:danger={item.danger} disabled={item.disabled}
|
||||||
|
onclick={() => { menu.close(); item.action?.(); }}>
|
||||||
|
<span class="label">{item.label}</span>
|
||||||
|
{#if item.checked !== undefined}<span class="box" class:on={item.checked}></span>{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.catcher { position: fixed; inset: 0; z-index: 200; }
|
||||||
|
.menu {
|
||||||
|
position: fixed; z-index: 201; min-width: 200px; max-width: 280px; padding: 6px;
|
||||||
|
background: var(--bg-0); border: 1px solid var(--line); box-shadow: 0 8px 24px rgb(0 0 0 / 0.5);
|
||||||
|
}
|
||||||
|
button { display: flex; align-items: center; gap: 12px; width: 100%; padding: 7px 10px; font-size: 14px; text-align: left; color: var(--text); }
|
||||||
|
button:hover:not(:disabled), button:focus-visible { background: var(--accent); color: var(--accent-text); outline: none; }
|
||||||
|
button.danger { color: var(--danger); }
|
||||||
|
button.danger:hover:not(:disabled), button.danger:focus-visible { background: var(--danger); color: #fff; }
|
||||||
|
.label { flex: 1; }
|
||||||
|
.box { width: 14px; height: 14px; border: 2px solid currentColor; }
|
||||||
|
.box.on { background: currentColor; }
|
||||||
|
.sep { height: 1px; margin: 4px 6px; background: var(--line); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { ICON_PATHS as paths } from '../lib/icons.ts';
|
||||||
|
|
||||||
|
let { name, size = 20 }: { name: string; size?: number } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="square" stroke-linejoin="miter" aria-hidden="true">
|
||||||
|
<path d={paths[name] ?? ''} />
|
||||||
|
</svg>
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { native, type IdentitySummary } from '../lib/native.ts';
|
||||||
|
import { identities } from '../lib/identities.svelte.ts';
|
||||||
|
import { store } from '../lib/settings.svelte.ts';
|
||||||
|
import { ui } from '../lib/ui.svelte.ts';
|
||||||
|
|
||||||
|
let message = $state('');
|
||||||
|
|
||||||
|
const usedBy = (id: string) => store.servers.filter(s => s.identityId === id).map(s => s.label);
|
||||||
|
|
||||||
|
async function act(task: () => Promise<unknown>) {
|
||||||
|
message = '';
|
||||||
|
try {
|
||||||
|
await task();
|
||||||
|
await identities.refresh();
|
||||||
|
} catch (e) {
|
||||||
|
message = (e as Error).message.replace(/^Error invoking remote method '[^']+': (Error: )?/, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportId(i: IdentitySummary) {
|
||||||
|
ui.prompt = {
|
||||||
|
title: `Back up ${i.name}`, label: 'Password for the backup file', confirm: 'Choose location...', input: 'password',
|
||||||
|
text: 'The .p12 file contains your private key. Protect it with a password; the desktop Mumble client can import it.',
|
||||||
|
onsubmit: pw => act(async () => {
|
||||||
|
const path = await native!.identities.exportP12(i.id, pw);
|
||||||
|
if (path) message = `Saved to ${path}`;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rename(i: IdentitySummary) {
|
||||||
|
ui.prompt = { title: 'Rename identity', label: 'Name', confirm: 'Rename', initial: i.name, onsubmit: n => act(() => native!.identities.rename(i.id, n)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(i: IdentitySummary) {
|
||||||
|
const servers = usedBy(i.id);
|
||||||
|
ui.prompt = {
|
||||||
|
title: `Delete ${i.name}?`, label: '', confirm: 'Delete', danger: true, input: 'none',
|
||||||
|
text: `Registrations on servers are tied to this certificate and cannot be recovered without a backup.${servers.length ? ` Used by: ${servers.join(', ')}; they will use the default identity instead.` : ''}`,
|
||||||
|
onsubmit: () => act(() => native!.identities.remove(i.id))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ul class="ids">
|
||||||
|
{#each identities.list as i (i.id)}
|
||||||
|
<li class:default={i.isDefault}>
|
||||||
|
<div class="head">
|
||||||
|
<strong>{i.name}</strong>
|
||||||
|
{#if i.isDefault}<span class="tag">default</span>{/if}
|
||||||
|
</div>
|
||||||
|
<code>{i.fingerprint}</code>
|
||||||
|
{#if usedBy(i.id).length}<p class="help">Used for {usedBy(i.id).join(', ')}</p>{/if}
|
||||||
|
<div class="actions">
|
||||||
|
{#if !i.isDefault}<button class="link" onclick={() => act(() => native!.identities.setDefault(i.id))}>Make default</button>{/if}
|
||||||
|
<button class="link" onclick={() => (ui.cert = { title: `Certificate: ${i.name}`, chain: [i.cert] })}>View certificate</button>
|
||||||
|
<button class="link" onclick={() => exportId(i)}>Back up</button>
|
||||||
|
<button class="link" onclick={() => rename(i)}>Rename</button>
|
||||||
|
<button class="link danger" onclick={() => remove(i)}>Delete</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{:else}
|
||||||
|
<li class="help">No identity yet; one is created when you first connect.</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
<button class="btn" onclick={() => (ui.wizard = 'add')}>Add identity</button>
|
||||||
|
{#if message}<p class="help msg">{message}</p>{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.ids { list-style: none; margin: 8px 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
li { padding: 10px 12px; background: var(--bg-0); border: 1px solid var(--line); }
|
||||||
|
li.default { border-color: var(--accent); }
|
||||||
|
.head { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.tag { font-size: 11px; font-weight: 600; padding: 0 6px; background: var(--accent); color: var(--accent-text); }
|
||||||
|
code { display: block; margin-top: 4px; font: 11px var(--mono); color: var(--text-dim); overflow-wrap: anywhere; }
|
||||||
|
.actions { display: flex; flex-wrap: wrap; gap: 4px 14px; margin-top: 8px; }
|
||||||
|
.link { color: var(--link); font-size: 13px; }
|
||||||
|
.link:hover { text-decoration: underline; }
|
||||||
|
.link.danger { color: var(--danger); }
|
||||||
|
.help { margin: 4px 0 0; font-size: 13px; color: var(--text-dim); }
|
||||||
|
.msg { overflow-wrap: anywhere; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Modal from './Modal.svelte';
|
||||||
|
import Icon from './Icon.svelte';
|
||||||
|
import { native, type IdentitySummary } from '../lib/native.ts';
|
||||||
|
import { identities } from '../lib/identities.svelte.ts';
|
||||||
|
|
||||||
|
// setup: first start (marks setup as done at the end); add: adding another identity from Settings
|
||||||
|
let { mode = 'setup', onclose }: { mode?: 'setup' | 'add'; onclose: () => void } = $props();
|
||||||
|
|
||||||
|
type Step = 'welcome' | 'create' | 'import' | 'backup' | 'done';
|
||||||
|
let step = $state<Step>('welcome');
|
||||||
|
let busy = $state(false);
|
||||||
|
let error = $state('');
|
||||||
|
let result = $state<IdentitySummary | null>(null);
|
||||||
|
let mumble = $state<{ name: string } | null>(null);
|
||||||
|
|
||||||
|
// create
|
||||||
|
let name = $state('');
|
||||||
|
let email = $state('');
|
||||||
|
// import
|
||||||
|
let file = $state<File | null>(null);
|
||||||
|
let filePassword = $state('');
|
||||||
|
// backup
|
||||||
|
let backupPassword = $state('');
|
||||||
|
let backupConfirm = $state('');
|
||||||
|
let savedTo = $state('');
|
||||||
|
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
|
const existing = mode === 'setup' ? identities.default : undefined;
|
||||||
|
|
||||||
|
native?.identities.findMumble().then(m => (mumble = m));
|
||||||
|
|
||||||
|
async function run(task: () => Promise<IdentitySummary>, next: Step) {
|
||||||
|
busy = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
result = await task();
|
||||||
|
await identities.refresh();
|
||||||
|
step = next;
|
||||||
|
} catch (e) {
|
||||||
|
error = (e as Error).message.replace(/^Error invoking remote method '[^']+': (Error: )?/, '');
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const create = (e: SubmitEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
run(() => native!.identities.create(name.trim(), email.trim()), 'backup');
|
||||||
|
};
|
||||||
|
|
||||||
|
const importFile = async (e: SubmitEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!file) return;
|
||||||
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||||
|
run(() => native!.identities.importP12(bytes, filePassword, ''), 'done');
|
||||||
|
};
|
||||||
|
|
||||||
|
const importMumble = () => run(() => native!.identities.importMumble(''), 'done');
|
||||||
|
|
||||||
|
function keepExisting() {
|
||||||
|
result = existing ?? null;
|
||||||
|
step = 'backup';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveBackup() {
|
||||||
|
if (!result) return;
|
||||||
|
busy = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const path = await native!.identities.exportP12(result.id, backupPassword);
|
||||||
|
if (path) {
|
||||||
|
savedTo = path;
|
||||||
|
step = 'done';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error = (e as Error).message;
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finish() {
|
||||||
|
if (mode === 'setup') await native?.identities.finishSetup();
|
||||||
|
await identities.refresh();
|
||||||
|
onclose();
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = $derived(mode === 'setup' ? 'Set up your identity' : 'Add an identity');
|
||||||
|
const passwordsOk = $derived(backupPassword.length >= 8 && backupPassword === backupConfirm);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal {title} onclose={mode === 'setup' ? () => {} : onclose} width={560}>
|
||||||
|
{#if step === 'welcome'}
|
||||||
|
<p>Mumble recognizes you by a <strong>certificate</strong>, not by a password. Registered names, admin rights and friends lists on servers are tied to it, so it is worth setting up once and keeping a backup.</p>
|
||||||
|
<div class="choices">
|
||||||
|
<button class="choice" onclick={() => (step = 'create')}>
|
||||||
|
<Icon name="plus" /><span><strong>Create a new identity</strong><small>Start fresh with a new certificate</small></span>
|
||||||
|
</button>
|
||||||
|
{#if mumble}
|
||||||
|
<button class="choice" disabled={busy} onclick={importMumble}>
|
||||||
|
<Icon name="download" /><span><strong>Use my Mumble desktop identity</strong><small>Found "{mumble.name}" from the desktop client; keeps your registrations</small></span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button class="choice" onclick={() => (step = 'import')}>
|
||||||
|
<Icon name="file" /><span><strong>Import a certificate file</strong><small>A .p12 or .pfx backup, for example exported from Mumble</small></span>
|
||||||
|
</button>
|
||||||
|
{#if existing}
|
||||||
|
<button class="choice" onclick={keepExisting}>
|
||||||
|
<Icon name="shield" /><span><strong>Keep the current identity</strong><small>"{existing.name}", created automatically earlier</small></span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else if step === 'create'}
|
||||||
|
<form id="w-create" onsubmit={create}>
|
||||||
|
<p>The name is stored in the certificate and helps you tell identities apart. Servers show the username you enter when connecting, not this.</p>
|
||||||
|
<label for="w-name">Name</label>
|
||||||
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
|
<input id="w-name" bind:value={name} required autofocus placeholder="e.g. kibi" />
|
||||||
|
<label for="w-email">Email <span class="opt">(optional, stored in the certificate)</span></label>
|
||||||
|
<input id="w-email" type="email" bind:value={email} />
|
||||||
|
</form>
|
||||||
|
{:else if step === 'import'}
|
||||||
|
<form id="w-import" onsubmit={importFile}>
|
||||||
|
<label for="w-file">Certificate file</label>
|
||||||
|
<input id="w-file" type="file" accept=".p12,.pfx,application/x-pkcs12" onchange={e => (file = (e.currentTarget as HTMLInputElement).files?.[0] ?? null)} />
|
||||||
|
<label for="w-fpass">Password <span class="opt">(leave empty if it has none)</span></label>
|
||||||
|
<input id="w-fpass" type="password" bind:value={filePassword} autocomplete="off" />
|
||||||
|
</form>
|
||||||
|
{:else if step === 'backup' && result}
|
||||||
|
<p>Identity <strong>{result.name}</strong> is ready.</p>
|
||||||
|
<p class="mono">{result.fingerprint}</p>
|
||||||
|
<p>If this computer dies, the certificate goes with it, and so do your registrations. Save a password-protected backup now; the desktop Mumble client can import it too.</p>
|
||||||
|
<label for="w-bpass">Backup password <span class="opt">(at least 8 characters)</span></label>
|
||||||
|
<input id="w-bpass" type="password" bind:value={backupPassword} autocomplete="new-password" />
|
||||||
|
<label for="w-bconf">Repeat password</label>
|
||||||
|
<input id="w-bconf" type="password" bind:value={backupConfirm} autocomplete="new-password" />
|
||||||
|
{:else if step === 'done'}
|
||||||
|
<p>You are set up{result ? ` as ${result.name}` : ''}.</p>
|
||||||
|
{#if result}<p class="mono">{result.fingerprint}</p>{/if}
|
||||||
|
{#if savedTo}<p class="ok">Backup saved to {savedTo}</p>{/if}
|
||||||
|
<p class="dim">Manage identities, pick one per server and export backups in Settings.</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if error}<p class="error">{error}</p>{/if}
|
||||||
|
|
||||||
|
{#snippet footer()}
|
||||||
|
{#if step === 'welcome'}
|
||||||
|
{#if mode === 'add'}<button class="btn" onclick={onclose}>Cancel</button>{/if}
|
||||||
|
{:else if step === 'create'}
|
||||||
|
<button class="btn" onclick={() => (step = 'welcome')}>Back</button>
|
||||||
|
<button class="btn primary" type="submit" form="w-create" disabled={busy || !name.trim()}>{busy ? 'Generating...' : 'Create'}</button>
|
||||||
|
{:else if step === 'import'}
|
||||||
|
<button class="btn" onclick={() => (step = 'welcome')}>Back</button>
|
||||||
|
<button class="btn primary" type="submit" form="w-import" disabled={busy || !file}>Import</button>
|
||||||
|
{:else if step === 'backup'}
|
||||||
|
<button class="btn" onclick={() => (step = 'done')}>Skip for now</button>
|
||||||
|
<button class="btn primary" disabled={busy || !passwordsOk} onclick={saveBackup}>Save backup...</button>
|
||||||
|
{:else}
|
||||||
|
<button class="btn primary" onclick={finish}>Done</button>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
p { color: var(--text-dim); margin: 10px 0 0; }
|
||||||
|
p strong { color: var(--text); }
|
||||||
|
.choices { display: flex; flex-direction: column; gap: 8px; margin-top: 16px; }
|
||||||
|
.choice { display: flex; align-items: center; gap: 14px; padding: 12px 14px; text-align: left; background: var(--bg-3); border: 1px solid var(--line); }
|
||||||
|
.choice:hover:not(:disabled) { border-color: var(--accent); background: var(--bg-hover); }
|
||||||
|
.choice span { display: flex; flex-direction: column; }
|
||||||
|
.choice small { color: var(--text-dim); font-size: 13px; }
|
||||||
|
.opt { text-transform: none; font-weight: 400; letter-spacing: 0; color: var(--text-faint); }
|
||||||
|
.mono { font: 12px var(--mono); overflow-wrap: anywhere; color: var(--text); }
|
||||||
|
.ok { color: var(--speaking); overflow-wrap: anywhere; }
|
||||||
|
.dim { font-size: 13px; }
|
||||||
|
.error { color: var(--danger); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Avatar from './Avatar.svelte';
|
||||||
|
import Icon from './Icon.svelte';
|
||||||
|
import { session } from '../lib/session.svelte.ts';
|
||||||
|
import { menu } from '../lib/menu.svelte.ts';
|
||||||
|
import { userMenu } from '../lib/actions.ts';
|
||||||
|
import { voice } from '../lib/audio/voice.svelte.ts';
|
||||||
|
import { store } from '../lib/settings.svelte.ts';
|
||||||
|
|
||||||
|
let { onnavigate }: { onnavigate: () => void } = $props();
|
||||||
|
|
||||||
|
const here = $derived(session.self ? session.usersIn(session.self.channelId) : []);
|
||||||
|
const elsewhere = $derived(session.allUsers().filter(u => u.channelId !== session.self?.channelId));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<aside class="members" aria-label="Members">
|
||||||
|
{#snippet group(title: string, users: typeof here)}
|
||||||
|
{#if users.length}
|
||||||
|
<h3>{title} ({users.length})</h3>
|
||||||
|
<ul>
|
||||||
|
{#each users as u (u.session)}
|
||||||
|
{@const isSelf = u.session === session.self?.session}
|
||||||
|
<li>
|
||||||
|
<button onclick={() => session.showUser(u.session)} title={isSelf ? 'You' : `${u.name} (double-click to message)`}
|
||||||
|
ondblclick={() => { if (!isSelf) { session.selectView(`dm:${u.session}`); onnavigate(); } }}
|
||||||
|
oncontextmenu={e => menu.show(e, userMenu(u))}>
|
||||||
|
<Avatar name={u.name} size={30} speaking={voice.talking[u.session] || (isSelf && voice.transmitting)} />
|
||||||
|
<span class="who">
|
||||||
|
<span class="name">{store.displayName(u)}</span>
|
||||||
|
<span class="sub">{u.userId != null ? 'Registered' : 'Guest'}{title !== 'In channel' ? ` in ${session.channel(u.channelId)?.name || 'Root'}` : ''}</span>
|
||||||
|
</span>
|
||||||
|
{#if u.selfDeaf || u.deaf}<Icon name="headphones-off" size={14} />{:else if u.selfMute || u.mute}<Icon name="mic-off" size={14} />{/if}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
{@render group('In channel', here)}
|
||||||
|
{@render group('Elsewhere', elsewhere)}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.members { flex: 1; min-height: 0; overflow-y: auto; padding: 8px 6px; }
|
||||||
|
h3 { font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-faint); margin: 14px 8px 6px; }
|
||||||
|
ul { list-style: none; margin: 0; padding: 0; }
|
||||||
|
button { display: flex; align-items: center; gap: 10px; width: 100%; padding: 5px 8px; text-align: left; color: var(--text-dim); }
|
||||||
|
button:hover { background: var(--bg-hover); color: var(--text); }
|
||||||
|
.who { flex: 1; min-width: 0; display: flex; flex-direction: column; line-height: 1.2; }
|
||||||
|
.name { color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.sub { font-size: 11px; color: var(--text-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Avatar from './Avatar.svelte';
|
||||||
|
import YouTubeEmbed from './YouTubeEmbed.svelte';
|
||||||
|
import { store } from '../lib/settings.svelte.ts';
|
||||||
|
import type { ChatEntry } from '../lib/session.svelte.ts';
|
||||||
|
|
||||||
|
let { entry, grouped }: { entry: ChatEntry; grouped: boolean } = $props();
|
||||||
|
|
||||||
|
const time = $derived(new Date(entry.time));
|
||||||
|
const hhmm = $derived(time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if entry.kind === 'system'}
|
||||||
|
<div class="system">
|
||||||
|
<span class="mark" aria-hidden="true"></span>
|
||||||
|
<div class="html">{@html entry.html} <span class="time">{hhmm}</span></div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<article class="msg" class:grouped>
|
||||||
|
<div class="gutter">
|
||||||
|
{#if grouped}
|
||||||
|
<span class="time hover">{hhmm}</span>
|
||||||
|
{:else}
|
||||||
|
<Avatar name={entry.author} size={38} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
{#if !grouped}
|
||||||
|
<header>
|
||||||
|
<strong class:self={entry.self}>{entry.author}</strong>
|
||||||
|
<time datetime={time.toISOString()}>{time.toLocaleString([], { dateStyle: 'short', timeStyle: 'short' })}</time>
|
||||||
|
</header>
|
||||||
|
{/if}
|
||||||
|
{#if entry.html}<div class="html">{@html entry.html}</div>{/if}
|
||||||
|
{#each entry.embeds as m (m.url)}
|
||||||
|
{#if m.kind === 'youtube'}
|
||||||
|
{#if store.settings.youtube !== 'off'}<div class="embed"><YouTubeEmbed videoId={m.videoId!} start={m.start} url={m.url} /></div>{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="embed">
|
||||||
|
{#if m.kind === 'image'}
|
||||||
|
<a href={m.url} target="_blank" rel="noopener noreferrer"><img src={m.url} alt={m.name} loading="lazy" /></a>
|
||||||
|
{:else if m.kind === 'video'}
|
||||||
|
<video src={m.url} controls preload="metadata"><track kind="captions" /></video>
|
||||||
|
{:else}
|
||||||
|
<audio src={m.url} controls preload="metadata"></audio>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.msg { display: flex; gap: 14px; padding: 2px 16px; margin-top: 14px; }
|
||||||
|
.msg.grouped { margin-top: 0; }
|
||||||
|
.msg:hover { background: rgb(255 255 255 / 0.02); }
|
||||||
|
.gutter { width: 38px; flex: none; display: flex; justify-content: center; }
|
||||||
|
.content { flex: 1; min-width: 0; }
|
||||||
|
header { display: flex; align-items: baseline; gap: 8px; }
|
||||||
|
header strong { font-weight: 600; }
|
||||||
|
header strong.self { color: var(--accent-hover); }
|
||||||
|
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 :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(pre), .html :global(code) { font-family: var(--mono); font-size: 13px; background: var(--bg-0); padding: 1px 4px; }
|
||||||
|
.html :global(pre) { padding: 8px 10px; overflow-x: auto; }
|
||||||
|
.html :global(blockquote) { margin: 4px 0; padding-left: 10px; border-left: 3px solid var(--line); color: var(--text-dim); }
|
||||||
|
.embed { margin-top: 6px; max-width: min(520px, 100%); }
|
||||||
|
.embed img, .embed video { display: block; max-width: 100%; max-height: 360px; background: var(--bg-0); border: 1px solid var(--line); }
|
||||||
|
.embed audio { width: 100%; }
|
||||||
|
.system { display: flex; gap: 14px; padding: 4px 16px; margin-top: 8px; color: var(--text-dim); font-size: 13px; }
|
||||||
|
.system .mark { width: 38px; flex: none; display: flex; justify-content: center; padding-top: 7px; }
|
||||||
|
.system .mark::before { content: ''; width: 6px; height: 6px; background: var(--text-faint); }
|
||||||
|
.system .time { margin-left: 6px; }
|
||||||
|
.system .html { flex: 1; min-width: 0; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import Icon from './Icon.svelte';
|
||||||
|
|
||||||
|
let { title, onclose, children, footer, width = 460 }:
|
||||||
|
{ title: string; onclose: () => void; children: Snippet; footer?: Snippet; width?: number } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={e => { if (e.key === 'Escape') onclose(); }} />
|
||||||
|
|
||||||
|
<div class="backdrop" role="presentation" onmousedown={e => { if (e.target === e.currentTarget) onclose(); }}>
|
||||||
|
<div class="modal" role="dialog" aria-modal="true" aria-label={title} style:max-width="{width}px">
|
||||||
|
<header>
|
||||||
|
<h2>{title}</h2>
|
||||||
|
<button class="icon-btn" onclick={onclose} aria-label="Close"><Icon name="x" /></button>
|
||||||
|
</header>
|
||||||
|
<div class="body">{@render children()}</div>
|
||||||
|
{#if footer}<footer>{@render footer()}</footer>{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.backdrop {
|
||||||
|
position: fixed; inset: 0; z-index: 100; background: rgb(0 0 0 / 0.6);
|
||||||
|
display: flex; align-items: center; justify-content: center; padding: 16px;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
width: 100%; max-height: calc(100vh - 32px); display: flex; flex-direction: column;
|
||||||
|
background: var(--bg-1); border: 1px solid var(--line); box-shadow: 0 16px 48px rgb(0 0 0 / 0.5);
|
||||||
|
}
|
||||||
|
header { display: flex; align-items: center; justify-content: space-between; padding: 16px 16px 0 20px; }
|
||||||
|
h2 { margin: 0; font-size: 18px; }
|
||||||
|
.body { padding: 4px 20px 20px; overflow-y: auto; }
|
||||||
|
footer { display: flex; justify-content: flex-end; gap: 8px; padding: 14px 20px; background: var(--bg-0); border-top: 1px solid var(--line); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Modal from './Modal.svelte';
|
||||||
|
|
||||||
|
let { title, label, confirm, danger = false, input = 'text', initial = '', text = '', onsubmit, onclose }:
|
||||||
|
{ title: string; label: string; confirm: string; danger?: boolean; input?: 'text' | 'password' | 'none'; initial?: string;
|
||||||
|
text?: string; onsubmit: (value: string) => void; onclose: () => void } = $props();
|
||||||
|
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
|
let value = $state(initial);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal {title} {onclose}>
|
||||||
|
<form id="prompt" onsubmit={e => { e.preventDefault(); onsubmit(value); onclose(); }}>
|
||||||
|
{#if text}<p>{text}</p>{/if}
|
||||||
|
{#if input !== 'none'}
|
||||||
|
<label for="p-val">{label}</label>
|
||||||
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
|
<input id="p-val" type={input} bind:value autofocus autocomplete="off" />
|
||||||
|
{/if}
|
||||||
|
</form>
|
||||||
|
{#snippet footer()}
|
||||||
|
<button class="btn" onclick={onclose}>Cancel</button>
|
||||||
|
<button class="btn" class:primary={!danger} class:danger type="submit" form="prompt">{confirm}</button>
|
||||||
|
{/snippet}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
p { color: var(--text-dim); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { voice, type TransmitMode } from '../lib/audio/voice.svelte.ts';
|
||||||
|
import { ui } from '../lib/ui.svelte.ts';
|
||||||
|
|
||||||
|
const q = $derived(ui.quickAudio!);
|
||||||
|
const s = voice.settings;
|
||||||
|
let el: HTMLDivElement | undefined = $state();
|
||||||
|
let pos = $state({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
voice.refreshDevices();
|
||||||
|
|
||||||
|
// Open above the button it came from, kept on screen
|
||||||
|
$effect(() => {
|
||||||
|
if (!el) return;
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
pos = {
|
||||||
|
x: Math.max(8, Math.min(q.x - r.width / 2, window.innerWidth - r.width - 8)),
|
||||||
|
y: Math.max(8, q.y - r.height - 8)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const close = () => (ui.quickAudio = null);
|
||||||
|
const METER_MIN = -80;
|
||||||
|
const pct = (db: number) => Math.max(0, Math.min(100, ((db - METER_MIN) / -METER_MIN) * 100));
|
||||||
|
const modes: { id: TransmitMode; label: string }[] = [
|
||||||
|
{ id: 'vad', label: 'Activity' }, { id: 'ptt', label: 'Push to talk' }, { id: 'continuous', label: 'Always on' }
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={e => { if (e.key === 'Escape') close(); }} onresize={close} />
|
||||||
|
|
||||||
|
<div class="catcher" role="presentation" onmousedown={close} oncontextmenu={e => { e.preventDefault(); close(); }}></div>
|
||||||
|
<div class="pop" role="dialog" aria-label={q.kind === 'input' ? 'Microphone' : 'Output'} bind:this={el} style:left="{pos.x}px" style:top="{pos.y}px">
|
||||||
|
{#if q.kind === 'input'}
|
||||||
|
<label for="qa-in">Microphone</label>
|
||||||
|
<select id="qa-in" value={s.inputDevice} onchange={e => voice.update({ inputDevice: e.currentTarget.value })}>
|
||||||
|
{#each voice.inputDevices as d (d.id)}<option value={d.id}>{d.label}</option>{:else}<option value="default">System default</option>{/each}
|
||||||
|
</select>
|
||||||
|
<label for="qa-gain">Input volume: {Math.round(s.inputGain * 100)}%</label>
|
||||||
|
<input id="qa-gain" type="range" min="0" max="3" step="0.05" value={s.inputGain}
|
||||||
|
oninput={e => voice.update({ inputGain: Number(e.currentTarget.value) })} />
|
||||||
|
<div class="meter" aria-hidden="true"><div class="fill" class:open={s.mode !== 'vad' || voice.level > s.vadThreshold} style:width="{pct(voice.level)}%"></div></div>
|
||||||
|
<div class="modes" role="radiogroup" aria-label="Transmit mode">
|
||||||
|
{#each modes as m (m.id)}
|
||||||
|
<button role="radio" aria-checked={s.mode === m.id} class:active={s.mode === m.id} onclick={() => voice.update({ mode: m.id })}>{m.label}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<label class="check"><input type="checkbox" checked={s.noiseSuppression} onchange={e => voice.update({ noiseSuppression: e.currentTarget.checked })} /> Noise suppression</label>
|
||||||
|
{:else}
|
||||||
|
<label for="qa-out">Output device</label>
|
||||||
|
<select id="qa-out" value={s.outputDevice} onchange={e => voice.update({ outputDevice: e.currentTarget.value })}>
|
||||||
|
{#each voice.outputDevices as d (d.id)}<option value={d.id}>{d.label}</option>{:else}<option value="default">System default</option>{/each}
|
||||||
|
</select>
|
||||||
|
<label for="qa-vol">Output volume: {Math.round(s.outputVolume * 100)}%</label>
|
||||||
|
<input id="qa-vol" type="range" min="0" max="2" step="0.05" value={s.outputVolume}
|
||||||
|
oninput={e => voice.update({ outputVolume: Number(e.currentTarget.value) })} />
|
||||||
|
{/if}
|
||||||
|
<button class="more" onclick={() => { close(); ui.settingsTab = 'voice'; ui.settingsOpen = true; }}>Voice settings...</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.catcher { position: fixed; inset: 0; z-index: 200; }
|
||||||
|
.pop { position: fixed; z-index: 201; width: 260px; padding: 10px 12px 8px; background: var(--bg-0); border: 1px solid var(--line); box-shadow: 0 8px 24px rgb(0 0 0 / 0.5); }
|
||||||
|
label { margin-top: 10px; }
|
||||||
|
label:first-child { margin-top: 0; }
|
||||||
|
select { font-size: 13px; padding: 6px 8px; }
|
||||||
|
input[type=range] { padding: 0; border: 0; background: transparent; accent-color: var(--accent); }
|
||||||
|
.meter { height: 6px; background: var(--bg-3); margin-top: 4px; overflow: hidden; }
|
||||||
|
.fill { height: 100%; background: var(--text-faint); transition: width 50ms linear; }
|
||||||
|
.fill.open { background: var(--speaking); }
|
||||||
|
.modes { display: flex; gap: 2px; margin-top: 10px; }
|
||||||
|
.modes button { flex: 1; padding: 5px 4px; font-size: 12px; background: var(--bg-3); color: var(--text-dim); }
|
||||||
|
.modes button.active { background: var(--accent); color: var(--accent-text); }
|
||||||
|
.check { display: flex; align-items: center; gap: 8px; text-transform: none; font-weight: 400; letter-spacing: 0; font-size: 13px; color: var(--text); margin-top: 10px; }
|
||||||
|
.check input { width: auto; }
|
||||||
|
.more { display: block; width: 100%; margin-top: 10px; padding: 6px 0 0; border-top: 1px solid var(--line); text-align: left; font-size: 13px; color: var(--link); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import Icon from './Icon.svelte';
|
||||||
|
import { toMumbleHtml, formatBytes } from '../lib/html.ts';
|
||||||
|
import { makeInlineImage } from '../lib/upload.ts';
|
||||||
|
import { ui } from '../lib/ui.svelte.ts';
|
||||||
|
|
||||||
|
// Rich text in the subset Mumble clients render (Qt rich text): bold, italic, underline,
|
||||||
|
// strikethrough, colors, lists, links and inline data: images. `limit` is the server's
|
||||||
|
// size limit for the whole HTML, pictures included.
|
||||||
|
let { value = '', limit, placeholder = '', onchange }:
|
||||||
|
{ value?: string; limit: number; placeholder?: string; onchange: (html: string) => void } = $props();
|
||||||
|
|
||||||
|
let editor: HTMLDivElement;
|
||||||
|
let picker: HTMLInputElement;
|
||||||
|
let source = $state(false);
|
||||||
|
let sourceText = $state('');
|
||||||
|
let html = $state('');
|
||||||
|
let error = $state('');
|
||||||
|
let color = $state('#e8b23c');
|
||||||
|
|
||||||
|
const size = $derived(html.length);
|
||||||
|
const over = $derived(limit > 0 && size > limit);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
document.execCommand('defaultParagraphSeparator', false, 'p');
|
||||||
|
document.execCommand('styleWithCSS', false, 'true');
|
||||||
|
editor.innerHTML = toMumbleHtml(value) || '<p><br></p>';
|
||||||
|
sync();
|
||||||
|
});
|
||||||
|
|
||||||
|
function sync() {
|
||||||
|
html = toMumbleHtml(editor.innerHTML);
|
||||||
|
onchange(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
function exec(cmd: string, arg?: string) {
|
||||||
|
editor.focus();
|
||||||
|
document.execCommand(cmd, false, arg);
|
||||||
|
sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// window.prompt does not exist in Electron; use the app's dialog and restore the selection after
|
||||||
|
function link() {
|
||||||
|
const sel = window.getSelection();
|
||||||
|
const range = sel && sel.rangeCount && editor.contains(sel.anchorNode) ? sel.getRangeAt(0).cloneRange() : null;
|
||||||
|
ui.prompt = {
|
||||||
|
title: 'Insert link', label: 'Address (https://...)', confirm: 'Insert link', initial: 'https://',
|
||||||
|
onsubmit: raw => {
|
||||||
|
const url = raw.trim();
|
||||||
|
if (!/^https?:\/\/\S+$/i.test(url)) return;
|
||||||
|
editor.focus();
|
||||||
|
if (range) { sel!.removeAllRanges(); sel!.addRange(range); }
|
||||||
|
if (range && !range.collapsed) exec('createLink', url);
|
||||||
|
else exec('insertHTML', `<a href="${url.replace(/"/g, '"')}">${url.replace(/</g, '<')}</a>`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertImage(file: File) {
|
||||||
|
error = '';
|
||||||
|
// Room left for the picture inside the server's limit
|
||||||
|
const room = (limit || 131072) - size - 64;
|
||||||
|
const uri = await makeInlineImage(file, room, [640, 480, 320, 200, 120]);
|
||||||
|
if (!uri) {
|
||||||
|
error = room < 2000 ? 'No room left for a picture within the server limit.' : 'Could not make this picture small enough.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
exec('insertImage', uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onpaste(e: ClipboardEvent) {
|
||||||
|
const img = [...(e.clipboardData?.files ?? [])].find(f => f.type.startsWith('image/'));
|
||||||
|
e.preventDefault();
|
||||||
|
if (img) return insertImage(img);
|
||||||
|
const pasted = e.clipboardData?.getData('text/html');
|
||||||
|
if (pasted) exec('insertHTML', toMumbleHtml(pasted));
|
||||||
|
else exec('insertText', e.clipboardData?.getData('text/plain') ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSource() {
|
||||||
|
if (!source) {
|
||||||
|
sourceText = html;
|
||||||
|
source = true;
|
||||||
|
} else {
|
||||||
|
editor.innerHTML = toMumbleHtml(sourceText) || '<p><br></p>';
|
||||||
|
source = false;
|
||||||
|
sync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onkeydown(e: KeyboardEvent) {
|
||||||
|
if (!(e.ctrlKey || e.metaKey)) return;
|
||||||
|
const map: Record<string, string> = { b: 'bold', i: 'italic', u: 'underline' };
|
||||||
|
const cmd = map[e.key.toLowerCase()];
|
||||||
|
if (cmd) { e.preventDefault(); exec(cmd); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const tools = [
|
||||||
|
{ cmd: 'bold', label: 'Bold (Ctrl+B)', text: 'B', cls: 'b' },
|
||||||
|
{ cmd: 'italic', label: 'Italic (Ctrl+I)', text: 'I', cls: 'i' },
|
||||||
|
{ cmd: 'underline', label: 'Underline (Ctrl+U)', text: 'U', cls: 'u' },
|
||||||
|
{ cmd: 'strikeThrough', label: 'Strikethrough', text: 'S', cls: 's' }
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="rich" class:over>
|
||||||
|
<div class="toolbar" role="toolbar" aria-label="Formatting">
|
||||||
|
{#each tools as t (t.cmd)}
|
||||||
|
<button class="tool {t.cls}" title={t.label} aria-label={t.label} disabled={source} onmousedown={e => e.preventDefault()} onclick={() => exec(t.cmd)}>{t.text}</button>
|
||||||
|
{/each}
|
||||||
|
<span class="sep"></span>
|
||||||
|
<label class="tool color" title="Text color">
|
||||||
|
<span class="swatch" style:background={color}></span>
|
||||||
|
<input type="color" bind:value={color} disabled={source} onchange={() => exec('foreColor', color)} aria-label="Text color" />
|
||||||
|
</label>
|
||||||
|
<span class="sep"></span>
|
||||||
|
<button class="tool" title="Bulleted list" aria-label="Bulleted list" disabled={source} onmousedown={e => e.preventDefault()} onclick={() => exec('insertUnorderedList')}><Icon name="list" size={16} /></button>
|
||||||
|
<button class="tool" title="Numbered list" aria-label="Numbered list" disabled={source} onmousedown={e => e.preventDefault()} onclick={() => exec('insertOrderedList')}><Icon name="list-ordered" size={16} /></button>
|
||||||
|
<button class="tool" title="Link" aria-label="Link" disabled={source} onmousedown={e => e.preventDefault()} onclick={link}><Icon name="link" size={16} /></button>
|
||||||
|
<button class="tool" title="Insert picture" aria-label="Insert picture" disabled={source} onmousedown={e => e.preventDefault()} onclick={() => picker.click()}><Icon name="image" size={16} /></button>
|
||||||
|
<input bind:this={picker} type="file" accept="image/*" hidden onchange={() => { const f = picker.files?.[0]; picker.value = ''; if (f) insertImage(f); }} />
|
||||||
|
<button class="tool" title="Clear formatting" aria-label="Clear formatting" disabled={source} onmousedown={e => e.preventDefault()} onclick={() => exec('removeFormat')}><Icon name="eraser" size={16} /></button>
|
||||||
|
<span class="grow"></span>
|
||||||
|
<button class="tool" class:on={source} title="Edit HTML" aria-label="Edit HTML" aria-pressed={source} onclick={toggleSource}><Icon name="code" size={16} /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="area" class:hidden={source} class:blank={!html} bind:this={editor} contenteditable="true" role="textbox" aria-multiline="true"
|
||||||
|
aria-label="Description" tabindex="0" data-placeholder={placeholder} oninput={sync} {onpaste} {onkeydown}></div>
|
||||||
|
{#if source}
|
||||||
|
<textarea class="area src" bind:value={sourceText} oninput={() => onchange(toMumbleHtml(sourceText))} aria-label="HTML source" spellcheck="false"></textarea>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="foot">
|
||||||
|
{#if error}<span class="err">{error}</span>{/if}
|
||||||
|
<span class="grow"></span>
|
||||||
|
<span class="count" title="Size of the description as the server counts it">{formatBytes(source ? sourceText.length : size)}{limit ? ` / ${formatBytes(limit)}` : ''}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.rich { border: 1px solid var(--line); background: var(--bg-0); }
|
||||||
|
.rich:focus-within { border-color: var(--accent); }
|
||||||
|
.rich.over { border-color: var(--danger); }
|
||||||
|
.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 2px; padding: 4px; border-bottom: 1px solid var(--line); background: var(--bg-2); }
|
||||||
|
.tool { width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; color: var(--text-dim); font-size: 14px; cursor: pointer; }
|
||||||
|
.tool:hover:not(:disabled), .tool.on { background: var(--bg-hover); color: var(--text); }
|
||||||
|
.b { font-weight: 800; }
|
||||||
|
.i { font-style: italic; font-family: Georgia, serif; }
|
||||||
|
.u { text-decoration: underline; }
|
||||||
|
.s { text-decoration: line-through; }
|
||||||
|
.sep { width: 1px; height: 18px; background: var(--line); margin: 0 4px; }
|
||||||
|
.grow { flex: 1; }
|
||||||
|
.color { position: relative; margin: 0; }
|
||||||
|
.swatch { width: 14px; height: 14px; border: 1px solid var(--text-faint); }
|
||||||
|
.color input { position: absolute; inset: 0; opacity: 0; cursor: pointer; padding: 0; }
|
||||||
|
.area { position: relative; min-height: 140px; max-height: 360px; overflow-y: auto; padding: 8px 10px; outline: none; overflow-wrap: anywhere; line-height: 1.45; }
|
||||||
|
.area.hidden { display: none; }
|
||||||
|
.area :global(p) { margin: 0 0 6px; }
|
||||||
|
.area :global(img) { max-width: 100%; }
|
||||||
|
.area.blank::before { content: attr(data-placeholder); color: var(--text-faint); position: absolute; left: 10px; top: 8px; pointer-events: none; }
|
||||||
|
.src { width: 100%; border: 0; resize: vertical; font: 12px var(--mono); background: transparent; }
|
||||||
|
.foot { display: flex; align-items: center; gap: 8px; padding: 4px 8px; border-top: 1px solid var(--line); font-size: 12px; color: var(--text-faint); }
|
||||||
|
.over .count { color: var(--danger); font-weight: 600; }
|
||||||
|
.err { color: var(--danger); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from './Icon.svelte';
|
||||||
|
import Avatar from './Avatar.svelte';
|
||||||
|
import Members from './Members.svelte';
|
||||||
|
import ChatLog from './ChatLog.svelte';
|
||||||
|
import Composer from './Composer.svelte';
|
||||||
|
import { session } from '../lib/session.svelte.ts';
|
||||||
|
import { store } from '../lib/settings.svelte.ts';
|
||||||
|
import { menu } from '../lib/menu.svelte.ts';
|
||||||
|
import { userMenu, changeNamePrompt, nicknamePrompt } from '../lib/actions.ts';
|
||||||
|
import { voice, userKey } from '../lib/audio/voice.svelte.ts';
|
||||||
|
import { renderIncoming } from '../lib/html.ts';
|
||||||
|
import RichEditor from './RichEditor.svelte';
|
||||||
|
|
||||||
|
let { onnavigate, onclose }: { onnavigate: () => void; onclose: () => void } = $props();
|
||||||
|
|
||||||
|
const panel = $derived(session.panel);
|
||||||
|
const channel = $derived(panel.kind === 'channel' ? session.channel(panel.id) : undefined);
|
||||||
|
const user = $derived(panel.kind === 'user' ? session.user(panel.session) : undefined);
|
||||||
|
const isSelf = $derived(!!user && user.session === session.self?.session);
|
||||||
|
|
||||||
|
// Description or comment arrive later when they were only announced by hash
|
||||||
|
// Re-runs when the hash changes, e.g. right after saving a long description
|
||||||
|
$effect(() => { if (channel?.descriptionHash && !channel.description) session.loadDescription(channel.id); });
|
||||||
|
$effect(() => { if (user?.commentHash && !user.comment) session.loadComment(user.session); });
|
||||||
|
|
||||||
|
const description = $derived(channel?.description ? renderIncoming(channel.description, store.trustedHosts).html : '');
|
||||||
|
const comment = $derived(user?.comment ? renderIncoming(user.comment, store.trustedHosts).html : '');
|
||||||
|
const pendingDescription = $derived(!!channel?.descriptionHash && !channel.description);
|
||||||
|
const pendingComment = $derived(!!user?.commentHash && !user.comment);
|
||||||
|
|
||||||
|
// User-resizable width, remembered between sessions
|
||||||
|
const MIN_W = 240, DEFAULT_W = 300;
|
||||||
|
const maxW = () => Math.max(MIN_W, Math.min(900, window.innerWidth - 420));
|
||||||
|
let width = $state(Math.max(MIN_W, Number(localStorage.getItem('mumh5.panelWidth')) || DEFAULT_W));
|
||||||
|
let resizing = $state(false);
|
||||||
|
|
||||||
|
function setWidth(w: number) {
|
||||||
|
width = Math.round(Math.max(MIN_W, Math.min(maxW(), w)));
|
||||||
|
try { localStorage.setItem('mumh5.panelWidth', String(width)); } catch { /* storage unavailable */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function startResize(e: PointerEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const handle = e.currentTarget as HTMLElement;
|
||||||
|
handle.setPointerCapture(e.pointerId);
|
||||||
|
resizing = true;
|
||||||
|
const right = (handle.parentElement as HTMLElement).getBoundingClientRect().right;
|
||||||
|
const move = (ev: PointerEvent) => setWidth(right - ev.clientX);
|
||||||
|
const up = () => {
|
||||||
|
resizing = false;
|
||||||
|
handle.removeEventListener('pointermove', move);
|
||||||
|
handle.removeEventListener('pointerup', up);
|
||||||
|
handle.removeEventListener('pointercancel', up);
|
||||||
|
};
|
||||||
|
handle.addEventListener('pointermove', move);
|
||||||
|
handle.addEventListener('pointerup', up);
|
||||||
|
handle.addEventListener('pointercancel', up);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resizeKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'ArrowLeft') setWidth(width + 16);
|
||||||
|
else if (e.key === 'ArrowRight') setWidth(width - 16);
|
||||||
|
else return;
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
let editing = $state(false);
|
||||||
|
let draft = $state('');
|
||||||
|
$effect(() => { void panel; editing = false; });
|
||||||
|
|
||||||
|
function startEdit() {
|
||||||
|
draft = user?.comment ?? '';
|
||||||
|
editing = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveComment() {
|
||||||
|
session.setComment(draft);
|
||||||
|
editing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onresize={() => setWidth(width)} />
|
||||||
|
|
||||||
|
<aside class="panel" class:resizing aria-label="Details" style:width="{width}px">
|
||||||
|
<!-- Focusable separator is the ARIA window-splitter pattern -->
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
|
||||||
|
<div class="resize" role="separator" aria-orientation="vertical" aria-label="Resize panel" aria-valuenow={width}
|
||||||
|
aria-valuemin={MIN_W} aria-valuemax={maxW()} tabindex="0" title="Drag to resize, double-click to reset"
|
||||||
|
onpointerdown={startResize} ondblclick={() => setWidth(DEFAULT_W)} onkeydown={resizeKey}></div>
|
||||||
|
{#if panel.kind === 'members' || (!channel && !user)}
|
||||||
|
<header><h2>Members</h2><button class="icon-btn" onclick={onclose} aria-label="Close"><Icon name="x" size={18} /></button></header>
|
||||||
|
<Members {onnavigate} />
|
||||||
|
{:else if channel && panel.kind === 'channel'}
|
||||||
|
<header>
|
||||||
|
<Icon name="channel" size={18} />
|
||||||
|
<h2>{channel.name || 'Root'}</h2>
|
||||||
|
<button class="icon-btn" onclick={() => session.closePanel()} aria-label="Close"><Icon name="x" size={18} /></button>
|
||||||
|
</header>
|
||||||
|
<div class="tabs" role="tablist">
|
||||||
|
<button role="tab" aria-selected={panel.tab === 'info'} class:active={panel.tab === 'info'} onclick={() => session.showChannel(channel.id, 'info')}>Info</button>
|
||||||
|
<button role="tab" aria-selected={panel.tab === 'chat'} class:active={panel.tab === 'chat'} onclick={() => session.showChannel(channel.id, 'chat')}>
|
||||||
|
Chat{#if session.unread[`ch:${channel.id}`]}<span class="badge">{session.unread[`ch:${channel.id}`]}</span>{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if panel.tab === 'info'}
|
||||||
|
<div class="body">
|
||||||
|
{#if description}
|
||||||
|
<div class="html">{@html description}</div>
|
||||||
|
{:else if pendingDescription}
|
||||||
|
<p class="dim">Loading description...</p>
|
||||||
|
{:else}
|
||||||
|
<p class="dim">This channel has no description.</p>
|
||||||
|
{/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>
|
||||||
|
<div class="actions">
|
||||||
|
{#if session.self?.channelId === channel.id}
|
||||||
|
<span class="here">You are in this channel</span>
|
||||||
|
{:else}
|
||||||
|
<button class="btn primary" disabled={!channel.canEnter} onclick={() => { session.joinChannel(channel.id); onnavigate(); }}>
|
||||||
|
{channel.canEnter ? 'Join channel' : 'No access'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button class="btn" onclick={() => session.showChannel(channel.id, 'chat')}>Open chat</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<ChatLog key={`ch:${channel.id}`}>
|
||||||
|
{#snippet empty()}<p class="dim pad">Messages sent here go to everyone in #{channel.name || 'Root'}, even if you are not in it.</p>{/snippet}
|
||||||
|
</ChatLog>
|
||||||
|
<Composer target={`ch:${channel.id}`} placeholder={`Message #${channel.name || 'Root'}`} />
|
||||||
|
{/if}
|
||||||
|
{:else if user}
|
||||||
|
<header>
|
||||||
|
<h2>{isSelf ? 'Your profile' : 'Profile'}</h2>
|
||||||
|
<button class="icon-btn" onclick={() => session.closePanel()} aria-label="Close"><Icon name="x" size={18} /></button>
|
||||||
|
</header>
|
||||||
|
<div class="body">
|
||||||
|
<div class="who" role="presentation" oncontextmenu={e => menu.show(e, userMenu(user))}>
|
||||||
|
<Avatar name={user.name} size={56} speaking={voice.talking[user.session] || (isSelf && voice.transmitting)} />
|
||||||
|
<div>
|
||||||
|
<h3>{store.displayName(user)}</h3>
|
||||||
|
{#if store.displayName(user) !== user.name}<p class="dim small">Real name: {user.name}</p>{/if}
|
||||||
|
<p class="badges">
|
||||||
|
<span class="badge-reg" class:registered={user.userId != null}>{user.userId != null ? 'Registered' : 'Guest'}</span>
|
||||||
|
{#if isSelf && user.userId == null}
|
||||||
|
<button class="register" title="Reserve this name on the server, tied to your certificate" onclick={() => session.register(user.session)}>Register</button>
|
||||||
|
{/if}
|
||||||
|
<span class="dim small">in {session.channel(user.channelId)?.name || 'Root'}</span>
|
||||||
|
</p>
|
||||||
|
<p class="flags">
|
||||||
|
{#if user.selfMute || user.mute}<span>{user.mute ? 'server muted' : 'muted'}</span>{/if}
|
||||||
|
{#if user.selfDeaf || user.deaf}<span>{user.deaf ? 'server deafened' : 'deafened'}</span>{/if}
|
||||||
|
{#if user.prioritySpeaker}<span>priority speaker</span>{/if}
|
||||||
|
{#if user.recording}<span class="bad">recording</span>{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if isSelf}
|
||||||
|
<div class="actions"><button class="btn" onclick={() => changeNamePrompt(user)}>Change name</button></div>
|
||||||
|
{:else}
|
||||||
|
<h4>Voice</h4>
|
||||||
|
<label for="p-vol" class="vol">Volume for me: {Math.round((voice.settings.userVolumes[userKey(user)] ?? 1) * 100)}%</label>
|
||||||
|
<input id="p-vol" type="range" min="0" max="3" step="0.05" value={voice.settings.userVolumes[userKey(user)] ?? 1}
|
||||||
|
oninput={e => voice.setUserVolume(user, Number(e.currentTarget.value))} />
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn" class:danger={!!voice.settings.localMutes[userKey(user)]} onclick={() => voice.setLocalMute(user, !voice.settings.localMutes[userKey(user)])}>
|
||||||
|
{voice.settings.localMutes[userKey(user)] ? 'Unmute for me' : 'Mute for me'}
|
||||||
|
</button>
|
||||||
|
<button class="btn" onclick={() => nicknamePrompt(user)}>Set nickname</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<h4>Description</h4>
|
||||||
|
{#if editing}
|
||||||
|
<RichEditor value={draft} limit={session.htmlLimit} placeholder="Tell people about yourself" onchange={h => (draft = h)} />
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn primary" onclick={saveComment}>Save</button>
|
||||||
|
<button class="btn" onclick={() => (editing = false)}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#if comment}
|
||||||
|
<div class="html">{@html comment}</div>
|
||||||
|
{:else if pendingComment}
|
||||||
|
<p class="dim">Loading description...</p>
|
||||||
|
{:else}
|
||||||
|
<p class="dim">{isSelf ? 'You have no description yet.' : 'No description.'}</p>
|
||||||
|
{/if}
|
||||||
|
{#if isSelf}<div class="actions"><button class="btn" onclick={startEdit}>Edit description</button></div>{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
|
||||||
|
{#if !isSelf}
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn primary" onclick={() => { session.selectView(`dm:${user.session}`); onnavigate(); }}>Send message</button>
|
||||||
|
<button class="btn" onclick={e => menu.show(e, userMenu(user))}>More</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.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.resizing { user-select: none; }
|
||||||
|
.resize { position: absolute; z-index: 5; top: 0; bottom: 0; left: -4px; width: 8px; cursor: col-resize; touch-action: none; }
|
||||||
|
.resize:hover, .resize:focus-visible, .resizing .resize { background: linear-gradient(to right, transparent 3px, var(--accent) 3px, var(--accent) 5px, transparent 5px); outline: none; }
|
||||||
|
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; }
|
||||||
|
.tabs { display: flex; flex: none; border-bottom: 1px solid var(--line); }
|
||||||
|
.tabs button { flex: 1; padding: 10px; color: var(--text-dim); font-weight: 600; font-size: 14px; display: inline-flex; justify-content: center; gap: 6px; }
|
||||||
|
.tabs button.active { color: var(--text); box-shadow: inset 0 -2px 0 var(--accent); }
|
||||||
|
.badge { background: var(--danger); color: #fff; font-size: 11px; padding: 0 5px; line-height: 16px; }
|
||||||
|
.body { flex: 1; overflow-y: auto; padding: 14px; }
|
||||||
|
.pad { padding: 14px; }
|
||||||
|
.dim { color: var(--text-dim); margin: 0; }
|
||||||
|
.small { font-size: 13px; margin-top: 8px; }
|
||||||
|
.html { overflow-wrap: anywhere; user-select: text; }
|
||||||
|
.html :global(img) { max-width: 100%; }
|
||||||
|
.html :global(p) { margin: 0 0 6px; }
|
||||||
|
.actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; align-items: center; }
|
||||||
|
.here { color: var(--speaking); font-weight: 600; font-size: 14px; }
|
||||||
|
.who { display: flex; gap: 14px; align-items: center; }
|
||||||
|
h3 { margin: 0; font-size: 18px; overflow-wrap: anywhere; }
|
||||||
|
h4 { margin: 22px 0 8px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-dim); }
|
||||||
|
.badges { display: flex; align-items: center; gap: 8px; margin: 6px 0 0; }
|
||||||
|
.badges .small { margin: 0; }
|
||||||
|
.badge-reg { font-size: 11px; font-weight: 700; padding: 1px 6px; border: 1px solid var(--line); color: var(--text-dim); }
|
||||||
|
.badge-reg.registered { border-color: var(--accent); background: var(--accent); color: var(--accent-text); }
|
||||||
|
.register { font-size: 12px; color: var(--link); }
|
||||||
|
.register:hover { text-decoration: underline; }
|
||||||
|
.vol { margin-top: 0; }
|
||||||
|
input[type=range] { padding: 0; border: 0; background: transparent; accent-color: var(--accent); }
|
||||||
|
.flags { display: flex; flex-wrap: wrap; gap: 4px; margin: 6px 0 0; }
|
||||||
|
.flags span { font-size: 11px; padding: 0 6px; border: 1px solid var(--line); color: var(--text-dim); }
|
||||||
|
.flags .bad { border-color: var(--danger); color: var(--danger); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from './Icon.svelte';
|
||||||
|
import { store, type SavedServer } from '../lib/settings.svelte.ts';
|
||||||
|
import { sessions } from '../lib/session.svelte.ts';
|
||||||
|
import { menu } from '../lib/menu.svelte.ts';
|
||||||
|
import { makeServerIcon } from '../lib/upload.ts';
|
||||||
|
|
||||||
|
let { onadd, onedit, onsettings }:
|
||||||
|
{ onadd: () => void; onedit: (s: SavedServer) => void; onsettings: () => void } = $props();
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Drag and drop reordering
|
||||||
|
let dragging = $state<string | null>(null);
|
||||||
|
let dropIndex = $state<number | null>(null);
|
||||||
|
|
||||||
|
function onDragOver(e: DragEvent, i: number) {
|
||||||
|
if (!dragging) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
|
dropIndex = e.clientY < r.top + r.height / 2 ? i : i + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (dragging && dropIndex !== null) {
|
||||||
|
const from = store.servers.findIndex(s => s.id === dragging);
|
||||||
|
store.moveServer(dragging, dropIndex > from ? dropIndex - 1 : dropIndex);
|
||||||
|
}
|
||||||
|
dragging = null;
|
||||||
|
dropIndex = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Icon picked from the tile's context menu
|
||||||
|
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);
|
||||||
|
menu.show(e, [
|
||||||
|
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 up', disabled: i === 0, action: () => store.moveServer(s.id, i - 1) },
|
||||||
|
{ label: 'Move down', 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) }
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<nav class="rail" aria-label="Servers" ondrop={onDrop} ondragover={e => { if (dragging) e.preventDefault(); }}>
|
||||||
|
{#each store.servers as s, i (s.id)}
|
||||||
|
{@const state = stateOf(s.id)}
|
||||||
|
{@const unread = unreadOf(s.id)}
|
||||||
|
{#if dropIndex === i && dragging}<div class="drop-line"></div>{/if}
|
||||||
|
<button class="tile" class:active={sessions.activeId === s.id} class:live={state === 'connected'}
|
||||||
|
class:connecting={state === 'connecting'} class:voice={sessions.voiceId === s.id && state === 'connected'}
|
||||||
|
class:dragged={dragging === s.id}
|
||||||
|
title="{s.label} ({s.host}){state === 'connected' ? (sessions.voiceId === s.id ? ', voice' : ', connected') : ''}"
|
||||||
|
draggable="true"
|
||||||
|
ondragstart={e => { dragging = s.id; e.dataTransfer?.setData('text/plain', s.id); if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move'; }}
|
||||||
|
ondragend={() => { dragging = null; dropIndex = null; }}
|
||||||
|
ondragover={e => onDragOver(e, i)}
|
||||||
|
onclick={() => sessions.open(s)}
|
||||||
|
oncontextmenu={e => serverMenu(e, s, i)}>
|
||||||
|
{#if s.icon}<img class="icon" src={s.icon} alt="" draggable="false" />{:else}{initials(s.label)}{/if}
|
||||||
|
{#if unread}<span class="unread">{unread > 99 ? '99+' : unread}</span>{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{#if dropIndex === store.servers.length && dragging}<div class="drop-line"></div>{/if}
|
||||||
|
<input bind:this={iconPicker} type="file" accept="image/*" hidden onchange={pickIcon} />
|
||||||
|
<button class="tile add" title="Add a server" onclick={onadd}><Icon name="plus" /></button>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<button class="tile ghost" title="Settings" onclick={onsettings}><Icon name="settings" /></button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.rail {
|
||||||
|
width: 68px; flex: none; background: var(--bg-0); display: flex; flex-direction: column;
|
||||||
|
align-items: center; gap: 8px; padding: 12px 0; overflow-y: auto;
|
||||||
|
}
|
||||||
|
.tile {
|
||||||
|
position: relative; width: 44px; height: 44px; flex: none; display: flex; align-items: center; justify-content: center;
|
||||||
|
background: var(--bg-3); font-weight: 700; font-size: 15px; transition: background 100ms, color 100ms;
|
||||||
|
}
|
||||||
|
.tile:hover { background: var(--accent); color: var(--accent-text); }
|
||||||
|
.tile.active { background: var(--accent); color: var(--accent-text); }
|
||||||
|
.tile.active::before {
|
||||||
|
content: ''; position: absolute; left: -12px; top: 8px; bottom: 8px; width: 4px; background: var(--text);
|
||||||
|
}
|
||||||
|
.tile.dragged { opacity: 0.4; }
|
||||||
|
.icon { width: 100%; height: 100%; object-fit: cover; display: block; pointer-events: none; }
|
||||||
|
/* Connection state: grey dot connecting, green connected, green with ring where voice runs */
|
||||||
|
.tile.live::after, .tile.connecting::after {
|
||||||
|
content: ''; position: absolute; right: -3px; bottom: -3px; width: 10px; height: 10px;
|
||||||
|
background: var(--speaking); border: 2px solid var(--bg-0);
|
||||||
|
}
|
||||||
|
.tile.connecting::after { background: var(--text-faint); }
|
||||||
|
.tile.voice::after { box-shadow: 0 0 0 2px var(--speaking); }
|
||||||
|
.unread {
|
||||||
|
position: absolute; right: -6px; top: -6px; min-width: 18px; padding: 0 4px; line-height: 16px;
|
||||||
|
font-size: 10px; background: var(--danger); color: #fff; border: 2px solid var(--bg-0);
|
||||||
|
}
|
||||||
|
.drop-line { width: 44px; height: 3px; flex: none; background: var(--accent); margin: -5px 0; }
|
||||||
|
.add { color: var(--speaking); background: var(--bg-2); }
|
||||||
|
.add:hover { background: var(--speaking); color: var(--bg-0); }
|
||||||
|
.ghost { background: transparent; color: var(--text-dim); }
|
||||||
|
.ghost:hover { background: var(--bg-3); color: var(--text); }
|
||||||
|
.spacer { flex: 1; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Modal from './Modal.svelte';
|
||||||
|
import VoiceSettings from './VoiceSettings.svelte';
|
||||||
|
import IdentityList from './IdentityList.svelte';
|
||||||
|
import { store } from '../lib/settings.svelte.ts';
|
||||||
|
import { native } from '../lib/native.ts';
|
||||||
|
import { fetchUploadInfo } from '../lib/upload.ts';
|
||||||
|
|
||||||
|
let { onclose, tab: initialTab = 'voice' }: { onclose: () => void; tab?: 'voice' | 'files' | 'identities' } = $props();
|
||||||
|
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
|
let tab = $state<'voice' | 'files' | 'identities'>(initialTab);
|
||||||
|
const s = store.settings;
|
||||||
|
let embedHosts = $state(s.embedHosts.join('\n'));
|
||||||
|
let testResult = $state('');
|
||||||
|
|
||||||
|
// Changes apply right away
|
||||||
|
function saveFiles() {
|
||||||
|
s.uploadHost = s.uploadHost.trim().replace(/\/+$/, '');
|
||||||
|
s.uploadKey = s.uploadKey.trim();
|
||||||
|
s.embedHosts = embedHosts.split(/[\s,]+/).map(h => h.trim()).filter(Boolean);
|
||||||
|
store.saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function test() {
|
||||||
|
saveFiles();
|
||||||
|
testResult = 'Checking...';
|
||||||
|
try {
|
||||||
|
const info = await fetchUploadInfo(s.uploadHost, s.uploadKey);
|
||||||
|
testResult = `Key works. Allowed: ${info.allowed_extensions.join(', ')} (max ${Math.round(info.max_bytes / 1048576)} MB)`;
|
||||||
|
} catch (e) {
|
||||||
|
testResult = (e as Error).message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tab = 'voice' | 'files' | 'identities';
|
||||||
|
const tabs: { id: Tab; label: string }[] = [
|
||||||
|
{ id: 'voice', label: 'Voice' },
|
||||||
|
{ id: 'files', label: 'Chat and files' },
|
||||||
|
...(native ? [{ id: 'identities' as Tab, label: 'Identities' }] : [])
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal title="Settings" onclose={() => { saveFiles(); onclose(); }} width={640}>
|
||||||
|
<div class="tabs" role="tablist">
|
||||||
|
{#each tabs as t (t.id)}
|
||||||
|
<button role="tab" aria-selected={tab === t.id} class:active={tab === t.id} onclick={() => (tab = t.id)}>{t.label}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if tab === 'voice'}
|
||||||
|
<VoiceSettings />
|
||||||
|
{:else if tab === 'files'}
|
||||||
|
<p class="help">Files go to a f0ckm instance and are posted as links. Other mumh5 users see them inline; regular Mumble users get a link and a small preview. Without an upload host you can still send images, inline and scaled to the server's limit.</p>
|
||||||
|
<label for="s-host">Upload host</label>
|
||||||
|
<input id="s-host" bind:value={s.uploadHost} onchange={saveFiles} placeholder="https://f0ck.dev" />
|
||||||
|
<label for="s-key">Upload key</label>
|
||||||
|
<input id="s-key" type="password" bind:value={s.uploadKey} onchange={saveFiles} placeholder="cu_..." autocomplete="off" />
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<label for="s-exp">Files expire after</label>
|
||||||
|
<select id="s-exp" bind:value={s.uploadExpiry} onchange={saveFiles}>
|
||||||
|
<option value="1h">1 hour</option>
|
||||||
|
<option value="24h">24 hours</option>
|
||||||
|
<option value="7d">7 days</option>
|
||||||
|
<option value="30d">30 days</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button class="btn" disabled={!s.uploadHost || !s.uploadKey} onclick={test}>Test key</button>
|
||||||
|
</div>
|
||||||
|
{#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>
|
||||||
|
|
||||||
|
<h3>Embeds</h3>
|
||||||
|
<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>
|
||||||
|
<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-yt">YouTube links</label>
|
||||||
|
<select id="s-yt" bind:value={s.youtube} onchange={saveFiles}>
|
||||||
|
<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="off">Plain links</option>
|
||||||
|
</select>
|
||||||
|
{:else}
|
||||||
|
<p class="help">Servers recognize you by your certificate. The default is used unless a server is set to another one.</p>
|
||||||
|
<IdentityList />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#snippet footer()}
|
||||||
|
<button class="btn primary" onclick={() => { saveFiles(); onclose(); }}>Done</button>
|
||||||
|
{/snippet}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.tabs { display: flex; gap: 2px; margin: 8px 0 4px; border-bottom: 1px solid var(--line); overflow-x: auto; }
|
||||||
|
.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); }
|
||||||
|
h3 { margin: 22px 0 0; font-size: 14px; }
|
||||||
|
.help { margin: 10px 0 0; font-size: 13px; color: var(--text-dim); }
|
||||||
|
.row { display: flex; align-items: flex-end; gap: 8px; }
|
||||||
|
.row > div { flex: 1; }
|
||||||
|
.check { display: flex; align-items: center; gap: 8px; text-transform: none; font-weight: 400; letter-spacing: 0; font-size: 14px; color: var(--text); margin-top: 14px; }
|
||||||
|
.check input { width: auto; }
|
||||||
|
textarea { resize: vertical; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from './Icon.svelte';
|
||||||
|
import Avatar from './Avatar.svelte';
|
||||||
|
import ChannelNode from './ChannelNode.svelte';
|
||||||
|
import { session, sessions } from '../lib/session.svelte.ts';
|
||||||
|
import { ui } from '../lib/ui.svelte.ts';
|
||||||
|
import { menu } from '../lib/menu.svelte.ts';
|
||||||
|
import { userMenu } from '../lib/actions.ts';
|
||||||
|
import { voice } from '../lib/audio/voice.svelte.ts';
|
||||||
|
|
||||||
|
let { onnavigate }: { onnavigate: () => void } = $props();
|
||||||
|
|
||||||
|
const roots = $derived(session.childrenOf(null));
|
||||||
|
const self = $derived(session.self);
|
||||||
|
const cert = $derived(session.serverCert);
|
||||||
|
const trusted = $derived(!!cert?.authorized);
|
||||||
|
|
||||||
|
function openQuick(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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function showServerCert() {
|
||||||
|
if (!cert?.chain.length) return;
|
||||||
|
ui.cert = {
|
||||||
|
title: `Certificate of ${session.server?.host}`,
|
||||||
|
chain: cert.chain,
|
||||||
|
status: trusted
|
||||||
|
? { 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.` }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
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))) }))
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<aside class="sidebar">
|
||||||
|
<header class:trusted>
|
||||||
|
<button class="title" onclick={showServerCert} disabled={!cert} title={cert ? (trusted ? 'Valid certificate. Click to view' : 'Self-signed or untrusted certificate. Click to view') : ''}>
|
||||||
|
<strong>{#if cert}<Icon name={trusted ? 'lock' : 'shield'} size={14} />{/if}{session.server?.label ?? 'mumh5'}</strong>
|
||||||
|
<span class="status">
|
||||||
|
{#if session.status === 'connected'}
|
||||||
|
<span class="dot"></span>Connected{#if session.rtt} , {session.rtt} ms{/if}
|
||||||
|
{:else if session.status === 'connecting'}Connecting...
|
||||||
|
{:else}Not connected{/if}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{#if session.status !== 'idle'}
|
||||||
|
<button class="icon-btn" title="Disconnect" onclick={() => session.server && sessions.close(session.server.id)}><Icon name="power" size={18} /></button>
|
||||||
|
{/if}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="scroll">
|
||||||
|
{#if session.status === 'connected'}
|
||||||
|
<ul class="tree">
|
||||||
|
{#each roots as c (c.id)}<ChannelNode channel={c} {onnavigate} />{/each}
|
||||||
|
</ul>
|
||||||
|
{#if dms.length}
|
||||||
|
<h3>Direct messages</h3>
|
||||||
|
<ul class="tree">
|
||||||
|
{#each dms as dm (dm.key)}
|
||||||
|
<li>
|
||||||
|
<button class="dm" class:viewing={session.view === dm.key}
|
||||||
|
onclick={() => { session.selectView(dm.key); onnavigate(); }}>
|
||||||
|
<Avatar name={dm.user?.name ?? '?'} size={22} />
|
||||||
|
<span class="label" class:offline={!dm.user}>{dm.user?.name ?? 'Offline user'}</span>
|
||||||
|
{#if session.unread[dm.key]}<span class="badge">{session.unread[dm.key]}</span>{/if}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
{:else if session.error}
|
||||||
|
<p class="error">{session.error}</p>
|
||||||
|
{:else if session.status === 'idle'}
|
||||||
|
<p class="hint">Pick a server on the left, or add one with the plus button.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if self && sessions.voiceSession && sessions.voiceSession !== sessions.active}
|
||||||
|
<div class="elsewhere">
|
||||||
|
<span>Voice is on <strong>{sessions.voiceSession.server?.label}</strong>, deafened here</span>
|
||||||
|
<button class="btn" onclick={() => sessions.setVoice(sessions.active)}>Talk here</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if self}
|
||||||
|
<footer class="me">
|
||||||
|
<button class="profile" title="Your profile" onclick={() => { session.showUser(self.session); ui.panelOpen = true; }}
|
||||||
|
oncontextmenu={e => menu.show(e, userMenu(self))}>
|
||||||
|
<Avatar name={self.name} size={32} speaking={voice.transmitting} />
|
||||||
|
<span class="who">
|
||||||
|
<strong>{self.name}</strong>
|
||||||
|
<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>
|
||||||
|
</button>
|
||||||
|
<button class="icon-btn" class:on={self.selfMute} title={self.selfMute ? 'Unmute (right-click for microphone options)' : 'Mute (right-click for microphone options)'}
|
||||||
|
aria-label={self.selfMute ? 'Unmute' : 'Mute'}
|
||||||
|
onclick={() => session.setSelfMute(!self.selfMute)} oncontextmenu={e => openQuick(e, 'input')}>
|
||||||
|
<Icon name={self.selfMute ? 'mic-off' : 'mic'} size={18} />
|
||||||
|
</button>
|
||||||
|
<button class="icon-btn" class:on={self.selfDeaf} title={self.selfDeaf ? 'Undeafen (right-click for output options)' : 'Deafen (right-click for output options)'}
|
||||||
|
aria-label={self.selfDeaf ? 'Undeafen' : 'Deafen'}
|
||||||
|
onclick={() => session.setSelfDeaf(!self.selfDeaf)} oncontextmenu={e => openQuick(e, 'output')}>
|
||||||
|
<Icon name={self.selfDeaf ? 'headphones-off' : 'headphones'} size={18} />
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
{/if}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.sidebar { width: 260px; flex: none; display: flex; flex-direction: column; background: var(--bg-1); min-height: 0; }
|
||||||
|
header { height: 52px; flex: none; display: flex; align-items: center; gap: 8px; padding: 0 8px 0 0; border-bottom: 1px solid var(--line); }
|
||||||
|
header.trusted { background: #1d5c38; border-bottom-color: #2a7a4c; }
|
||||||
|
header.trusted .status, header.trusted .icon-btn { color: #cfeedd; }
|
||||||
|
header.trusted .dot { background: #fff; }
|
||||||
|
.title { flex: 1; min-width: 0; align-self: stretch; display: flex; flex-direction: column; justify-content: center; padding-left: 14px; text-align: left; }
|
||||||
|
.title:disabled { opacity: 1; }
|
||||||
|
.title:hover:not(:disabled) { background: rgb(255 255 255 / 0.05); }
|
||||||
|
.title strong { display: flex; align-items: center; gap: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.status { font-size: 12px; color: var(--text-dim); display: flex; align-items: center; gap: 6px; }
|
||||||
|
.dot { width: 8px; height: 8px; background: var(--speaking); }
|
||||||
|
.scroll { flex: 1; overflow-y: auto; padding: 8px 6px; }
|
||||||
|
ul { list-style: none; margin: 0; padding: 0; }
|
||||||
|
h3 { font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-faint); margin: 18px 8px 6px; }
|
||||||
|
.dm { display: flex; align-items: center; gap: 8px; width: 100%; padding: 4px 8px; color: var(--text-dim); text-align: left; }
|
||||||
|
.dm:hover { background: var(--bg-hover); color: var(--text); }
|
||||||
|
.dm.viewing { background: var(--bg-active); color: var(--text); }
|
||||||
|
.label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.offline { color: var(--text-faint); font-style: italic; }
|
||||||
|
.badge { background: var(--danger); color: #fff; font-size: 11px; font-weight: 700; padding: 0 5px; line-height: 16px; }
|
||||||
|
.hint, .error { padding: 8px; color: var(--text-dim); font-size: 14px; }
|
||||||
|
.error { color: var(--danger); }
|
||||||
|
.elsewhere { flex: none; display: flex; align-items: center; gap: 8px; padding: 8px 10px; font-size: 12px; color: var(--text-dim); background: var(--bg-0); border-top: 1px solid var(--line); }
|
||||||
|
.elsewhere span { flex: 1; min-width: 0; }
|
||||||
|
.elsewhere .btn { padding: 4px 10px; font-size: 12px; }
|
||||||
|
.me { flex: none; display: flex; align-items: center; gap: 8px; padding: 8px; background: var(--bg-0); }
|
||||||
|
.profile { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; padding: 2px; text-align: left; }
|
||||||
|
.profile:hover { background: var(--bg-hover); }
|
||||||
|
.who { flex: 1; min-width: 0; display: flex; flex-direction: column; line-height: 1.2; }
|
||||||
|
.who strong, .who span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.who span { font-size: 12px; color: var(--text-dim); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
import Modal from './Modal.svelte';
|
||||||
|
import { session } from '../lib/session.svelte.ts';
|
||||||
|
import { store } from '../lib/settings.svelte.ts';
|
||||||
|
import { ui } from '../lib/ui.svelte.ts';
|
||||||
|
import { native } from '../lib/native.ts';
|
||||||
|
import { formatBytes } from '../lib/html.ts';
|
||||||
|
import type { CertDetails } from '../core/transport.ts';
|
||||||
|
|
||||||
|
let { target, onclose }: { target: number; onclose: () => void } = $props();
|
||||||
|
// The dialog is remounted per user ({#key} in App), so the session is fixed for its lifetime
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
|
const who = target;
|
||||||
|
|
||||||
|
// The server fills in only what we may see: details for ourselves and admins,
|
||||||
|
// packet statistics for people in our channel, times and pings for everyone.
|
||||||
|
let stats = $state<any>(null);
|
||||||
|
let certs = $state<CertDetails[]>([]);
|
||||||
|
let updated = $state(0);
|
||||||
|
|
||||||
|
const client = session.client;
|
||||||
|
const user = $derived(session.user(who));
|
||||||
|
|
||||||
|
const off = client?.on('userStats', msg => {
|
||||||
|
if (msg.session !== who) return;
|
||||||
|
// Refreshes ask for statistics only; keep the details from the first answer
|
||||||
|
stats = stats && !msg.version ? { ...stats, ...msg, version: stats.version, certificates: stats.certificates, address: stats.address } : msg;
|
||||||
|
updated = Date.now();
|
||||||
|
if (msg.certificates?.length && !certs.length && native) {
|
||||||
|
native.describeCerts(msg.certificates).then(c => (certs = c));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
client?.requestUserStats(who);
|
||||||
|
const timer = setInterval(() => client?.requestUserStats(who, true), 5000);
|
||||||
|
onDestroy(() => { off?.(); clearInterval(timer); });
|
||||||
|
|
||||||
|
function duration(secs?: number): string {
|
||||||
|
if (secs == null) return 'unknown';
|
||||||
|
const d = Math.floor(secs / 86400), h = Math.floor(secs / 3600) % 24, m = Math.floor(secs / 60) % 60, s = secs % 60;
|
||||||
|
return [d && `${d}d`, (d || h) && `${h}h`, (d || h || m) && `${m}m`, `${s}s`].filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function address(bytes?: Uint8Array): string {
|
||||||
|
if (!bytes?.length) return '';
|
||||||
|
const b = [...bytes];
|
||||||
|
// IPv4-mapped IPv6 (::ffff:a.b.c.d)
|
||||||
|
if (b.length === 16 && b.slice(0, 10).every(x => x === 0) && b[10] === 255 && b[11] === 255) return b.slice(12).join('.');
|
||||||
|
const groups = [];
|
||||||
|
for (let i = 0; i < b.length; i += 2) groups.push(((b[i] << 8) | b[i + 1]).toString(16));
|
||||||
|
return groups.join(':').replace(/(^|:)0(:0)+(:|$)/, '::');
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = $derived.by(() => {
|
||||||
|
const v = stats?.version;
|
||||||
|
if (!v) return '';
|
||||||
|
const n = v.version_v2 ? Number(v.version_v2) : 0;
|
||||||
|
const num = n ? `${Math.floor(n / 2 ** 48)}.${Math.floor(n / 2 ** 32) % 65536}.${Math.floor(n / 2 ** 16) % 65536}`
|
||||||
|
: v.version_v1 ? `${v.version_v1 >> 16}.${(v.version_v1 >> 8) & 255}.${v.version_v1 & 255}` : '';
|
||||||
|
return [v.release, num && !String(v.release ?? '').includes(num) ? `(protocol ${num})` : ''].filter(Boolean).join(' ');
|
||||||
|
});
|
||||||
|
|
||||||
|
const ping = (avg?: number, v?: number) => avg ? `${avg.toFixed(1)} ms${v ? ` (variance ${Math.sqrt(v).toFixed(1)})` : ''}` : 'n/a';
|
||||||
|
const loss = (s: any) => {
|
||||||
|
const total = (s?.good ?? 0) + (s?.late ?? 0) + (s?.lost ?? 0);
|
||||||
|
return total ? `${(((s?.lost ?? 0) / total) * 100).toFixed(1)}%` : '-';
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal title="Information: {user ? store.displayName(user) : `#${target}`}" {onclose} width={560}>
|
||||||
|
{#if !stats}
|
||||||
|
<p class="dim">Asking the server...</p>
|
||||||
|
{:else}
|
||||||
|
<h3>Connection</h3>
|
||||||
|
<dl>
|
||||||
|
<dt>Online for</dt><dd>{duration(stats.onlinesecs)}</dd>
|
||||||
|
{#if stats.idlesecs != null}<dt>Idle for</dt><dd>{duration(stats.idlesecs)}</dd>{/if}
|
||||||
|
<dt>Ping (TCP)</dt><dd>{ping(stats.tcp_ping_avg, stats.tcp_ping_var)}</dd>
|
||||||
|
<dt>Ping (UDP)</dt><dd>{stats.udp_packets ? ping(stats.udp_ping_avg, stats.udp_ping_var) : 'not using UDP (voice over TCP)'}</dd>
|
||||||
|
<dt>Packets</dt><dd>{stats.tcp_packets ?? 0} TCP, {stats.udp_packets ?? 0} UDP</dd>
|
||||||
|
{#if stats.bandwidth != null}<dt>Bandwidth</dt><dd>{formatBytes(stats.bandwidth)}/s</dd>{/if}
|
||||||
|
{#if stats.address?.length}<dt>Address</dt><dd class="mono">{address(stats.address)}</dd>{/if}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{#if stats.version}
|
||||||
|
<h3>Client</h3>
|
||||||
|
<dl>
|
||||||
|
<dt>Version</dt><dd>{version || 'unknown'}</dd>
|
||||||
|
{#if stats.version.os}<dt>System</dt><dd>{stats.version.os} {stats.version.os_version ?? ''}</dd>{/if}
|
||||||
|
<dt>Opus</dt><dd>{stats.opus ? 'supported' : 'no (legacy codecs only)'}</dd>
|
||||||
|
</dl>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if stats.from_client || stats.from_server}
|
||||||
|
<h3>Voice packets</h3>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th></th><th>Good</th><th>Late</th><th>Lost</th><th>Resync</th><th>Loss</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><th>From client</th><td>{stats.from_client?.good ?? 0}</td><td>{stats.from_client?.late ?? 0}</td><td>{stats.from_client?.lost ?? 0}</td><td>{stats.from_client?.resync ?? 0}</td><td>{loss(stats.from_client)}</td></tr>
|
||||||
|
<tr><th>To client</th><td>{stats.from_server?.good ?? 0}</td><td>{stats.from_server?.late ?? 0}</td><td>{stats.from_server?.lost ?? 0}</td><td>{stats.from_server?.resync ?? 0}</td><td>{loss(stats.from_server)}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p class="dim small">Counted on the encrypted UDP channel; clients sending voice over TCP show zeros.</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if stats.certificates?.length}
|
||||||
|
<h3>Certificate</h3>
|
||||||
|
<dl>
|
||||||
|
<dt>Subject</dt><dd>{certs[0]?.subject.replace(/\n/g, ', ') ?? '...'}</dd>
|
||||||
|
<dt>Verified</dt><dd>{stats.strong_certificate ? 'yes, signed by a trusted authority' : 'no, self-signed'}</dd>
|
||||||
|
</dl>
|
||||||
|
{#if certs.length}
|
||||||
|
<button class="btn cert" onclick={() => (ui.cert = { title: `Certificate of ${user?.name ?? target}`, chain: certs })}>View certificate</button>
|
||||||
|
{/if}
|
||||||
|
{:else if user?.hash}
|
||||||
|
<h3>Certificate</h3>
|
||||||
|
<dl><dt>SHA-1</dt><dd class="mono">{user.hash}</dd></dl>
|
||||||
|
<p class="dim small">Only admins and the user themselves can see the full certificate.</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !stats.version}
|
||||||
|
<p class="dim small note">Client details, address and certificate are only shown to admins and to the user themselves.</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#snippet footer()}
|
||||||
|
{#if updated}<span class="dim small upd">Updates every 5 seconds</span>{/if}
|
||||||
|
<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); }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||||
|
th, td { padding: 4px 6px; text-align: right; border-bottom: 1px solid var(--line); }
|
||||||
|
th:first-child { text-align: left; color: var(--text-faint); font-weight: 400; }
|
||||||
|
thead th { color: var(--text-faint); font-weight: 600; }
|
||||||
|
.dim { color: var(--text-dim); margin: 0; }
|
||||||
|
.small { font-size: 12px; margin-top: 6px; }
|
||||||
|
.note { margin-top: 16px; }
|
||||||
|
.cert { margin-top: 10px; }
|
||||||
|
.upd { margin-right: auto; align-self: center; }
|
||||||
|
@media (max-width: 500px) { dl { grid-template-columns: 1fr; } dt { margin-top: 6px; } }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
import { voice, type TransmitMode } from '../lib/audio/voice.svelte.ts';
|
||||||
|
import { session } from '../lib/session.svelte.ts';
|
||||||
|
|
||||||
|
const s = voice.settings;
|
||||||
|
let capturing = $state(false);
|
||||||
|
|
||||||
|
// Opening the settings starts audio so the meter and device names work before connecting
|
||||||
|
voice.start();
|
||||||
|
onDestroy(() => { if (voice.testing) voice.setTesting(false); if (session.status !== 'connected') voice.stop(); });
|
||||||
|
|
||||||
|
const METER_MIN = -80;
|
||||||
|
const pct = (db: number) => Math.max(0, Math.min(100, ((db - METER_MIN) / -METER_MIN) * 100));
|
||||||
|
const open = $derived(voice.level > s.vadThreshold);
|
||||||
|
|
||||||
|
const keyName = (code: string) => code
|
||||||
|
.replace(/^Key/, '').replace(/^Digit/, '').replace(/^Numpad/, 'Num ')
|
||||||
|
.replace('Backquote', 'Backtick (`)').replace(/([a-z])([A-Z])/g, '$1 $2');
|
||||||
|
|
||||||
|
function captureKey(e: KeyboardEvent) {
|
||||||
|
if (!capturing) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.code !== 'Escape') voice.update({ pttKey: e.code });
|
||||||
|
capturing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modes: { id: TransmitMode; label: string; help: string }[] = [
|
||||||
|
{ id: 'vad', label: 'Voice activity', help: 'Sends when you speak louder than the threshold' },
|
||||||
|
{ id: 'ptt', label: 'Push to talk', help: 'Sends while you hold the key' },
|
||||||
|
{ id: 'continuous', label: 'Always on', help: 'Sends all the time' }
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydowncapture={captureKey} />
|
||||||
|
|
||||||
|
<div class="voice">
|
||||||
|
{#if voice.error}<p class="error">{voice.error}</p>{/if}
|
||||||
|
|
||||||
|
<h3>Devices</h3>
|
||||||
|
<div class="grid">
|
||||||
|
<div>
|
||||||
|
<label for="v-in">Microphone</label>
|
||||||
|
<select id="v-in" value={s.inputDevice} onchange={e => voice.update({ inputDevice: e.currentTarget.value })}>
|
||||||
|
{#each voice.inputDevices as d (d.id)}<option value={d.id}>{d.label}</option>{:else}<option value="default">System default</option>{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="v-out">Speakers / headphones</label>
|
||||||
|
<select id="v-out" value={s.outputDevice} onchange={e => voice.update({ outputDevice: e.currentTarget.value })}>
|
||||||
|
{#each voice.outputDevices as d (d.id)}<option value={d.id}>{d.label}</option>{:else}<option value="default">System default</option>{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Transmit</h3>
|
||||||
|
<div class="modes" role="radiogroup" aria-label="Transmit mode">
|
||||||
|
{#each modes as m (m.id)}
|
||||||
|
<button role="radio" aria-checked={s.mode === m.id} class:active={s.mode === m.id} onclick={() => voice.update({ mode: m.id })}>
|
||||||
|
<strong>{m.label}</strong><small>{m.help}</small>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if s.mode === 'ptt'}
|
||||||
|
<label for="v-ptt">Push-to-talk key</label>
|
||||||
|
<div class="row">
|
||||||
|
<button id="v-ptt" class="btn key" class:capturing onclick={() => (capturing = true)}>
|
||||||
|
{capturing ? 'Press a key... (Esc cancels)' : keyName(s.pttKey)}
|
||||||
|
</button>
|
||||||
|
<span class="hint" class:on={voice.pttDown}>{voice.pttDown ? 'Talking' : 'Hold the key to talk'}</span>
|
||||||
|
</div>
|
||||||
|
<p class="help">Works while mumh5 is focused. System-wide push to talk is planned.</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<label for="v-level">Input level{s.mode === 'vad' ? ' and threshold' : ''}</label>
|
||||||
|
<div class="meter" class:open={s.mode !== 'vad' || open} aria-hidden="true">
|
||||||
|
<div class="fill" style:width="{pct(voice.level)}%"></div>
|
||||||
|
{#if s.mode === 'vad'}<div class="mark" style:left="{pct(s.vadThreshold)}%"></div>{/if}
|
||||||
|
</div>
|
||||||
|
{#if s.mode === 'vad'}
|
||||||
|
<input id="v-level" type="range" min={METER_MIN} max="0" step="1" value={s.vadThreshold}
|
||||||
|
oninput={e => voice.update({ vadThreshold: Number(e.currentTarget.value) })} aria-label="Voice activity threshold" />
|
||||||
|
<p class="help">Speak normally and put the marker just below your voice; the bar turns green when you would be sending. Threshold {s.vadThreshold} dB.</p>
|
||||||
|
<label for="v-hold">Keep sending after speech: {s.vadHoldMs} ms</label>
|
||||||
|
<input id="v-hold" type="range" min="100" max="1500" step="50" value={s.vadHoldMs}
|
||||||
|
oninput={e => voice.update({ vadHoldMs: Number(e.currentTarget.value) })} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div>
|
||||||
|
<label for="v-gain">Microphone volume: {Math.round(s.inputGain * 100)}%</label>
|
||||||
|
<input id="v-gain" type="range" min="0" max="3" step="0.05" value={s.inputGain}
|
||||||
|
oninput={e => voice.update({ inputGain: Number(e.currentTarget.value) })} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="v-vol">Output volume: {Math.round(s.outputVolume * 100)}%</label>
|
||||||
|
<input id="v-vol" type="range" min="0" max="2" step="0.05" value={s.outputVolume}
|
||||||
|
oninput={e => voice.update({ outputVolume: Number(e.currentTarget.value) })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row test">
|
||||||
|
<button class="btn" class:primary={voice.testing} onclick={() => voice.setTesting(!voice.testing)}>
|
||||||
|
{voice.testing ? 'Stop test' : 'Test microphone'}
|
||||||
|
</button>
|
||||||
|
<span class="help">{voice.testing ? 'You should hear yourself. Use headphones to avoid feedback.' : 'Plays your microphone back to you'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Processing</h3>
|
||||||
|
<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.autoGainControl} onchange={e => voice.update({ autoGainControl: e.currentTarget.checked })} /> Automatic gain control</label>
|
||||||
|
|
||||||
|
<h3>Quality</h3>
|
||||||
|
<div class="grid">
|
||||||
|
<div>
|
||||||
|
<label for="v-br">Bitrate: {Math.round(s.bitrate / 1000)} kbit/s</label>
|
||||||
|
<input id="v-br" type="range" min="8000" max="128000" step="4000" value={s.bitrate}
|
||||||
|
oninput={e => voice.update({ bitrate: Number(e.currentTarget.value) })} />
|
||||||
|
{#if voice.effectiveBitrate && voice.effectiveBitrate < s.bitrate && session.status === 'connected'}
|
||||||
|
<p class="help warn">Limited to {Math.round(voice.effectiveBitrate / 1000)} kbit/s by this server</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="v-frame">Audio per packet</label>
|
||||||
|
<select id="v-frame" value={String(s.frameMs)} onchange={e => voice.update({ frameMs: Number(e.currentTarget.value) as 10 | 20 | 40 | 60 })}>
|
||||||
|
<option value="10">10 ms (lowest latency)</option>
|
||||||
|
<option value="20">20 ms (recommended)</option>
|
||||||
|
<option value="40">40 ms</option>
|
||||||
|
<option value="60">60 ms (least bandwidth)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label for="v-jit">Jitter buffer: {s.jitterMs} ms</label>
|
||||||
|
<input id="v-jit" type="range" min="20" max="300" step="10" value={s.jitterMs}
|
||||||
|
oninput={e => voice.update({ jitterMs: Number(e.currentTarget.value) })} />
|
||||||
|
<p class="help">Higher values smooth out bad connections at the cost of delay.</p>
|
||||||
|
|
||||||
|
{#if session.status === 'connected'}
|
||||||
|
<h3>Statistics</h3>
|
||||||
|
<p class="help stats">Sent {voice.stats.sent} packets, received {voice.stats.received}, decoded {voice.stats.decoded}, gaps {voice.stats.lost}. Transport: TCP tunnel.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
h3 { margin: 22px 0 0; font-size: 14px; }
|
||||||
|
h3:first-of-type { margin-top: 8px; }
|
||||||
|
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 12px; }
|
||||||
|
@media (max-width: 560px) { .grid { grid-template-columns: 1fr; } }
|
||||||
|
.modes { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin-top: 8px; }
|
||||||
|
@media (max-width: 560px) { .modes { grid-template-columns: 1fr; } }
|
||||||
|
.modes button { display: flex; flex-direction: column; gap: 2px; padding: 10px; text-align: left; background: var(--bg-0); border: 1px solid var(--line); }
|
||||||
|
.modes button.active { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
|
||||||
|
.modes small { color: var(--text-dim); font-size: 12px; }
|
||||||
|
.row { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.key { min-width: 160px; }
|
||||||
|
.key.capturing { border-color: var(--accent); color: var(--accent-hover); }
|
||||||
|
.hint { font-size: 13px; color: var(--text-dim); }
|
||||||
|
.hint.on { color: var(--speaking); font-weight: 600; }
|
||||||
|
.meter { position: relative; height: 12px; background: var(--bg-0); border: 1px solid var(--line); overflow: hidden; }
|
||||||
|
.fill { height: 100%; background: var(--text-faint); transition: width 50ms linear; }
|
||||||
|
.meter.open .fill { background: var(--speaking); }
|
||||||
|
.mark { position: absolute; top: -1px; bottom: -1px; width: 2px; background: var(--warn); }
|
||||||
|
input[type=range] { padding: 0; border: 0; background: transparent; accent-color: var(--accent); }
|
||||||
|
.test { 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: 10px; }
|
||||||
|
.check input { width: auto; }
|
||||||
|
.help { margin: 6px 0 0; font-size: 13px; color: var(--text-dim); }
|
||||||
|
.warn { color: var(--warn); }
|
||||||
|
.error { color: var(--danger); margin: 8px 0 0; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from './Icon.svelte';
|
||||||
|
import { store } from '../lib/settings.svelte.ts';
|
||||||
|
|
||||||
|
let { videoId, start = 0, url }: { videoId: string; start?: number; url: string } = $props();
|
||||||
|
|
||||||
|
// Nothing is loaded from YouTube until the user clicks (unless thumbnails are enabled)
|
||||||
|
let playing = $state(false);
|
||||||
|
const src = $derived(`https://www.youtube-nocookie.com/embed/${videoId}?autoplay=1&rel=0${start ? `&start=${start}` : ''}`);
|
||||||
|
const thumb = $derived(store.settings.youtube === 'thumbnail' ? `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg` : '');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="yt">
|
||||||
|
{#if playing}
|
||||||
|
<iframe {src} title="YouTube video" allow="autoplay; encrypted-media; picture-in-picture; fullscreen" allowfullscreen
|
||||||
|
referrerpolicy="strict-origin-when-cross-origin" sandbox="allow-scripts allow-same-origin allow-presentation allow-popups"></iframe>
|
||||||
|
{:else}
|
||||||
|
<button class="facade" style:background-image={thumb ? `url(${thumb})` : undefined} onclick={() => (playing = true)}
|
||||||
|
title="Play here (loads from YouTube)">
|
||||||
|
<span class="play"><svg width="28" height="28" viewBox="0 0 24 24" aria-hidden="true"><path d="M7 4l14 8-14 8z" fill="currentColor" /></svg></span>
|
||||||
|
<span class="label">
|
||||||
|
<strong>YouTube</strong>
|
||||||
|
<span>{start ? `from ${Math.floor(start / 60)}:${String(start % 60).padStart(2, '0')}, ` : ''}click to play</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<a class="open" href={url} target="_blank" rel="noopener noreferrer" title="Open on YouTube"><Icon name="link" size={14} /></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.yt { position: relative; width: min(480px, 100%); aspect-ratio: 16 / 9; background: #000; border: 1px solid var(--line); }
|
||||||
|
iframe { display: block; width: 100%; height: 100%; border: 0; }
|
||||||
|
.facade { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px;
|
||||||
|
background: #111 center / cover no-repeat; color: #fff; }
|
||||||
|
.facade::before { content: ''; position: absolute; inset: 0; background: linear-gradient(transparent 40%, rgb(0 0 0 / 0.7)); }
|
||||||
|
.play { position: relative; width: 64px; height: 44px; display: flex; align-items: center; justify-content: center; background: #e0443e; transition: transform 100ms; }
|
||||||
|
.facade:hover .play { transform: scale(1.08); }
|
||||||
|
.label { position: relative; display: flex; flex-direction: column; align-items: center; font-size: 13px; text-shadow: 0 1px 2px #000; }
|
||||||
|
.label span { color: #ddd; }
|
||||||
|
.open { position: absolute; top: 6px; right: 6px; width: 26px; height: 26px; display: flex; align-items: center; justify-content: center; background: rgb(0 0 0 / 0.6); color: #fff; }
|
||||||
|
.open:hover { background: rgb(0 0 0 / 0.85); text-decoration: none; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
|
export default { preprocess: vitePreprocess() };
|
||||||
@@ -0,0 +1,561 @@
|
|||||||
|
// Drives the built Electron app against a real Mumble server, with a headless second client.
|
||||||
|
// npm run build && MUMBLE_TEST_HOST=localhost:64739 npm run test:e2e
|
||||||
|
// Optional: UPLOAD_HOST + UPLOAD_KEY to also test file sharing, SHOTS_DIR to save screenshots.
|
||||||
|
import { _electron as electron } from 'playwright-core';
|
||||||
|
import electronPath from 'electron';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdtempSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import zlib from 'node:zlib';
|
||||||
|
import { createCodec } from '../../src/core/proto.ts';
|
||||||
|
import { encodeVoice, decodeVoice, type VoicePacket } from '../../src/core/voice-packet.ts';
|
||||||
|
import { MumbleClient } from '../../src/core/client.ts';
|
||||||
|
import type { Transport } from '../../src/core/transport.ts';
|
||||||
|
import { openTls } from '../../electron/tls-transport.ts';
|
||||||
|
import { generateIdentity } from '../../electron/identity.ts';
|
||||||
|
|
||||||
|
const target = process.env.MUMBLE_TEST_HOST;
|
||||||
|
if (!target) {
|
||||||
|
console.log('MUMBLE_TEST_HOST not set, skipping');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
const [host, port] = target.split(':');
|
||||||
|
const shots = process.env.SHOTS_DIR;
|
||||||
|
const root = path.resolve(import.meta.dirname, '../..');
|
||||||
|
|
||||||
|
function headless(username: string, password = ''): Promise<MumbleClient> {
|
||||||
|
const codec = createCodec();
|
||||||
|
const id = generateIdentity(username);
|
||||||
|
const client = new MumbleClient(codec);
|
||||||
|
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, password, os: 'test' });
|
||||||
|
return new Promise((res, rej) => { client.on('synced', () => res(client)); client.on('close', rej); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Noisy PNG, no image library needed. Noise keeps the JPEG preview big enough (over the
|
||||||
|
// server's 5000-char text limit) to exercise Murmur's XML check on image messages.
|
||||||
|
function testPng(w = 400, h = 300): Buffer {
|
||||||
|
const raw = Buffer.alloc((w * 3 + 1) * h);
|
||||||
|
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
|
||||||
|
raw.set([(x * 7 + Math.random() * 90) & 255, (y * 5 + Math.random() * 90) & 255, Math.random() * 255], y * (w * 3 + 1) + 1 + x * 3);
|
||||||
|
}
|
||||||
|
const chunk = (type: string, data: Buffer) => {
|
||||||
|
const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
|
||||||
|
const td = Buffer.concat([Buffer.from(type), data]);
|
||||||
|
const crc = Buffer.alloc(4); crc.writeUInt32BE(zlib.crc32(td));
|
||||||
|
return Buffer.concat([len, td, crc]);
|
||||||
|
};
|
||||||
|
const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr.set([8, 2, 0, 0, 0], 8);
|
||||||
|
return Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), chunk('IHDR', ihdr), chunk('IDAT', zlib.deflateSync(raw)), chunk('IEND', Buffer.alloc(0))]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fails with a label instead of hanging when an expected event never comes
|
||||||
|
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))]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ELECTRON_RUN_AS_NODE, ...env } = process.env;
|
||||||
|
const userData = mkdtempSync(path.join(tmpdir(), 'mumh5-e2e-'));
|
||||||
|
const app = await electron.launch({
|
||||||
|
executablePath: electronPath as unknown as string,
|
||||||
|
// Stay on $DISPLAY (e.g. Xvfb); Electron would otherwise pick the Wayland session automatically
|
||||||
|
// Fake microphone (a beeping tone) and auto-granted permission for the voice tests
|
||||||
|
args: [root, `--user-data-dir=${userData}`, '--ozone-platform=x11', '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'],
|
||||||
|
env: env as Record<string, string>
|
||||||
|
});
|
||||||
|
const bob = await headless('bob');
|
||||||
|
// 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;
|
||||||
|
try {
|
||||||
|
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 });
|
||||||
|
|
||||||
|
// First start: identity setup wizard
|
||||||
|
await page.getByRole('dialog', { name: 'Set up your identity' }).waitFor();
|
||||||
|
await page.getByRole('button', { name: /Create a new identity/ }).click();
|
||||||
|
await page.getByLabel('Name').fill('alice');
|
||||||
|
await page.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.getByRole('dialog', { name: 'Set up your identity' }).waitFor({ state: 'detached' });
|
||||||
|
console.log('ok: identity wizard creates an identity');
|
||||||
|
|
||||||
|
// Settings: upload host + key
|
||||||
|
if (process.env.UPLOAD_HOST && process.env.UPLOAD_KEY) {
|
||||||
|
await page.getByTitle('Settings').click();
|
||||||
|
await page.getByRole('tab', { name: 'Chat and files' }).click();
|
||||||
|
await page.getByLabel('Upload host').fill(process.env.UPLOAD_HOST);
|
||||||
|
await page.getByLabel('Upload key').fill(process.env.UPLOAD_KEY);
|
||||||
|
await page.getByRole('button', { name: 'Test key' }).click();
|
||||||
|
await page.getByText(/^Key works\./).waitFor();
|
||||||
|
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add and connect to the server
|
||||||
|
await page.getByTitle('Add a server').click();
|
||||||
|
await page.getByLabel('Address').fill(host);
|
||||||
|
await page.getByLabel('Port').fill(port);
|
||||||
|
await page.getByLabel('Username').fill('alice');
|
||||||
|
await page.getByLabel('Label').fill('Test Server');
|
||||||
|
await page.getByRole('button', { name: 'Save and connect' }).click();
|
||||||
|
await page.getByText(/Connected/).first().waitFor();
|
||||||
|
console.log('ok: connected');
|
||||||
|
|
||||||
|
// Bob is listed in the channel tree
|
||||||
|
await page.locator('.sidebar').getByText('bob').waitFor();
|
||||||
|
console.log('ok: sees bob');
|
||||||
|
|
||||||
|
// ─── Voice ───────────────────────────────────────────────────────────────
|
||||||
|
const aliceSession = () => [...bob.users.values()].find(u => u.name === 'alice')!.session;
|
||||||
|
const collect = (n: number, stopOnLast = false) => new Promise<VoicePacket[]>(res => {
|
||||||
|
const got: VoicePacket[] = [];
|
||||||
|
const off = bob.on('voice', raw => {
|
||||||
|
const p = decodeVoice(raw);
|
||||||
|
if (!p || p.session !== aliceSession()) return;
|
||||||
|
got.push(p);
|
||||||
|
if (got.length >= n || (stopOnLast && p.last)) { off(); res(got); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const setMode = async (label: string) => {
|
||||||
|
await page.getByTitle('Settings').click();
|
||||||
|
await page.getByRole('radio', { name: new RegExp(label) }).click();
|
||||||
|
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Always on: a steady stream of 20 ms Opus packets
|
||||||
|
const stream = collect(25);
|
||||||
|
await setMode('Always on');
|
||||||
|
const packets = await within(stream, 'voice packets from the app');
|
||||||
|
assert.ok(packets.every(p => p.opus.length > 0 && !p.last));
|
||||||
|
const steps = packets.slice(1).map((p, i) => p.frame - packets[i].frame);
|
||||||
|
assert.ok(steps.every(d => d === 2), `sequence advances 2 frames per 20 ms packet: ${steps.join(',')}`);
|
||||||
|
console.log(`ok: app sends Opus voice (${packets[0].opus.length} bytes per packet)`);
|
||||||
|
|
||||||
|
// Echo alice's own audio back as bob: the app decodes it and shows bob speaking
|
||||||
|
const echo = packets.map((p, i) => encodeVoice({ target: 0, frame: i * 2, opus: p.opus, last: i === packets.length - 1 }, bob.protobufVoice));
|
||||||
|
const bobRow = page.locator('.sidebar .user', { hasText: 'bob' });
|
||||||
|
const pump = setInterval(() => { const pk = echo.shift(); if (pk) bob.sendVoiceTunnel(pk); }, 20);
|
||||||
|
await bobRow.locator('.avatar.speaking').waitFor({ timeout: 3000 });
|
||||||
|
console.log('ok: incoming voice decoded, speaker highlighted');
|
||||||
|
await bobRow.locator('.avatar.speaking').waitFor({ state: 'detached', timeout: 3000 });
|
||||||
|
clearInterval(pump);
|
||||||
|
console.log('ok: speaking indicator clears after the terminator');
|
||||||
|
|
||||||
|
// Push to talk: silent until the key is held, terminator on release
|
||||||
|
const ended = collect(1000, true);
|
||||||
|
await setMode('Push to talk');
|
||||||
|
const tail = await within(ended, 'terminator after leaving always-on');
|
||||||
|
assert.equal(tail.at(-1)!.last, true);
|
||||||
|
let leaked = 0;
|
||||||
|
const offLeak = bob.on('voice', raw => { if (decodeVoice(raw)?.session === aliceSession()) leaked++; });
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
offLeak();
|
||||||
|
assert.equal(leaked, 0, 'no voice while the push-to-talk key is up');
|
||||||
|
await page.locator('.chat h1').click();
|
||||||
|
const ptt = collect(1000, true);
|
||||||
|
await page.keyboard.down('Backquote');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.keyboard.up('Backquote');
|
||||||
|
const burst = await within(ptt, 'push-to-talk burst');
|
||||||
|
assert.ok(burst.length >= 8 && burst.length <= 30, `about 300 ms of audio (${burst.length} packets)`);
|
||||||
|
assert.equal(burst.at(-1)!.last, true);
|
||||||
|
console.log(`ok: push to talk (${burst.length} packets, ends with terminator)`);
|
||||||
|
|
||||||
|
// Voice activity with the fake beeping microphone: transmits in bursts
|
||||||
|
await setMode('Voice activity');
|
||||||
|
const vad = await within(collect(10), 'voice activity packets', 8000);
|
||||||
|
assert.ok(vad.length === 10);
|
||||||
|
const biggest = Math.max(...vad.map(p => p.opus.length));
|
||||||
|
assert.ok(biggest > 40, `packets during the tone carry real audio (largest ${biggest} bytes)`);
|
||||||
|
console.log(`ok: voice activity detection transmits on the tone (up to ${biggest} bytes per packet)`);
|
||||||
|
|
||||||
|
// The app's own statistics confirm it decoded the echoed audio
|
||||||
|
await page.getByTitle('Settings').click();
|
||||||
|
const statsText = await page.locator('.stats').textContent();
|
||||||
|
const decoded = Number(/decoded (\d+)/.exec(statsText ?? '')?.[1]);
|
||||||
|
assert.ok(decoded >= 20, `decoded frames reported: ${statsText}`);
|
||||||
|
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
||||||
|
console.log(`ok: ${decoded} frames decoded`);
|
||||||
|
await setMode('Push to talk');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
// Alice -> channel
|
||||||
|
const gotText = new Promise<string>(res => bob.on('text', m => res(m.html)));
|
||||||
|
await page.getByLabel('Message').fill('hello <from> alice\nsecond line https://example.com/x');
|
||||||
|
await page.getByLabel('Message').press('Enter');
|
||||||
|
const html = await within(gotText, 'channel text');
|
||||||
|
assert.equal(html, 'hello <from> alice<br/>second line <a href="https://example.com/x">https://example.com/x</a>');
|
||||||
|
console.log('ok: bob received channel text');
|
||||||
|
|
||||||
|
// Bob -> channel, rendered sanitized
|
||||||
|
bob.sendText({ channels: [0] }, 'hi alice <b>bold</b><script>window.pwned=1</script><img src="https://evil.example/t.png">');
|
||||||
|
await page.locator('.msg', { hasText: 'hi alice' }).waitFor();
|
||||||
|
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('img[src^="https://evil"]').count(), 0);
|
||||||
|
console.log('ok: incoming text rendered and sanitized');
|
||||||
|
|
||||||
|
// YouTube links become a click-to-play card; nothing loads from YouTube before the click
|
||||||
|
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');
|
||||||
|
await card.getByText('from 1:30, click to play').waitFor();
|
||||||
|
assert.equal(await card.locator('iframe').count(), 0);
|
||||||
|
assert.equal(await card.locator('.facade').evaluate(el => getComputedStyle(el).backgroundImage), 'none', 'no thumbnail request by default');
|
||||||
|
await card.locator('.facade').click();
|
||||||
|
assert.equal(await card.locator('iframe').getAttribute('src'), 'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?autoplay=1&rel=0&start=90');
|
||||||
|
console.log('ok: YouTube link embedded as click-to-play');
|
||||||
|
|
||||||
|
// Bob -> DM
|
||||||
|
const alice = [...bob.users.values()].find(u => u.name === 'alice')!;
|
||||||
|
bob.sendText({ users: [alice.session] }, 'psst');
|
||||||
|
await page.locator('.sidebar').getByText('Direct messages').waitFor();
|
||||||
|
await page.locator('.dm', { hasText: 'bob' }).click();
|
||||||
|
await page.locator('.msg', { hasText: 'psst' }).waitFor();
|
||||||
|
console.log('ok: direct message shown in its own view');
|
||||||
|
await page.locator('.channel .name').first().dblclick();
|
||||||
|
|
||||||
|
// Mute toggles propagate
|
||||||
|
const muted = new Promise<boolean>(res => bob.on('user', (u, changed) => { if (u.name === 'alice' && changed.includes('selfMute')) res(u.selfMute); }));
|
||||||
|
await page.locator('.me').getByRole('button', { name: 'Mute', exact: true }).click();
|
||||||
|
assert.equal(await within(muted, 'mute state'), true);
|
||||||
|
await page.locator('.me').getByRole('button', { name: 'Unmute', exact: true }).waitFor();
|
||||||
|
|
||||||
|
// Right-click on mute / deafen opens quick audio options
|
||||||
|
await page.locator('.me').getByRole('button', { name: 'Unmute', exact: true }).click({ button: 'right' });
|
||||||
|
const micPop = page.getByRole('dialog', { name: 'Microphone' });
|
||||||
|
await micPop.getByLabel('Microphone').waitFor();
|
||||||
|
await micPop.getByRole('radio', { name: 'Always on' }).waitFor();
|
||||||
|
await micPop.getByLabel(/Input volume/).fill('1.5');
|
||||||
|
await micPop.getByText('Input volume: 150%').waitFor();
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await micPop.waitFor({ state: 'detached' });
|
||||||
|
await page.locator('.me').getByRole('button', { name: 'Deafen', exact: true }).click({ button: 'right' });
|
||||||
|
const outPop = page.getByRole('dialog', { name: 'Output' });
|
||||||
|
await outPop.getByLabel('Output device').waitFor();
|
||||||
|
await outPop.getByRole('button', { name: 'Voice settings...' }).click();
|
||||||
|
await page.getByRole('tab', { name: 'Voice', selected: true }).waitFor();
|
||||||
|
await page.getByText('Microphone volume: 150%').waitFor();
|
||||||
|
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
||||||
|
console.log('ok: quick audio options on right-click');
|
||||||
|
|
||||||
|
// Tray icon mirrors the voice state; its menu acts on it
|
||||||
|
const trayState = () => app.evaluate(() => (globalThis as any).__mumh5Tray && {
|
||||||
|
tooltip: (globalThis as any).__mumh5Tray.tooltip, items: (globalThis as any).__mumh5Tray.items, icon: (globalThis as any).__mumh5Tray.state.icon
|
||||||
|
});
|
||||||
|
const trayUntil = async (label: string, pred: (t: any) => boolean) => {
|
||||||
|
for (let i = 0; i < 50; i++) { const t = await trayState(); if (t && pred(t)) return t; await page.waitForTimeout(100); }
|
||||||
|
throw new Error(`Tray never reached: ${label} (${JSON.stringify(await trayState())})`);
|
||||||
|
};
|
||||||
|
const tMuted = await trayUntil('muted', t => /muted on Test Server/.test(t.tooltip));
|
||||||
|
assert.equal(tMuted.icon, 'data:image/png;base64,');
|
||||||
|
assert.equal(tMuted.items.find((i: any) => i.label === 'Mute').checked, true);
|
||||||
|
await app.evaluate(() => (globalThis as any).__mumh5Tray.click('Mute'));
|
||||||
|
await within(new Promise<void>(res => bob.on('user', (u, ch) => { if (u.name === 'alice' && ch.includes('selfMute') && !u.selfMute) res(); })), 'unmuted from the tray');
|
||||||
|
await trayUntil('connected', t => /connected on Test Server/.test(t.tooltip) && !t.items.find((i: any) => i.label === 'Mute').checked);
|
||||||
|
await app.evaluate(() => (globalThis as any).__mumh5Tray.click('Mute'));
|
||||||
|
await trayUntil('muted again', t => /muted/.test(t.tooltip));
|
||||||
|
console.log('ok: tray icon follows voice state and its menu mutes');
|
||||||
|
await page.locator('.channel + .users, .users').getByTitle('Muted', { exact: true }).first().waitFor();
|
||||||
|
console.log('ok: self mute propagated and shown');
|
||||||
|
|
||||||
|
if (process.env.UPLOAD_HOST) {
|
||||||
|
const png = path.join(userData, 'gradient.png');
|
||||||
|
writeFileSync(png, testPng());
|
||||||
|
const gotUpload = new Promise<string>(res => bob.on('text', m => res(m.html)));
|
||||||
|
await page.locator('.chat .composer input[type=file]').setInputFiles(png);
|
||||||
|
const up = await within(gotUpload, 'upload message');
|
||||||
|
assert.match(up, /<a href="http[^"]+\/cu\/[A-Za-z0-9_-]{12}\/gradient\.png">gradient\.png<\/a>/);
|
||||||
|
assert.match(up, /<br\/><img src="data:image\/jpeg;base64,[^"]+"\/>$/);
|
||||||
|
assert.ok(up.length > 5000, `preview should exceed the text limit (${up.length})`);
|
||||||
|
await page.locator('.embed img').waitFor();
|
||||||
|
await page.waitForFunction(() => (document.querySelector('.embed img') as HTMLImageElement)?.naturalWidth === 400);
|
||||||
|
console.log('ok: upload posted with vanilla thumbnail, embed loads for mumh5');
|
||||||
|
|
||||||
|
// Types the upload host does not allow are refused before uploading
|
||||||
|
const txt = path.join(userData, 'notes.txt');
|
||||||
|
writeFileSync(txt, 'plain text');
|
||||||
|
let leaked = false;
|
||||||
|
const off = bob.on('text', () => { leaked = true; });
|
||||||
|
await page.locator('.chat .composer input[type=file]').setInputFiles(txt);
|
||||||
|
await page.getByText('notes.txt: file type not allowed on this upload host').waitFor();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
off();
|
||||||
|
assert.equal(leaked, false);
|
||||||
|
console.log('ok: disallowed file type refused');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server certificate viewer from the header (the Docker server is self-signed, so no green header)
|
||||||
|
assert.equal(await page.locator('.sidebar header.trusted').count(), 0);
|
||||||
|
await page.locator('.sidebar header .title').click();
|
||||||
|
await page.getByRole('dialog', { name: /Certificate of/ }).getByText('self-signed').waitFor();
|
||||||
|
await page.getByRole('button', { name: 'Close', exact: true }).last().click();
|
||||||
|
console.log('ok: server certificate viewer');
|
||||||
|
|
||||||
|
// Own description: rich text with a picture, as Mumble-compatible XHTML
|
||||||
|
await page.locator('.me .profile').click();
|
||||||
|
await page.getByRole('button', { name: 'Edit description' }).click();
|
||||||
|
const area = page.locator('.rich .area');
|
||||||
|
await area.click();
|
||||||
|
await page.keyboard.type('I test things ');
|
||||||
|
await page.keyboard.press('Control+b');
|
||||||
|
await page.keyboard.type('bold');
|
||||||
|
await page.keyboard.press('Control+b');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.keyboard.type('second line');
|
||||||
|
const pic = path.join(userData, 'avatar.png');
|
||||||
|
writeFileSync(pic, testPng(300, 200));
|
||||||
|
await page.locator('.rich input[type=file]').setInputFiles(pic);
|
||||||
|
await area.locator('img').waitFor();
|
||||||
|
const aliceId = [...bob.users.values()].find(u => u.name === 'alice')!.session;
|
||||||
|
const commented = new Promise<string>(res => bob.on('user', (u, changed) => {
|
||||||
|
if (u.session !== aliceId) return;
|
||||||
|
if (changed.includes('comment') && u.comment) res(u.comment);
|
||||||
|
// Long comments are announced by hash only; fetch the text
|
||||||
|
else if (changed.includes('commentHash')) bob.requestBlob({ comments: [aliceId] });
|
||||||
|
}));
|
||||||
|
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||||
|
const comment = await within(commented, 'comment update');
|
||||||
|
assert.match(comment, /^<p>I test things <span style="font-weight:bold">bold<\/span><\/p><p>second line<img src="data:image\/jpeg;base64,[^"]+"\/><\/p>$/);
|
||||||
|
assert.ok(comment.length > 5000, 'long enough that the server ran its XML check and accepted it');
|
||||||
|
await page.locator('.panel .html img').waitFor();
|
||||||
|
console.log('ok: own description edited');
|
||||||
|
|
||||||
|
// Other user's profile and context menu
|
||||||
|
await page.locator('.sidebar .user', { hasText: 'bob' }).click({ button: 'right' });
|
||||||
|
for (const item of ['View profile', 'Send message', 'Server mute', 'Kick...', 'Ban...']) {
|
||||||
|
await page.getByRole('menuitem', { name: item }).waitFor();
|
||||||
|
}
|
||||||
|
await page.getByRole('menuitem', { name: 'View profile' }).click();
|
||||||
|
await page.locator('.panel h3', { hasText: 'bob' }).waitFor();
|
||||||
|
console.log('ok: user context menu and profile');
|
||||||
|
|
||||||
|
// Information: own connection shows client details and the full certificate
|
||||||
|
await page.locator('.sidebar .user.self').click({ button: 'right' });
|
||||||
|
await page.getByRole('menuitem', { name: 'Information' }).click();
|
||||||
|
const info = page.getByRole('dialog', { name: /^Information: alice/ });
|
||||||
|
await info.getByText('mumh5 0.1.0').waitFor();
|
||||||
|
await info.getByText('linux', { exact: false }).first().waitFor();
|
||||||
|
await info.getByText('Online for').waitFor();
|
||||||
|
await info.getByRole('button', { name: 'View certificate' }).click();
|
||||||
|
await page.getByRole('dialog', { name: /^Certificate of alice/ }).getByText('alice', { exact: true }).first().waitFor();
|
||||||
|
await page.getByRole('dialog', { name: /^Certificate of alice/ }).getByRole('button', { name: 'Close', exact: true }).last().click();
|
||||||
|
await info.getByRole('button', { name: 'Close', exact: true }).last().click();
|
||||||
|
console.log('ok: own connection information with certificate chain');
|
||||||
|
|
||||||
|
// Someone else as a normal user: times and pings, details withheld by the server
|
||||||
|
await page.locator('.sidebar .user', { hasText: 'bob' }).click({ button: 'right' });
|
||||||
|
await page.getByRole('menuitem', { name: 'Information' }).click();
|
||||||
|
const bobInfo = page.getByRole('dialog', { name: 'Information: bob' });
|
||||||
|
await bobInfo.getByText('Ping (TCP)').waitFor();
|
||||||
|
await bobInfo.getByText('only shown to admins', { exact: false }).first().waitFor();
|
||||||
|
assert.equal(await bobInfo.getByText('mumh5 0.1.0').count(), 0);
|
||||||
|
await bobInfo.getByRole('button', { name: 'Close', exact: true }).last().click();
|
||||||
|
console.log('ok: other user information respects server permissions');
|
||||||
|
|
||||||
|
if (admin) {
|
||||||
|
// Remove channels left over from an earlier run (the server refuses duplicate names)
|
||||||
|
for (const c of [...admin.channels.values()]) {
|
||||||
|
if (c.id !== 0 && c.parent === 0) admin.send('ChannelRemove', { channel_id: c.id });
|
||||||
|
}
|
||||||
|
await new Promise(r => setTimeout(r, 300));
|
||||||
|
|
||||||
|
// Channels: a long description (only sent as a hash), and a nested pair
|
||||||
|
const longDesc = 'Welcome to the lobby. ' + 'This description is long enough that the server only announces its hash. '.repeat(3);
|
||||||
|
const created = new Promise<void>(res => {
|
||||||
|
let n = 0;
|
||||||
|
admin.on('channel', c => { if (['Lobby', 'Games', 'Minecraft'].includes(c.name) && ++n === 3) res(); });
|
||||||
|
});
|
||||||
|
admin.send('ChannelState', { parent: 0, name: 'Lobby', description: longDesc });
|
||||||
|
admin.send('ChannelState', { parent: 0, name: 'Games' });
|
||||||
|
await new Promise(r => setTimeout(r, 300));
|
||||||
|
const games = [...admin.channels.values()].find(c => c.name === 'Games')!;
|
||||||
|
admin.send('ChannelState', { parent: games.id, name: 'Minecraft' });
|
||||||
|
await within(created, 'channels created');
|
||||||
|
|
||||||
|
await page.locator('.channel', { hasText: 'Games' }).waitFor();
|
||||||
|
assert.equal(await page.locator('.channel', { hasText: 'Minecraft' }).count(), 0, 'empty subchannels start collapsed');
|
||||||
|
console.log('ok: channels collapsed by default');
|
||||||
|
|
||||||
|
// Single click previews the description (fetched on demand), without joining
|
||||||
|
await page.locator('.channel .name', { hasText: 'Lobby' }).click();
|
||||||
|
await page.locator('.panel .html', { hasText: 'Welcome to the lobby' }).waitFor();
|
||||||
|
assert.equal(await page.locator('.me .who span').textContent(), 'Root');
|
||||||
|
console.log('ok: single click shows description without joining');
|
||||||
|
|
||||||
|
// Side chat sends to that channel while staying in Root
|
||||||
|
const lobby = [...bob.channels.values()].find(c => c.name === 'Lobby')!;
|
||||||
|
await page.locator('.panel [role=tab]', { hasText: 'Chat' }).click();
|
||||||
|
// Channel messages only reach people in that channel, so listen from inside Lobby
|
||||||
|
admin.joinChannel(lobby.id);
|
||||||
|
await new Promise(r => setTimeout(r, 300));
|
||||||
|
const sideMsg = new Promise<number[]>(res => admin.on('text', m => { if (m.html === 'side hello') res(m.channels); }));
|
||||||
|
await page.locator('.panel textarea').fill('side hello');
|
||||||
|
await page.locator('.panel textarea').press('Enter');
|
||||||
|
assert.deepEqual(await within(sideMsg, 'side chat message'), [lobby.id]);
|
||||||
|
await page.locator('.panel .msg', { hasText: 'side hello' }).waitFor();
|
||||||
|
assert.equal(await page.locator('.chat .msg', { hasText: 'side hello' }).count(), 0);
|
||||||
|
console.log('ok: side chat sends to the previewed channel');
|
||||||
|
|
||||||
|
// Double click joins; the joined channel opens up
|
||||||
|
await page.locator('.channel .name', { hasText: 'Games' }).dblclick();
|
||||||
|
await page.locator('.me .who span', { hasText: 'Games' }).waitFor();
|
||||||
|
await page.locator('.channel', { hasText: 'Minecraft' }).waitFor();
|
||||||
|
console.log('ok: double click joins and expands');
|
||||||
|
|
||||||
|
// Channel context menu
|
||||||
|
await page.locator('.channel .name', { hasText: 'Lobby' }).click({ button: 'right' });
|
||||||
|
await page.getByRole('menuitem', { name: 'Join channel' }).click();
|
||||||
|
await page.locator('.me .who span', { hasText: 'Lobby' }).waitFor();
|
||||||
|
console.log('ok: channel context menu joins');
|
||||||
|
|
||||||
|
// Side panel can be resized by dragging its left edge
|
||||||
|
const panelBox = (await page.locator('aside.panel').boundingBox())!;
|
||||||
|
const handle = (await page.locator('aside.panel .resize').boundingBox())!;
|
||||||
|
await page.mouse.move(handle.x + 4, handle.y + 200);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(handle.x - 150, handle.y + 200, { steps: 5 });
|
||||||
|
await page.mouse.up();
|
||||||
|
const grown = (await page.locator('aside.panel').boundingBox())!;
|
||||||
|
assert.ok(Math.abs(grown.width - (panelBox.width + 154)) < 6, `panel resized ${panelBox.width} -> ${grown.width}`);
|
||||||
|
console.log('ok: side panel resizable');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without an upload host, images are sent inline within Mumble's image limit
|
||||||
|
await page.locator('.channel .name', { hasText: 'Root' }).dblclick();
|
||||||
|
await page.locator('.me .who span', { hasText: 'Root' }).waitFor();
|
||||||
|
await page.getByTitle('Settings').click();
|
||||||
|
await page.getByRole('tab', { name: 'Chat and files' }).click();
|
||||||
|
await page.getByLabel('Upload host').fill('');
|
||||||
|
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
||||||
|
const big = path.join(userData, 'big.png');
|
||||||
|
writeFileSync(big, testPng(1600, 1200));
|
||||||
|
const gotInline = new Promise<string>(res => bob.on('text', m => { if (m.html.startsWith('<img')) res(m.html); }));
|
||||||
|
await page.locator('.chat .composer input[type=file]').setInputFiles(big);
|
||||||
|
const inline = await within(gotInline, 'inline image');
|
||||||
|
assert.match(inline, /^<img src="data:image\/jpeg;base64,[^"]+"\/>$/);
|
||||||
|
assert.ok(inline.length <= 131072, `inline image within the server limit (${inline.length})`);
|
||||||
|
console.log(`ok: inline image without upload host (${inline.length} chars)`);
|
||||||
|
|
||||||
|
// ─── Several servers at once ─────────────────────────────────────────────
|
||||||
|
const userState = (name: string) => [...bob.users.values()].find(u => u.name === name);
|
||||||
|
const until2 = (label: string, cond: () => boolean) => within(new Promise<void>(res => {
|
||||||
|
if (cond()) return res();
|
||||||
|
const off = bob.on('user', () => { if (cond()) { off(); res(); } });
|
||||||
|
}), label);
|
||||||
|
|
||||||
|
await page.getByTitle('Add a server').click();
|
||||||
|
await page.getByLabel('Address').fill(host);
|
||||||
|
await page.getByLabel('Port').fill(port);
|
||||||
|
await page.getByLabel('Username').fill('alice-b');
|
||||||
|
await page.getByLabel('Label').fill('Second');
|
||||||
|
await page.getByRole('button', { name: 'Save and connect' }).click();
|
||||||
|
await page.locator('.rail .tile.live').nth(1).waitFor();
|
||||||
|
assert.equal(await page.locator('.rail .tile.live').count(), 2, 'both servers connected');
|
||||||
|
await until2('background server shows deafened', () => !!userState('alice-b')?.selfDeaf);
|
||||||
|
assert.equal(await page.locator('.rail .tile.voice').getAttribute('title').then(t => t?.startsWith('Test Server')), true);
|
||||||
|
await page.getByText('Voice is on Test Server').waitFor();
|
||||||
|
console.log('ok: second server connected, deafened in the background');
|
||||||
|
|
||||||
|
// Joining a channel on the second server moves voice there
|
||||||
|
await page.locator('.channel .name', { hasText: 'Root' }).dblclick();
|
||||||
|
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);
|
||||||
|
console.log('ok: voice follows the channel you join');
|
||||||
|
|
||||||
|
// Messages on the server not on screen show as unread on its tile
|
||||||
|
bob.sendText({ channels: [0] }, 'ping both');
|
||||||
|
await page.locator('.rail .tile[title^="Test Server"] .unread').waitFor();
|
||||||
|
console.log('ok: unread badge on the background server');
|
||||||
|
|
||||||
|
// Reorder by dragging Second above Test Server
|
||||||
|
await page.locator('.rail .tile[title^="Second"]').dragTo(page.locator('.rail .tile[title^="Test Server"]'), { targetPosition: { x: 22, y: 4 } });
|
||||||
|
assert.match(await page.locator('.rail .tile').first().getAttribute('title') ?? '', /^Second/);
|
||||||
|
console.log('ok: servers reordered by drag and drop');
|
||||||
|
|
||||||
|
// Custom icon from the tile's context menu: square-cropped and shown instead of initials
|
||||||
|
const iconFile = path.join(userData, 'icon.png');
|
||||||
|
writeFileSync(iconFile, testPng(300, 200));
|
||||||
|
await page.locator('.rail .tile[title^="Second"]').click({ button: 'right' });
|
||||||
|
const chooser = page.waitForEvent('filechooser');
|
||||||
|
await page.getByRole('menuitem', { name: 'Change icon...' }).click();
|
||||||
|
await (await chooser).setFiles(iconFile);
|
||||||
|
const icon = page.locator('.rail .tile[title^="Second"] img.icon');
|
||||||
|
await icon.waitFor();
|
||||||
|
assert.deepEqual(await icon.evaluate((i: HTMLImageElement) => [i.naturalWidth, i.naturalHeight, i.src.slice(0, 15)]), [128, 128, 'data:image/webp']);
|
||||||
|
console.log('ok: custom server icon');
|
||||||
|
|
||||||
|
// Disconnecting the voice server hands voice back and restores the earlier state
|
||||||
|
await page.locator('.rail .tile[title^="Second"]').click({ button: 'right' });
|
||||||
|
await page.getByRole('menuitem', { name: 'Disconnect' }).click();
|
||||||
|
await until2('voice back on the first server', () => !userState('alice-b') && !userState('alice')?.selfDeaf);
|
||||||
|
assert.equal(userState('alice')?.selfMute, true, 'earlier self-mute kept');
|
||||||
|
await page.locator('.rail .tile[title^="Test Server"]').click();
|
||||||
|
await page.locator('.rail .tile[title^="Test Server"] .unread').waitFor({ state: 'detached' });
|
||||||
|
console.log('ok: voice returns when the other server disconnects');
|
||||||
|
|
||||||
|
// Change name: Mumble needs a reconnect, the app does it and remembers the name.
|
||||||
|
// Unique per run: a reused test server may still have an earlier run's name registered.
|
||||||
|
const newName = `alice${Date.now() % 100000}`;
|
||||||
|
await page.locator('.me .profile').click();
|
||||||
|
await page.getByRole('button', { name: 'Change name' }).click();
|
||||||
|
await page.getByLabel('New name').fill(newName);
|
||||||
|
await page.getByRole('button', { name: 'Reconnect with this name' }).click();
|
||||||
|
await page.locator('.me .who strong', { hasText: newName }).waitFor();
|
||||||
|
await within(new Promise<void>(res => {
|
||||||
|
if ([...bob.users.values()].some(u => u.name === newName)) return res();
|
||||||
|
bob.on('user', u => { if (u.name === newName) res(); });
|
||||||
|
}), 'bob sees the new name');
|
||||||
|
console.log('ok: name changed by reconnecting');
|
||||||
|
|
||||||
|
// Self-registration: guest badge becomes registered
|
||||||
|
await page.locator('.me .profile').click();
|
||||||
|
await page.locator('.panel .badge-reg', { hasText: 'Guest' }).waitFor();
|
||||||
|
await page.locator('.panel .register').click();
|
||||||
|
await page.locator('.panel .badge-reg.registered', { hasText: 'Registered' }).waitFor();
|
||||||
|
await page.locator('.sidebar .user', { hasText: newName }).locator('.reg').waitFor();
|
||||||
|
console.log('ok: registered on the server');
|
||||||
|
|
||||||
|
if (shots) {
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
for (const [name, w, h] of [['wide', 1280, 800], ['medium', 900, 700], ['narrow', 400, 780]] as const) {
|
||||||
|
await page.setViewportSize({ width: w, height: h });
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
await page.screenshot({ path: path.join(shots, `${name}.png`) });
|
||||||
|
}
|
||||||
|
await page.setViewportSize({ width: 1280, height: 900 });
|
||||||
|
await page.locator('.me').getByRole('button', { name: /^(Mute|Unmute)$/ }).click({ button: 'right' });
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.screenshot({ path: path.join(shots, 'quick-audio.png') });
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.locator('.me .profile').click();
|
||||||
|
await page.getByRole('button', { name: 'Edit description' }).click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.screenshot({ path: path.join(shots, 'editor.png') });
|
||||||
|
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||||
|
await page.getByTitle('Settings').click();
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
await page.screenshot({ path: path.join(shots, 'settings-voice.png') });
|
||||||
|
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
||||||
|
await page.locator('.sidebar .user', { hasText: 'bob' }).click();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
await page.screenshot({ path: path.join(shots, 'profile.png') });
|
||||||
|
await page.setViewportSize({ width: 400, height: 780 });
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
await page.getByLabel('Open channels').click();
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
await page.screenshot({ path: path.join(shots, 'narrow-drawer.png') });
|
||||||
|
}
|
||||||
|
console.log('E2E PASSED');
|
||||||
|
} catch (e) {
|
||||||
|
if (shots) await (await app.firstWindow()).screenshot({ path: path.join(shots, 'failure.png') });
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
bob.disconnect();
|
||||||
|
admin?.disconnect();
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
// Generates the README screenshots in docs/screenshots from a scripted scene.
|
||||||
|
// docker run -d --name mumh5-shots -p 64740:64738 -e MUMBLE_SUPERUSER_PASSWORD=shots \
|
||||||
|
// -e MUMBLE_CONFIG_AUTOBANATTEMPTS=0 -e "MUMBLE_CONFIG_WELCOMETEXT=Welcome to the community voice server." \
|
||||||
|
// mumblevoip/mumble-server
|
||||||
|
// npm run build && MUMBLE_TEST_HOST=localhost:64740 MUMBLE_SUPERUSER_PASSWORD=shots npm run screenshots
|
||||||
|
import { _electron as electron } from 'playwright-core';
|
||||||
|
import electronPath from 'electron';
|
||||||
|
import { mkdtempSync, mkdirSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { createCodec } from '../../src/core/proto.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;
|
||||||
|
const superPw = process.env.MUMBLE_SUPERUSER_PASSWORD;
|
||||||
|
if (!target || !superPw) {
|
||||||
|
console.log('Set MUMBLE_TEST_HOST and MUMBLE_SUPERUSER_PASSWORD');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const [host, port] = target.split(':');
|
||||||
|
const root = path.resolve(import.meta.dirname, '../..');
|
||||||
|
const out = path.join(root, 'docs', 'screenshots');
|
||||||
|
mkdirSync(out, { recursive: true });
|
||||||
|
const wait = (ms: number) => new Promise(r => setTimeout(r, ms));
|
||||||
|
|
||||||
|
function headless(username: string, password = ''): 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, password, os: 'Linux' });
|
||||||
|
return new Promise((res, rej) => { client.on('synced', () => res(client)); client.on('close', rej); });
|
||||||
|
}
|
||||||
|
|
||||||
|
const byName = (c: MumbleClient, name: string) => [...c.channels.values()].find(x => x.name === name)!;
|
||||||
|
|
||||||
|
// ─── Server scene ─────────────────────────────────────────────────────────────
|
||||||
|
const admin = await headless('SuperUser', superPw);
|
||||||
|
// Start from an empty tree when the server is reused
|
||||||
|
for (const c of [...admin.channels.values()]) if (c.id !== 0 && c.parent === 0) admin.send('ChannelRemove', { channel_id: c.id });
|
||||||
|
await wait(500);
|
||||||
|
admin.send('ChannelState', { parent: 0, name: 'Lobby', position: 0, description: '<p><b>Welcome!</b> Hang out, say hi, and drop your clips here.</p><p>Be nice. Push to talk is appreciated in busy rooms.</p>' });
|
||||||
|
admin.send('ChannelState', { parent: 0, name: 'Gaming', position: 1 });
|
||||||
|
admin.send('ChannelState', { parent: 0, name: 'Music', position: 2, description: 'Listening parties every Friday.' });
|
||||||
|
admin.send('ChannelState', { parent: 0, name: 'AFK', position: 3 });
|
||||||
|
await wait(1500);
|
||||||
|
admin.send('ChannelState', { parent: byName(admin, 'Gaming').id, name: 'Minecraft' });
|
||||||
|
admin.send('ChannelState', { parent: byName(admin, 'Gaming').id, name: 'Valorant' });
|
||||||
|
await wait(1500);
|
||||||
|
const lobby = byName(admin, 'Lobby').id;
|
||||||
|
admin.disconnect();
|
||||||
|
|
||||||
|
const sarah = await headless('sarah');
|
||||||
|
const max = await headless('max');
|
||||||
|
const jonas = await headless('jonas');
|
||||||
|
const lena = await headless('lena');
|
||||||
|
sarah.joinChannel(lobby);
|
||||||
|
max.joinChannel(lobby);
|
||||||
|
await wait(300);
|
||||||
|
jonas.joinChannel(byName(jonas, 'Minecraft').id);
|
||||||
|
lena.joinChannel(byName(lena, 'Music').id);
|
||||||
|
await wait(1200);
|
||||||
|
lena.setSelfMute(true);
|
||||||
|
max.setSelfDeaf(true);
|
||||||
|
sarah.setComment('<p><b>sarah</b> <span style="color:#e8b23c">streamer, night owl</span></p><p>Mostly playing <i>Minecraft</i> and <i>Valorant</i>. Ask me about modpacks.</p><ul><li>EU evenings</li><li>Push to talk</li></ul>');
|
||||||
|
|
||||||
|
// ─── App ──────────────────────────────────────────────────────────────────────
|
||||||
|
const { ELECTRON_RUN_AS_NODE, ...env } = process.env;
|
||||||
|
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', '--use-fake-ui-for-media-stream'],
|
||||||
|
env: env as Record<string, string>
|
||||||
|
});
|
||||||
|
const page = await app.firstWindow();
|
||||||
|
await page.setViewportSize({ width: 1440, height: 900 });
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Create a new identity/ }).click();
|
||||||
|
await page.getByLabel('Name').fill('kibi');
|
||||||
|
await page.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('Settings').click();
|
||||||
|
await page.getByRole('tab', { name: 'Chat and files' }).click();
|
||||||
|
await page.getByLabel('YouTube links').selectOption('thumbnail');
|
||||||
|
await page.getByRole('tab', { name: 'Voice' }).click();
|
||||||
|
await page.getByRole('radio', { name: /Always on/ }).click();
|
||||||
|
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
||||||
|
|
||||||
|
await page.getByTitle('Add a server').click();
|
||||||
|
await page.getByLabel('Address').fill(host);
|
||||||
|
await page.getByLabel('Port').fill(port);
|
||||||
|
await page.getByLabel('Username').fill('kibi');
|
||||||
|
await page.getByLabel('Label').fill('Community');
|
||||||
|
await page.getByRole('button', { name: 'Save and connect' }).click();
|
||||||
|
await page.getByText(/Connected/).first().waitFor();
|
||||||
|
await page.locator('.channel .name', { hasText: 'Lobby' }).dblclick();
|
||||||
|
await page.locator('.me .who span', { hasText: 'Lobby' }).waitFor();
|
||||||
|
await wait(800);
|
||||||
|
|
||||||
|
sarah.sendText({ channels: [lobby] }, 'anyone up for a round later?');
|
||||||
|
await wait(1200);
|
||||||
|
max.sendText({ channels: [lobby] }, 'yes, after dinner. 8pm?');
|
||||||
|
await wait(1200);
|
||||||
|
await page.getByLabel('Message').fill('works for me. meanwhile, a classic: https://www.youtube.com/watch?v=dQw4w9WgXcQ');
|
||||||
|
await page.getByLabel('Message').press('Enter');
|
||||||
|
await wait(1200);
|
||||||
|
sarah.sendText({ channels: [lobby] }, '<b>no.</b> not again <i>(ok it is a good song)</i>');
|
||||||
|
await wait(3000);
|
||||||
|
|
||||||
|
await page.screenshot({ path: path.join(out, 'main.png') });
|
||||||
|
|
||||||
|
await page.locator('.sidebar .user', { hasText: 'sarah' }).click();
|
||||||
|
await page.locator('.panel .html', { hasText: 'streamer' }).waitFor();
|
||||||
|
await wait(500);
|
||||||
|
await page.screenshot({ path: path.join(out, 'profile.png') });
|
||||||
|
|
||||||
|
await page.getByTitle('Settings').click();
|
||||||
|
await wait(800);
|
||||||
|
await page.screenshot({ path: path.join(out, 'voice-settings.png') });
|
||||||
|
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.locator('.scrim').first().click().catch(() => {});
|
||||||
|
await wait(500);
|
||||||
|
await page.screenshot({ path: path.join(out, 'mobile.png') });
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
for (const c of [sarah, max, jonas, lena]) c.disconnect();
|
||||||
|
console.log(`Screenshots written to ${out}`);
|
||||||
|
process.exit(0);
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdtempSync, mkdirSync, writeFileSync, statSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { generateIdentity, identityFromP12, identityToP12 } from '../electron/identity.ts';
|
||||||
|
import { IdentityStore } from '../electron/identity-store.ts';
|
||||||
|
|
||||||
|
const tmp = () => mkdtempSync(path.join(tmpdir(), 'mumh5-id-'));
|
||||||
|
|
||||||
|
test('PKCS#12 export and import round trip', () => {
|
||||||
|
const id = generateIdentity('roundtrip', 'rt@example.com');
|
||||||
|
const p12 = identityToP12(id, 'hunter22', 'roundtrip');
|
||||||
|
const back = identityFromP12(p12, 'hunter22');
|
||||||
|
assert.equal(back.fingerprint, id.fingerprint);
|
||||||
|
assert.throws(() => identityFromP12(p12, 'wrong'), /Wrong password/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('store migrates the legacy identity and still asks for setup', async () => {
|
||||||
|
const dir = tmp();
|
||||||
|
const legacy = generateIdentity('legacy user');
|
||||||
|
writeFileSync(path.join(dir, 'identity.json'), JSON.stringify(legacy));
|
||||||
|
const store = new IdentityStore(dir);
|
||||||
|
const { identities, setupDone } = await store.list();
|
||||||
|
assert.equal(setupDone, false);
|
||||||
|
assert.equal(identities.length, 1);
|
||||||
|
assert.equal(identities[0].fingerprint, legacy.fingerprint);
|
||||||
|
assert.equal(identities[0].name, 'legacy user');
|
||||||
|
assert.ok(identities[0].isDefault);
|
||||||
|
assert.equal(statSync(path.join(dir, 'identities.json')).mode & 0o077, 0, 'identities file is owner-only');
|
||||||
|
await store.finishSetup();
|
||||||
|
assert.equal((await new IdentityStore(dir).list()).setupDone, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('store create, import, duplicates, default and remove', async () => {
|
||||||
|
const store = new IdentityStore(tmp());
|
||||||
|
const a = await store.create('alice', '');
|
||||||
|
const other = generateIdentity('bob');
|
||||||
|
const b = await store.importP12(identityToP12(other, 'pw', 'bob'), 'pw', '');
|
||||||
|
assert.equal(b.name, 'bob');
|
||||||
|
await assert.rejects(store.importP12(identityToP12(other, 'x', 'bob'), 'x', ''), /already stored/);
|
||||||
|
assert.equal((await store.get()).id, a.id, 'first identity is the default');
|
||||||
|
assert.equal((await store.get(b.id)).fingerprint, other.fingerprint);
|
||||||
|
await store.setDefault(b.id);
|
||||||
|
await store.remove(b.id);
|
||||||
|
const { identities } = await store.list();
|
||||||
|
assert.deepEqual(identities.map(i => [i.name, i.isDefault]), [['alice', true]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('imports the desktop Mumble client certificate', async () => {
|
||||||
|
const cfg = tmp();
|
||||||
|
const mumble = generateIdentity('Mumble User');
|
||||||
|
mkdirSync(path.join(cfg, 'Mumble', 'Mumble'), { recursive: true });
|
||||||
|
const b64 = Buffer.from(identityToP12(mumble, '', 'Mumble User')).toString('base64');
|
||||||
|
writeFileSync(path.join(cfg, 'Mumble', 'Mumble', 'mumble_settings.json'), JSON.stringify({ certificate: b64 }));
|
||||||
|
const prev = { xdg: process.env.XDG_CONFIG_HOME, appdata: process.env.APPDATA, home: process.env.HOME };
|
||||||
|
process.env.XDG_CONFIG_HOME = cfg;
|
||||||
|
process.env.APPDATA = path.join(cfg, 'none');
|
||||||
|
process.env.HOME = path.join(cfg, 'none');
|
||||||
|
try {
|
||||||
|
const store = new IdentityStore(tmp());
|
||||||
|
assert.deepEqual(await store.findMumbleCertificate().then(f => f?.name), 'Mumble User');
|
||||||
|
const imported = await store.importFromMumble('');
|
||||||
|
assert.equal(imported.fingerprint, mumble.fingerprint);
|
||||||
|
} finally {
|
||||||
|
for (const [k, v] of Object.entries({ XDG_CONFIG_HOME: prev.xdg, APPDATA: prev.appdata, HOME: prev.home })) {
|
||||||
|
if (v === undefined) delete process.env[k]; else process.env[k] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createCodec, FrameReader } from '../src/core/proto.ts';
|
||||||
|
|
||||||
|
const codec = createCodec();
|
||||||
|
|
||||||
|
test('encode/decode roundtrip with framing split across chunks', () => {
|
||||||
|
const a = codec.encode('TextMessage', { channel_id: [0], message: 'hello <b>world</b>' });
|
||||||
|
const b = codec.encode('Ping', { timestamp: 1790796000000 });
|
||||||
|
const stream = new Uint8Array([...a, ...b]);
|
||||||
|
const reader = new FrameReader();
|
||||||
|
const out: any[] = [];
|
||||||
|
for (let i = 0; i < stream.length; i += 3) {
|
||||||
|
reader.push(stream.subarray(i, i + 3), (id, body) => out.push(codec.decode(id, body)));
|
||||||
|
}
|
||||||
|
assert.equal(out.length, 2);
|
||||||
|
assert.equal(out[0].name, 'TextMessage');
|
||||||
|
assert.equal(out[0].msg.message, 'hello <b>world</b>');
|
||||||
|
assert.deepEqual(out[0].msg.channel_id, [0]);
|
||||||
|
assert.equal(out[1].name, 'Ping');
|
||||||
|
assert.equal(out[1].msg.timestamp, 1790796000000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('encode rejects invalid payloads', () => {
|
||||||
|
assert.throws(() => codec.encode('ChannelRemove', { channel_id: 'nope' }));
|
||||||
|
});
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
// Integration test against a real Mumble server. Skipped unless MUMBLE_TEST_HOST is set, e.g.
|
||||||
|
// docker run -d -p 64739:64738 mumblevoip/mumble-server && MUMBLE_TEST_HOST=localhost:64739 npm test
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createCodec } from '../src/core/proto.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';
|
||||||
|
import { encodeVoice, decodeVoice } from '../src/core/voice-packet.ts';
|
||||||
|
|
||||||
|
const target = process.env.MUMBLE_TEST_HOST;
|
||||||
|
const codec = createCodec();
|
||||||
|
|
||||||
|
function connect(name: string): Promise<MumbleClient> {
|
||||||
|
const [host, port] = target!.split(':');
|
||||||
|
const id = generateIdentity(name);
|
||||||
|
const client = new MumbleClient(codec);
|
||||||
|
const transport: Transport = { onData: null, onClose: null, send: b => conn.send(b), close: () => conn.close() };
|
||||||
|
const conn = openTls(host, Number(port || 64738), id.certPem, id.keyPem, {
|
||||||
|
onSecure: () => {},
|
||||||
|
onData: c => transport.onData?.(c),
|
||||||
|
onClose: r => transport.onClose?.(r)
|
||||||
|
});
|
||||||
|
client.connect(transport, { username: name, os: 'test' });
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
client.on('synced', () => resolve(client));
|
||||||
|
client.on('close', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const until = <T>(fn: (resolve: (v: T) => void) => void, ms = 5000) =>
|
||||||
|
new Promise<T>((resolve, reject) => {
|
||||||
|
const t = setTimeout(() => reject(new Error('timeout')), ms);
|
||||||
|
fn(v => { clearTimeout(t); resolve(v); });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('two clients sync, see each other, exchange text, and update state', { skip: !target }, async () => {
|
||||||
|
const alice = await connect('alice' + Date.now() % 1000);
|
||||||
|
const bob = await connect('bob' + Date.now() % 1000);
|
||||||
|
try {
|
||||||
|
assert.ok(alice.channels.has(0), 'root channel present');
|
||||||
|
assert.ok(alice.session != null && bob.session != null);
|
||||||
|
|
||||||
|
// Alice learns about Bob via UserState
|
||||||
|
await until<void>(res => {
|
||||||
|
if (alice.users.has(bob.session!)) return res();
|
||||||
|
alice.on('user', u => { if (u.session === bob.session) res(); });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Channel text message
|
||||||
|
const got = until<string>(res => bob.on('text', m => res(m.html)));
|
||||||
|
alice.sendText({ channels: [0] }, 'hi from <b>alice</b>');
|
||||||
|
assert.equal(await got, 'hi from <b>alice</b>');
|
||||||
|
|
||||||
|
// Self-mute propagates to other clients
|
||||||
|
const muted = until<boolean>(res => bob.on('user', (u, changed) => {
|
||||||
|
if (u.session === alice.session && changed.includes('selfMute')) res(u.selfMute);
|
||||||
|
}));
|
||||||
|
alice.setSelfMute(true);
|
||||||
|
assert.equal(await muted, true);
|
||||||
|
|
||||||
|
// Plugin data between clients (used for rich chat)
|
||||||
|
const pd = until<string>(res => bob.on('pluginData', (s, id, data) => res(`${s}:${id}:${new TextDecoder().decode(data)}`)));
|
||||||
|
alice.sendPluginData([bob.session!], 'mumh5', new TextEncoder().encode('{"t":"typing"}'));
|
||||||
|
assert.equal(await pd, `${alice.session}:mumh5:{"t":"typing"}`);
|
||||||
|
|
||||||
|
// Disconnect is seen as UserRemove
|
||||||
|
const gone = until<number>(res => bob.on('userRemove', u => res(u.session)));
|
||||||
|
const aliceSession = alice.session;
|
||||||
|
alice.disconnect();
|
||||||
|
assert.equal(await gone, aliceSession);
|
||||||
|
} finally {
|
||||||
|
alice.disconnect();
|
||||||
|
bob.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('image messages over the text limit must be well-formed XML', { skip: !target }, async () => {
|
||||||
|
const alice = await connect('imgA' + Date.now() % 1000);
|
||||||
|
const bob = await connect('imgB' + Date.now() % 1000);
|
||||||
|
try {
|
||||||
|
// Longer than the default 5000-char text limit, under the 128 KB image limit
|
||||||
|
const img = `<img src="data:image/jpeg;base64,${'A'.repeat(8000)}"/>`;
|
||||||
|
|
||||||
|
const denied = until<number>(res => alice.on('permissionDenied', d => res(d.type)));
|
||||||
|
alice.sendText({ channels: [0] }, `<a href="https://x.example/f.png">f.png</a><br>${img}`);
|
||||||
|
assert.equal(await denied, 4, 'unclosed <br> is invalid XML, server denies as TextTooLong');
|
||||||
|
|
||||||
|
const got = until<string>(res => bob.on('text', m => res(m.html)));
|
||||||
|
const ok = `<a href="https://x.example/f.png">f.png</a><br/>${img}`;
|
||||||
|
alice.sendText({ channels: [0] }, ok);
|
||||||
|
assert.equal(await got, ok);
|
||||||
|
} finally {
|
||||||
|
alice.disconnect();
|
||||||
|
bob.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bursts of messages are paced instead of dropped by the server', { skip: !target }, async () => {
|
||||||
|
const alice = await connect('burstA' + Date.now() % 1000);
|
||||||
|
const bob = await connect('burstB' + Date.now() % 1000);
|
||||||
|
try {
|
||||||
|
const got: string[] = [];
|
||||||
|
const all = until<void>(res => bob.on('text', m => { got.push(m.html); if (got.length === 9) res(); }), 15000);
|
||||||
|
for (let i = 0; i < 9; i++) alice.sendText({ channels: [0] }, `msg ${i}`);
|
||||||
|
assert.ok(alice.queued > 0, 'some messages wait for the rate limit');
|
||||||
|
await all;
|
||||||
|
assert.deepEqual(got, Array.from({ length: 9 }, (_, i) => `msg ${i}`));
|
||||||
|
} finally {
|
||||||
|
alice.disconnect();
|
||||||
|
bob.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('voice packets are relayed through the TCP tunnel in the negotiated format', { skip: !target }, async () => {
|
||||||
|
const alice = await connect('voiceA' + Date.now() % 1000);
|
||||||
|
const bob = await connect('voiceB' + Date.now() % 1000);
|
||||||
|
try {
|
||||||
|
assert.equal(alice.protobufVoice, true, '1.5 server uses the protobuf voice format');
|
||||||
|
const frames: { session: number; frame: number; last: boolean; bytes: number }[] = [];
|
||||||
|
const done = until<void>(res => bob.on('voice', raw => {
|
||||||
|
const p = decodeVoice(raw);
|
||||||
|
if (!p) return;
|
||||||
|
frames.push({ session: p.session, frame: p.frame, last: p.last, bytes: p.opus.length });
|
||||||
|
if (p.last) res();
|
||||||
|
}));
|
||||||
|
// Opus TOC byte 0x78: SILK wideband 20 ms frame, followed by filler
|
||||||
|
const opus = new Uint8Array([0x78, ...new Array(40).fill(7)]);
|
||||||
|
for (let i = 0; i < 5; i++) alice.sendVoiceTunnel(encodeVoice({ target: 0, frame: i * 2, opus, last: i === 4 }, alice.protobufVoice));
|
||||||
|
await done;
|
||||||
|
assert.equal(frames.length, 5);
|
||||||
|
assert.ok(frames.every(f => f.session === alice.session && f.bytes === opus.length));
|
||||||
|
assert.deepEqual(frames.map(f => f.frame), [0, 2, 4, 6, 8]);
|
||||||
|
assert.equal(frames[4].last, true);
|
||||||
|
} finally {
|
||||||
|
alice.disconnect();
|
||||||
|
bob.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { writeVarint, readVarint, encodeVoice, decodeVoice, relayAsServer } from '../src/core/voice-packet.ts';
|
||||||
|
|
||||||
|
test('Mumble varints round trip at every size boundary', () => {
|
||||||
|
for (const v of [0, 1, 0x7f, 0x80, 0x3fff, 0x4000, 0x1fffff, 0x200000, 0xfffffff, 0x10000000, 0xffffffff, 0x100000000, 2 ** 40 + 5]) {
|
||||||
|
const out: number[] = [];
|
||||||
|
writeVarint(out, v);
|
||||||
|
assert.deepEqual(readVarint(new Uint8Array(out), 0), [v, out.length], `value ${v}`);
|
||||||
|
}
|
||||||
|
const small: number[] = [];
|
||||||
|
writeVarint(small, 300);
|
||||||
|
assert.deepEqual(small, [0x81, 0x2c], 'matches PacketDataStream encoding');
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const protobuf of [false, true]) {
|
||||||
|
test(`${protobuf ? 'protobuf' : 'legacy'} voice packets survive a server relay`, () => {
|
||||||
|
const opus = new Uint8Array([0xfc, 1, 2, 3, 250]);
|
||||||
|
for (const last of [false, true]) {
|
||||||
|
const sent = encodeVoice({ target: 0, frame: 1234, opus, last }, protobuf);
|
||||||
|
const got = decodeVoice(relayAsServer(sent, 42, protobuf));
|
||||||
|
assert.deepEqual(got, { session: 42, target: 0, frame: 1234, opus, last });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('legacy header layout: opus type, terminator bit in the size field', () => {
|
||||||
|
const p = encodeVoice({ target: 3, frame: 5, opus: new Uint8Array(10), last: true }, false);
|
||||||
|
assert.equal(p[0], (4 << 5) | 3);
|
||||||
|
assert.equal(p[1], 5);
|
||||||
|
assert.deepEqual([p[2], p[3]], [0x80 | 0x20, 10], 'size 10 | 0x2000 as a 2-byte varint');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-audio packets are ignored', () => {
|
||||||
|
assert.equal(decodeVoice(new Uint8Array([1, 2, 3])), null, 'protobuf ping');
|
||||||
|
assert.equal(decodeVoice(new Uint8Array([0x20, 5])), null, 'legacy ping');
|
||||||
|
assert.equal(decodeVoice(new Uint8Array([0x80, 1])), null, 'truncated');
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"extends": "@tsconfig/svelte/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["node", "svelte", "vite/client"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*", "electron/**/*", "test/**/*"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
base: './',
|
||||||
|
plugins: [svelte()],
|
||||||
|
build: { target: 'chrome120', outDir: 'dist', emptyOutDir: true }
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user