Merge pull request 'Screen sharing' (#5) from screen-sharing into main
Reviewed-on: #5
This commit was merged in pull request #5.
This commit is contained in:
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-web
|
||||||
|
dist-proxy
|
||||||
|
dist-electron
|
||||||
|
release
|
||||||
|
.git
|
||||||
|
docs
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
@@ -5,3 +5,4 @@ dist-proxy/
|
|||||||
dist-electron/
|
dist-electron/
|
||||||
release/
|
release/
|
||||||
*.log
|
*.log
|
||||||
|
.env
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ All notable changes to mumh5. Versions follow the `version` in `package.json`.
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- Screen sharing between mumh5 users in a channel: a dialog to choose the screen or window (with preview), the sound and the quality; sound from one program or the whole system on Linux through PipeWire. Viewers watch in the voice tiles, enlarge a stream like in a meeting and set its volume. Streams go directly between clients; a STUN server can be set in Settings, Voice.
|
||||||
- Browser version: `npm run build:web` builds the web app and a small self-hosted proxy that bridges browsers to Mumble servers on an allowlist. Identities are kept in the browser, voice goes through the TCP tunnel. See "Browser version" in the README.
|
- Browser version: `npm run build:web` builds the web app and a small self-hosted proxy that bridges browsers to Mumble servers on an allowlist. Identities are kept in the browser, voice goes through the TCP tunnel. See "Browser version" in the README.
|
||||||
- Link previews in chat: title, description and image for web links (up to two per message). By default they are fetched by your f0ckm upload host, so the linked sites never see your IP address. Can be switched to "fetched by this computer" or off in Settings, Chat and files.
|
- Link previews in chat: title, description and image for web links (up to two per message). By default they are fetched by your f0ckm upload host, so the linked sites never see your IP address. Can be switched to "fetched by this computer" or off in Settings, Chat and files.
|
||||||
- Voice tiles on small windows: when the window is too narrow for the member list, the people in your voice channel appear as tiles above the chat, light up while they talk, and keep mute and deafen at hand. The tiles can be collapsed.
|
- Voice tiles on small windows: when the window is too narrow for the member list, the people in your voice channel appear as tiles above the chat, light up while they talk, and keep mute and deafen at hand. The tiles can be collapsed.
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ npm run build:web # browser build to dist-web/, proxy bundle to dist-proxy/
|
|||||||
npm run dev:web # browser build with hot reload: starts the proxy (any server allowed) and Vite
|
npm run dev:web # browser build with hot reload: starts the proxy (any server allowed) and Vite
|
||||||
npm run proxy # run the proxy from source (MUMH5_SERVERS=host:port required)
|
npm run proxy # run the proxy from source (MUMH5_SERVERS=host:port required)
|
||||||
npm run test:e2e:web # drives the browser build through the proxy (needs npm run build:web first)
|
npm run test:e2e:web # drives the browser build through the proxy (needs npm run build:web first)
|
||||||
|
npm run test:e2e:share # screen sharing between two instances of the built app
|
||||||
npm run proto # regenerate src/core/mumble-pb.js and mumble-udp-pb.js from proto/
|
npm run proto # regenerate src/core/mumble-pb.js and mumble-udp-pb.js from proto/
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -42,10 +43,11 @@ A reused test server keeps registrations and channels from earlier runs; tests m
|
|||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
- `electron/` (Node, main process): window, TLS sockets to Mumble servers (`tls-transport.ts`), encrypted UDP voice (`udp-voice.ts`, `ocb2.ts`, tested against Mumble's OCB2 vectors), identities and PKCS#12 (`identity.ts`, `identity-store.ts`), certificate parsing (`certs.ts`), tray (`tray.ts`). The renderer only gets the narrow `window.mumh5Native` API from `preload.ts` (context isolation, sandbox).
|
- `electron/` (Node, main process): window, TLS sockets to Mumble servers (`tls-transport.ts`), encrypted UDP voice (`udp-voice.ts`, `ocb2.ts`, tested against Mumble's OCB2 vectors), identities and PKCS#12 (`identity.ts`, `identity-store.ts`), certificate parsing (`certs.ts`), tray (`tray.ts`). The renderer only gets the narrow `window.mumh5Native` API from `preload.ts` (context isolation, sandbox).
|
||||||
- `server/` (Node): the web proxy. `proxy.ts` serves `dist-web`, bridges WebSocket connections to Mumble over TLS (reusing `electron/tls-transport.ts`) and has stateless identity endpoints. It stores nothing; the browser keeps identities in localStorage and sends one with each connect.
|
- `server/` (Node): the web proxy. `proxy.ts` serves `dist-web`, bridges WebSocket connections to Mumble over TLS (reusing `electron/tls-transport.ts`) and has stateless identity endpoints. It stores nothing; the browser keeps identities in localStorage and sends one with each connect. It also answers STUN on UDP for screen sharing between browser users.
|
||||||
- `src/core/` (browser-safe TypeScript, also runs in Node for tests): framing and codec (`proto.ts`), the Mumble client state machine (`client.ts`), voice packet formats (`voice-packet.ts`). No DOM, no Electron, no Node imports here.
|
- `src/core/` (browser-safe TypeScript, also runs in Node for tests): framing and codec (`proto.ts`), the Mumble client state machine (`client.ts`), voice packet formats (`voice-packet.ts`). No DOM, no Electron, no Node imports here.
|
||||||
- `src/lib/native.ts`: `desktop` is the Electron preload API or null; `native` is what both platforms provide (identities, certificates), backed by `web.svelte.ts` in the browser build (`isWeb`, vite `--mode web`). Desktop-only features check `desktop`.
|
- `src/lib/native.ts`: `desktop` is the Electron preload API or null; `native` is what both platforms provide (identities, certificates), backed by `web.svelte.ts` in the browser build (`isWeb`, vite `--mode web`). Desktop-only features check `desktop`.
|
||||||
- `src/lib/`: app state. `session.svelte.ts` has one `Session` per server plus the `sessions` manager; `session` is a Proxy to the active one. `audio/voice.svelte.ts` is the voice engine (WebCodecs Opus, capture and playback AudioWorklets). `html.ts` sanitizes incoming HTML and serializes outgoing rich text.
|
- `src/lib/`: app state. `session.svelte.ts` has one `Session` per server plus the `sessions` manager; `session` is a Proxy to the active one. `audio/voice.svelte.ts` is the voice engine (WebCodecs Opus, capture and playback AudioWorklets). `html.ts` sanitizes incoming HTML and serializes outgoing rich text.
|
||||||
|
- Screen sharing: `src/core/share-signal.ts` (signals as Mumble plugin data, id `mumh5.share`, compressed and chunked) and `src/lib/share.svelte.ts` (one WebRTC connection per viewer, candidates inside the description, no trickle). The start dialog is `ShareDialog.svelte`, the tiles and the large view are `VoiceStage.svelte`. Desktop source listing is `share:sources` / `share:pick` in `electron/main.ts`; Linux sound is `electron/pipewire.ts` (a virtual microphone fed with pw-link, never with this app's own playback).
|
||||||
- `src/ui/`: Svelte components. `App.svelte` owns layout and global dialogs (`ui.svelte.ts` store).
|
- `src/ui/`: Svelte components. `App.svelte` owns layout and global dialogs (`ui.svelte.ts` store).
|
||||||
|
|
||||||
Voice runs on one server at a time (where you last joined a channel); background servers are auto self-deafened and restored when voice returns.
|
Voice runs on one server at a time (where you last joined a channel); background servers are auto self-deafened and restored when voice returns.
|
||||||
@@ -62,6 +64,7 @@ Voice runs on one server at a time (where you last joined a channel); background
|
|||||||
|
|
||||||
- The CSP allows media only from `self`, `blob:` and http(s). `data:` audio is blocked silently: play user files through object URLs. Large user files go to IndexedDB (`src/lib/blobstore.ts`), not localStorage.
|
- The CSP allows media only from `self`, `blob:` and http(s). `data:` audio is blocked silently: play user files through object URLs. Large user files go to IndexedDB (`src/lib/blobstore.ts`), not localStorage.
|
||||||
- `window.prompt` does not exist in Electron; use `ui.prompt`.
|
- `window.prompt` does not exist in Electron; use `ui.prompt`.
|
||||||
|
- `desktopCapturer.getSources` can return an empty list on its first calls under X11 and takes seconds on the bare Xvfb test display; main retries and the dialog has Refresh. With `--use-fake-device-for-media-stream` the captured picture is Chromium's test pattern, not the screen. On Wayland the call itself opens the system picker, so it is never repeated.
|
||||||
- A drop handler must read derived state before clearing the drag item it derives from.
|
- A drop handler must read derived state before clearing the drag item it derives from.
|
||||||
|
|
||||||
## Svelte reactivity pitfalls (all hit before)
|
## Svelte reactivity pitfalls (all hit before)
|
||||||
@@ -76,6 +79,7 @@ Voice runs on one server at a time (where you last joined a channel); background
|
|||||||
- Murmur silently drops own UserState, TextMessage, ChannelState, ACL and Version past a leaky bucket (burst 5, 1/s). The client paces these; don't bypass `send()`.
|
- Murmur silently drops own UserState, TextMessage, ChannelState, ACL and Version past a leaky bucket (burst 5, 1/s). The client paces these; don't bypass `send()`.
|
||||||
- 1.5 servers use the protobuf UDP voice format (type byte 0 + MumbleUDP.Audio) with clients announcing 1.5; older ones the legacy format. The UDPTunnel TCP body is the raw voice packet, not a protobuf message. Sequence numbers count 10 ms frames.
|
- 1.5 servers use the protobuf UDP voice format (type byte 0 + MumbleUDP.Audio) with clients announcing 1.5; older ones the legacy format. The UDPTunnel TCP body is the raw voice packet, not a protobuf message. Sequence numbers count 10 ms frames.
|
||||||
- Long comments and descriptions arrive as a hash only; a new hash invalidates the old text; fetch with RequestBlob.
|
- Long comments and descriptions arrive as a hash only; a new hash invalidates the old text; fetch with RequestBlob.
|
||||||
|
- Murmur drops PluginDataTransmission over 1000 bytes and rate limits it; keep packets at 900 bytes and few.
|
||||||
- Renaming while connected is not possible; mumh5 reconnects with the new name.
|
- Renaming while connected is not possible; mumh5 reconnects with the new name.
|
||||||
- Murmur never sends SuperUser (user id 0) PermissionQuery answers; treat SuperUser as allowed everything.
|
- Murmur never sends SuperUser (user id 0) PermissionQuery answers; treat SuperUser as allowed everything.
|
||||||
- In ACLs, Write overrides denies. Grant test rights to one user (`$<certhash>` group), not `@all`.
|
- In ACLs, Write overrides denies. Grant test rights to one user (`$<certhash>` group), not `@all`.
|
||||||
|
|||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
# The browser version: web app and proxy in one image.
|
||||||
|
# docker compose up -d --build
|
||||||
|
FROM node:22-slim AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
# No install scripts: the Electron download they would trigger is only needed for the desktop app
|
||||||
|
RUN npm ci --ignore-scripts
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build:web
|
||||||
|
|
||||||
|
FROM node:22-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /src/dist-web ./dist-web
|
||||||
|
COPY --from=build /src/dist-proxy ./dist-proxy
|
||||||
|
USER node
|
||||||
|
ENV MUMH5_PORT=8080
|
||||||
|
EXPOSE 8080/tcp 3478/udp
|
||||||
|
CMD ["node", "dist-proxy/proxy.mjs"]
|
||||||
@@ -76,6 +76,15 @@ mumh5 keeps the foundation and replaces the experience.
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
### Screen sharing
|
||||||
|
|
||||||
|
- Share a screen or a window with the mumh5 users in your channel. A dialog lets you choose what to show, with a live preview, which sound goes with it and the quality, before anything is sent
|
||||||
|
- Sound: on Linux one program or everything except mumh5 itself (through PipeWire, so viewers do not hear the voice chat twice); on Windows the whole system; in a browser what the browser offers
|
||||||
|
- People in the channel see an indicator next to your name, on your tile and in your profile, and click to watch. Your tile shows the picture; a click makes it large with everyone else in a strip below, like a meeting. Viewers set the stream's volume
|
||||||
|
- No server setup and no extra account: the setup messages travel through the Mumble server, the stream goes directly between the two clients (WebRTC), up to 8 viewers. Regular Mumble clients do not see streams
|
||||||
|
- Direct connections mean sharer and viewer see each other's IP address; mumh5 says so before the first use. Across the internet both sides need a STUN server. The browser version uses the one built into its proxy; in the desktop app you enter one in Settings, Voice (the proxy's address works there too), and none is contacted unless you do
|
||||||
|
- Tested between two desktop instances on one machine with a test picture. Sound capture, real screens, connections across the internet, Windows and the browser build are untested
|
||||||
|
|
||||||
### Chat
|
### Chat
|
||||||
- Messages grouped by author, direct messages, unread badges
|
- Messages grouped by author, direct messages, unread badges
|
||||||
- File sharing: drag and drop, paste, or the attach button, with upload progress
|
- File sharing: drag and drop, paste, or the attach button, with upload progress
|
||||||
@@ -159,6 +168,15 @@ MUMH5_SERVERS="mumble.example.com=My server" node dist-proxy/proxy.mjs
|
|||||||
|
|
||||||
Then open `http://127.0.0.1:8080`. For development, `npm run dev:web` starts the proxy and a hot-reloading page together, with any server allowed. To deploy, copy `dist-web/` and `dist-proxy/` next to each other on the server (Node 22 or newer, no `node_modules` needed) and put a reverse proxy with HTTPS in front that forwards WebSocket upgrades. Browsers only allow the microphone on HTTPS pages (or on localhost).
|
Then open `http://127.0.0.1:8080`. For development, `npm run dev:web` starts the proxy and a hot-reloading page together, with any server allowed. To deploy, copy `dist-web/` and `dist-proxy/` next to each other on the server (Node 22 or newer, no `node_modules` needed) and put a reverse proxy with HTTPS in front that forwards WebSocket upgrades. Browsers only allow the microphone on HTTPS pages (or on localhost).
|
||||||
|
|
||||||
|
With Docker, the same thing is one command; settings go in a `.env` file next to `docker-compose.yml`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo 'MUMH5_SERVERS=mumble.example.com=My server' > .env
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
The container uses the host's network: the proxy listens on `127.0.0.1:8080` for your reverse proxy and on UDP 3478 for STUN, and a Mumble server on the same machine is reachable as `localhost`.
|
||||||
|
|
||||||
| Variable | Default | Meaning |
|
| Variable | Default | Meaning |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `MUMH5_SERVERS` | none | Mumble servers people may connect to: `host[:port][=Label]`, comma-separated. Required unless `MUMH5_ALLOW_ANY=1` |
|
| `MUMH5_SERVERS` | none | Mumble servers people may connect to: `host[:port][=Label]`, comma-separated. Required unless `MUMH5_ALLOW_ANY=1` |
|
||||||
@@ -167,6 +185,7 @@ Then open `http://127.0.0.1:8080`. For development, `npm run dev:web` starts the
|
|||||||
| `MUMH5_ORIGINS` | same host | Origins allowed to use the API, comma-separated, when the page is hosted elsewhere |
|
| `MUMH5_ORIGINS` | same host | Origins allowed to use the API, comma-separated, when the page is hosted elsewhere |
|
||||||
| `MUMH5_TRUST_PROXY` | off | Take client addresses from `X-Forwarded-For` (set this behind a reverse proxy) |
|
| `MUMH5_TRUST_PROXY` | off | Take client addresses from `X-Forwarded-For` (set this behind a reverse proxy) |
|
||||||
| `MUMH5_SEND_PROXY` | off | Announce each visitor's address to the server with the PROXY protocol (see below). Breaks connections to a plain Mumble server |
|
| `MUMH5_SEND_PROXY` | off | Announce each visitor's address to the server with the PROXY protocol (see below). Breaks connections to a plain Mumble server |
|
||||||
|
| `MUMH5_STUN_PORT`, `MUMH5_STUN_BIND` | `3478`, all addresses | UDP port of the built-in STUN responder that lets browser users find a direct route for screen sharing. Open this UDP port in the firewall; it does not go through nginx. `0` turns it off |
|
||||||
| `MUMH5_STATIC` | `../dist-web` | Folder with the web build |
|
| `MUMH5_STATIC` | `../dist-web` | Folder with the web build |
|
||||||
| `MUMH5_MAX_CONNECTIONS`, `MUMH5_MAX_PER_ADDRESS` | `200`, `8` | Connection limits, in total and per client address |
|
| `MUMH5_MAX_CONNECTIONS`, `MUMH5_MAX_PER_ADDRESS` | `200`, `8` | Connection limits, in total and per client address |
|
||||||
|
|
||||||
@@ -254,7 +273,7 @@ Without an upload host, mumh5 still sends images, scaled to fit the server's lim
|
|||||||
- System-wide push to talk
|
- System-wide push to talk
|
||||||
- Whisper and shout
|
- Whisper and shout
|
||||||
- Rich chat between mumh5 users: replies, reactions, edits, typing indicators
|
- Rich chat between mumh5 users: replies, reactions, edits, typing indicators
|
||||||
- Screen sharing between mumh5 users in a channel, with sound (one application or the whole system, also on Linux through PipeWire). Anyone can start a stream; no extra key, your Mumble certificate is your identity. Streams go directly between clients with WebRTC, set up through the Mumble server, with an optional self-hosted relay for many viewers
|
- Screen sharing: several streams at once, and an optional self-hosted relay for many viewers
|
||||||
- Release builds for all platforms
|
- Release builds for all platforms
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# The mumh5 web proxy. Settings come from a .env file next to this one, for example:
|
||||||
|
# MUMH5_SERVERS=mumble.example.com=My server
|
||||||
|
# MUMH5_TRUST_PROXY=1
|
||||||
|
services:
|
||||||
|
mumh5:
|
||||||
|
build: .
|
||||||
|
restart: unless-stopped
|
||||||
|
# Host networking, so STUN sees each visitor's real address (through Docker's port
|
||||||
|
# forwarding it would often see Docker's own) and a Mumble server on this machine is
|
||||||
|
# reachable as localhost. The proxy listens on 127.0.0.1:8080 for nginx and on UDP 3478.
|
||||||
|
network_mode: host
|
||||||
|
env_file:
|
||||||
|
- path: .env
|
||||||
|
required: false
|
||||||
|
environment:
|
||||||
|
MUMH5_BIND: ${MUMH5_BIND:-127.0.0.1}
|
||||||
+61
-1
@@ -1,4 +1,4 @@
|
|||||||
import { app, BrowserWindow, dialog, ipcMain, net, shell, session, systemPreferences, type WebContents } from 'electron';
|
import { app, BrowserWindow, desktopCapturer, dialog, ipcMain, net, shell, session, systemPreferences, type WebContents } from 'electron';
|
||||||
import { promises as fs } from 'node:fs';
|
import { promises as fs } from 'node:fs';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
@@ -9,9 +9,15 @@ import { fetchLinkPreview } from './link-preview.ts';
|
|||||||
import { fetchPublicListWith, pingServer } from './publist.ts';
|
import { fetchPublicListWith, pingServer } from './publist.ts';
|
||||||
import { openTls } from './tls-transport.ts';
|
import { openTls } from './tls-transport.ts';
|
||||||
import { udpChannel } from './udp-voice.ts';
|
import { udpChannel } from './udp-voice.ts';
|
||||||
|
import { ShareAudio, pipewireAvailable, VIRTUAL_MIC_LABEL, type AudioTarget } from './pipewire.ts';
|
||||||
|
|
||||||
const devUrl = process.env.VITE_DEV_SERVER_URL;
|
const devUrl = process.env.VITE_DEV_SERVER_URL;
|
||||||
|
|
||||||
|
// Screen sharing connects mumh5 users directly. Chromium would hide this computer's addresses
|
||||||
|
// behind names that only resolve on the local network, so two people on IPv6 could not reach
|
||||||
|
// each other without a helper server. Only this app's own page runs here.
|
||||||
|
app.commandLine.appendSwitch('disable-features', 'WebRtcHideLocalIpsWithMdns');
|
||||||
|
|
||||||
let identityStore: IdentityStore | null = null;
|
let identityStore: IdentityStore | null = null;
|
||||||
const identities = () => (identityStore ??= new IdentityStore(app.getPath('userData')));
|
const identities = () => (identityStore ??= new IdentityStore(app.getPath('userData')));
|
||||||
|
|
||||||
@@ -120,6 +126,49 @@ ipcMain.handle('publist:ping', (_e, host: string, port: number) => pingServer(St
|
|||||||
|
|
||||||
ipcMain.handle('preview:fetch', (_e, url: string) => fetchLinkPreview(String(url).slice(0, 2048)));
|
ipcMain.handle('preview:fetch', (_e, url: string) => fetchLinkPreview(String(url).slice(0, 2048)));
|
||||||
|
|
||||||
|
// ─── Screen sharing ───────────────────────────────────────────────────────────
|
||||||
|
// The renderer lists the sources, the user picks one, and the next getDisplayMedia call gets it.
|
||||||
|
// On Wayland, listing already opens the system's picker and returns the one chosen source;
|
||||||
|
// `chosen` tells the renderer not to ask a second time.
|
||||||
|
let screenSources: Electron.DesktopCapturerSource[] = [];
|
||||||
|
let pickedSource: string | null = null;
|
||||||
|
const onWayland = () => process.platform === 'linux' && app.commandLine.getSwitchValue('ozone-platform') !== 'x11' &&
|
||||||
|
(process.env.XDG_SESSION_TYPE === 'wayland' || !!process.env.WAYLAND_DISPLAY);
|
||||||
|
|
||||||
|
// Sound: PipeWire on Linux (one program or everything but this app), system loopback on Windows
|
||||||
|
const shareAudio = new ShareAudio(() => app.getAppMetrics().map(m => m.pid));
|
||||||
|
ipcMain.handle('share:info', async () => ({
|
||||||
|
portal: onWayland(),
|
||||||
|
audio: process.platform === 'win32' ? 'loopback' : (await pipewireAvailable()) ? 'pipewire' : 'none',
|
||||||
|
micLabel: VIRTUAL_MIC_LABEL
|
||||||
|
}));
|
||||||
|
ipcMain.handle('share:audioApps', () => shareAudio.apps().catch(() => []));
|
||||||
|
ipcMain.handle('share:audioStart', (_e, target: AudioTarget) =>
|
||||||
|
shareAudio.start(target?.kind === 'app' ? { kind: 'app', name: String(target.name) } : { kind: 'all' }));
|
||||||
|
ipcMain.handle('share:audioStop', () => shareAudio.stop());
|
||||||
|
// The virtual microphone outlives the process otherwise
|
||||||
|
app.on('will-quit', () => { shareAudio.stop(); });
|
||||||
|
|
||||||
|
// Names first (fast and dependable), thumbnails in a second call: grabbing the pictures can be
|
||||||
|
// slow and, under X11, can come back with nothing.
|
||||||
|
ipcMain.handle('share:sources', async (_e, thumbnails: boolean) => {
|
||||||
|
const wayland = onWayland();
|
||||||
|
const size = thumbnails ? { width: 320, height: 180 } : { width: 0, height: 0 };
|
||||||
|
const list = () => desktopCapturer.getSources({ types: ['screen', 'window'], thumbnailSize: size });
|
||||||
|
let found = await list();
|
||||||
|
// Not on Wayland, where empty means the user cancelled the system's picker and asking again would reopen it
|
||||||
|
for (let i = 0; i < 3 && !found.length && !wayland; i++) {
|
||||||
|
await new Promise(r => setTimeout(r, 250));
|
||||||
|
found = await list();
|
||||||
|
}
|
||||||
|
if (found.length || !thumbnails) screenSources = found;
|
||||||
|
return {
|
||||||
|
chosen: wayland && found.length === 1,
|
||||||
|
sources: found.map(s => ({ id: s.id, name: s.name, thumbnail: s.thumbnail.isEmpty() ? '' : s.thumbnail.toDataURL() }))
|
||||||
|
};
|
||||||
|
});
|
||||||
|
ipcMain.on('share:pick', (_e, id: string) => { pickedSource = String(id); });
|
||||||
|
|
||||||
ipcMain.on('tray:update', (_e, state: tray.TrayState) => tray.update(state));
|
ipcMain.on('tray:update', (_e, state: tray.TrayState) => tray.update(state));
|
||||||
|
|
||||||
ipcMain.handle('platform:info', () => ({ os: process.platform, osVersion: os.release() }));
|
ipcMain.handle('platform:info', () => ({ os: process.platform, osVersion: os.release() }));
|
||||||
@@ -195,6 +244,17 @@ app.whenReady().then(async () => {
|
|||||||
cb({ requestHeaders: details.requestHeaders });
|
cb({ requestHeaders: details.requestHeaders });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
session.defaultSession.setDisplayMediaRequestHandler((request, callback) => {
|
||||||
|
const source = screenSources.find(s => s.id === pickedSource);
|
||||||
|
pickedSource = null;
|
||||||
|
// Without a picked source the request is refused
|
||||||
|
try {
|
||||||
|
if (!source) callback({});
|
||||||
|
else if (request.audioRequested && process.platform === 'win32') callback({ video: source, audio: 'loopback' });
|
||||||
|
else callback({ video: source });
|
||||||
|
} catch { /* refused */ }
|
||||||
|
});
|
||||||
|
|
||||||
// Microphone, camera and screen capture for voice and video; nothing else
|
// Microphone, camera and screen capture for voice and video; nothing else
|
||||||
session.defaultSession.setPermissionRequestHandler((_wc, permission, cb) => {
|
session.defaultSession.setPermissionRequestHandler((_wc, permission, cb) => {
|
||||||
cb(['media', 'display-capture', 'clipboard-sanitized-write', 'notifications'].includes(permission));
|
cb(['media', 'display-capture', 'clipboard-sanitized-write', 'notifications'].includes(permission));
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
// Sound for screen sharing on Linux. Chromium cannot capture what other programs play, so a
|
||||||
|
// virtual microphone is created in PipeWire and the chosen programs' output is linked into it;
|
||||||
|
// the renderer then records that microphone like any other. Works through PipeWire's command
|
||||||
|
// line tools (pw-cli, pw-dump, pw-link, pw-mon), no native module.
|
||||||
|
import { execFile, spawn, type ChildProcess } from 'node:child_process';
|
||||||
|
|
||||||
|
export const VIRTUAL_MIC = 'mumh5-share-audio';
|
||||||
|
export const VIRTUAL_MIC_LABEL = 'mumh5 screen share audio';
|
||||||
|
|
||||||
|
// What to capture: everything except this app's own sound, or one program
|
||||||
|
export type AudioTarget = { kind: 'all' } | { kind: 'app'; name: string };
|
||||||
|
|
||||||
|
interface PwObject { id: number; type: string; info?: { direction?: string; props?: Record<string, any> } }
|
||||||
|
|
||||||
|
function run(cmd: string, args: string[], maxBuffer = 64 * 1024 * 1024): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
execFile(cmd, args, { maxBuffer, timeout: 5000 }, (err, stdout) => err ? reject(err) : resolve(stdout));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const dump = async (): Promise<PwObject[]> => JSON.parse(await run('pw-dump', []));
|
||||||
|
|
||||||
|
// A playing program: its name and process come from the stream itself or from the client that owns it
|
||||||
|
function describe(node: PwObject, byId: Map<number, PwObject>): { name: string; pid: number | null } {
|
||||||
|
const p = node.info?.props ?? {};
|
||||||
|
const client = byId.get(p['client.id'])?.info?.props ?? {};
|
||||||
|
return {
|
||||||
|
name: String(p['application.name'] ?? client['application.name'] ?? p['node.name'] ?? ''),
|
||||||
|
pid: Number(p['application.process.id'] ?? client['application.process.id']) || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputs = (objects: PwObject[]) =>
|
||||||
|
objects.filter(o => o.type === 'PipeWire:Interface:Node' && o.info?.props?.['media.class'] === 'Stream/Output/Audio');
|
||||||
|
|
||||||
|
// Programs currently playing sound, without this app
|
||||||
|
export function audioApps(objects: PwObject[], ownPids: number[]): string[] {
|
||||||
|
const byId = new Map(objects.map(o => [o.id, o]));
|
||||||
|
const names = new Set<string>();
|
||||||
|
for (const node of outputs(objects)) {
|
||||||
|
const d = describe(node, byId);
|
||||||
|
if (d.name && !(d.pid && ownPids.includes(d.pid))) names.add(d.name);
|
||||||
|
}
|
||||||
|
return [...names].sort((a, b) => a.localeCompare(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which output ports to connect to which inputs of the virtual microphone, and which existing
|
||||||
|
// links to remove. This app's own playback is never linked: the people watching are in the same
|
||||||
|
// voice channel and would hear themselves back.
|
||||||
|
export function planLinks(objects: PwObject[], target: AudioTarget, ownPids: number[]): { add: [number, number][]; remove: number[] } {
|
||||||
|
const byId = new Map(objects.map(o => [o.id, o]));
|
||||||
|
const mic = objects.find(o => o.type === 'PipeWire:Interface:Node' && o.info?.props?.['node.name'] === VIRTUAL_MIC);
|
||||||
|
if (!mic) return { add: [], remove: [] };
|
||||||
|
const ports = objects.filter(o => o.type === 'PipeWire:Interface:Port');
|
||||||
|
const micIn = ports.filter(p => p.info?.props?.['node.id'] === mic.id && p.info?.direction === 'input');
|
||||||
|
const input = (channel: string) => micIn.find(p => p.info?.props?.['audio.channel'] === channel)?.id;
|
||||||
|
const left = input('FL'), right = input('FR');
|
||||||
|
if (left == null || right == null) return { add: [], remove: [] };
|
||||||
|
|
||||||
|
const wanted = new Set<string>();
|
||||||
|
for (const node of outputs(objects)) {
|
||||||
|
const d = describe(node, byId);
|
||||||
|
if (d.pid && ownPids.includes(d.pid)) continue;
|
||||||
|
if (target.kind === 'app' && d.name !== target.name) continue;
|
||||||
|
for (const port of ports) {
|
||||||
|
if (port.info?.props?.['node.id'] !== node.id || port.info?.direction !== 'output') continue;
|
||||||
|
const channel = port.info.props['audio.channel'];
|
||||||
|
// Mono programs go to both sides; surround beyond the front pair is left out
|
||||||
|
if (channel === 'FL' || channel === 'MONO') wanted.add(`${port.id}:${left}`);
|
||||||
|
if (channel === 'FR' || channel === 'MONO') wanted.add(`${port.id}:${right}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const remove: number[] = [];
|
||||||
|
for (const link of objects.filter(o => o.type === 'PipeWire:Interface:Link')) {
|
||||||
|
const info = link.info as any;
|
||||||
|
if (info?.['input-node-id'] !== mic.id) continue;
|
||||||
|
const key = `${info['output-port-id']}:${info['input-port-id']}`;
|
||||||
|
if (wanted.has(key)) wanted.delete(key);
|
||||||
|
else remove.push(link.id);
|
||||||
|
}
|
||||||
|
return { add: [...wanted].map(k => k.split(':').map(Number) as [number, number]), remove };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether PipeWire and its tools are there
|
||||||
|
export async function pipewireAvailable(): Promise<boolean> {
|
||||||
|
if (process.platform !== 'linux') return false;
|
||||||
|
try { await dump(); await run('pw-link', ['--version']); await run('pw-cli', ['--version']); return true; } catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ShareAudio {
|
||||||
|
private target: AudioTarget | null = null;
|
||||||
|
private monitor: ChildProcess | null = null;
|
||||||
|
private debounce: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
private busy: Promise<void> = Promise.resolve();
|
||||||
|
private ownPids: () => number[];
|
||||||
|
|
||||||
|
constructor(ownPids: () => number[]) { this.ownPids = ownPids; }
|
||||||
|
|
||||||
|
async apps(): Promise<string[]> {
|
||||||
|
return audioApps(await dump(), this.ownPids());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creates the microphone, links the target and keeps the links right while programs come and go
|
||||||
|
async start(target: AudioTarget): Promise<void> {
|
||||||
|
this.target = target;
|
||||||
|
if (!(await dump()).some(o => o.info?.props?.['node.name'] === VIRTUAL_MIC)) {
|
||||||
|
await run('pw-cli', ['create-node', 'adapter',
|
||||||
|
`{ factory.name=support.null-audio-sink node.name=${VIRTUAL_MIC} node.description="${VIRTUAL_MIC_LABEL}" media.class=Audio/Source/Virtual object.linger=1 audio.position=[FL,FR] audio.rate=48000 audio.channels=2 }`]);
|
||||||
|
// Give PipeWire a moment to publish the node and its ports
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const objects = await dump();
|
||||||
|
const mic = objects.find(o => o.info?.props?.['node.name'] === VIRTUAL_MIC);
|
||||||
|
if (mic && objects.some(o => o.type === 'PipeWire:Interface:Port' && o.info?.props?.['node.id'] === mic.id)) break;
|
||||||
|
await new Promise(r => setTimeout(r, 100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.relink();
|
||||||
|
if (!this.monitor) {
|
||||||
|
const proc = spawn('pw-mon', ['--color=never'], { stdio: ['ignore', 'pipe', 'ignore'] });
|
||||||
|
this.monitor = proc;
|
||||||
|
proc.stdout!.on('data', (data: Buffer) => {
|
||||||
|
const text = data.toString();
|
||||||
|
if (!text.includes('added') && !text.includes('removed')) return;
|
||||||
|
// Starting a program fires many events at once
|
||||||
|
clearTimeout(this.debounce);
|
||||||
|
this.debounce = setTimeout(() => this.relink().catch(() => {}), 400);
|
||||||
|
});
|
||||||
|
proc.on('error', () => { if (this.monitor === proc) this.monitor = null; });
|
||||||
|
proc.on('exit', () => { if (this.monitor === proc) this.monitor = null; });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One at a time, so two graph changes cannot interleave their link commands
|
||||||
|
private relink(): Promise<void> {
|
||||||
|
const next = this.busy.then(async () => {
|
||||||
|
if (!this.target) return;
|
||||||
|
const { add, remove } = planLinks(await dump(), this.target, this.ownPids());
|
||||||
|
for (const id of remove) await run('pw-link', ['-d', String(id)]).catch(() => {});
|
||||||
|
// "File exists" when a link is already there is fine
|
||||||
|
for (const [out, input] of add) await run('pw-link', [String(out), String(input)]).catch(() => {});
|
||||||
|
});
|
||||||
|
this.busy = next.catch(() => {});
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
this.target = null;
|
||||||
|
clearTimeout(this.debounce);
|
||||||
|
this.monitor?.kill();
|
||||||
|
this.monitor = null;
|
||||||
|
await this.busy;
|
||||||
|
try {
|
||||||
|
for (const o of await dump()) {
|
||||||
|
if (o.type === 'PipeWire:Interface:Node' && o.info?.props?.['node.name'] === VIRTUAL_MIC) await run('pw-cli', ['destroy', String(o.id)]).catch(() => {});
|
||||||
|
}
|
||||||
|
} catch { /* PipeWire gone */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,12 @@ contextBridge.exposeInMainWorld('mumh5Native', {
|
|||||||
onContextMenu: (fn: (params: unknown) => void) => { ipcRenderer.on('context-menu', (_e, p) => fn(p)); },
|
onContextMenu: (fn: (params: unknown) => void) => { ipcRenderer.on('context-menu', (_e, p) => fn(p)); },
|
||||||
editAction: (action: string, arg?: unknown) => ipcRenderer.send('edit:action', action, arg),
|
editAction: (action: string, arg?: unknown) => ipcRenderer.send('edit:action', action, arg),
|
||||||
onTrayAction: (fn: (action: string) => void) => { ipcRenderer.on('tray:action', (_e, a: string) => fn(a)); },
|
onTrayAction: (fn: (action: string) => void) => { ipcRenderer.on('tray:action', (_e, a: string) => fn(a)); },
|
||||||
|
screenSources: (thumbnails: boolean) => ipcRenderer.invoke('share:sources', !!thumbnails),
|
||||||
|
pickScreenSource: (id: string) => ipcRenderer.send('share:pick', id),
|
||||||
|
shareInfo: () => ipcRenderer.invoke('share:info'),
|
||||||
|
shareAudioApps: () => ipcRenderer.invoke('share:audioApps'),
|
||||||
|
shareAudioStart: (target: unknown) => ipcRenderer.invoke('share:audioStart', target),
|
||||||
|
shareAudioStop: () => ipcRenderer.invoke('share:audioStop'),
|
||||||
describeCerts: (ders: Uint8Array[]) => ipcRenderer.invoke('certs:describe', ders),
|
describeCerts: (ders: Uint8Array[]) => ipcRenderer.invoke('certs:describe', ders),
|
||||||
identities: {
|
identities: {
|
||||||
list: () => ipcRenderer.invoke('identities:list'),
|
list: () => ipcRenderer.invoke('identities:list'),
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"dist:mac": "npm run build && electron-builder --mac",
|
"dist:mac": "npm run build && electron-builder --mac",
|
||||||
"test:e2e": "node test/e2e/app.e2e.ts",
|
"test:e2e": "node test/e2e/app.e2e.ts",
|
||||||
"test:e2e:web": "node test/e2e/web.e2e.ts",
|
"test:e2e:web": "node test/e2e/web.e2e.ts",
|
||||||
|
"test:e2e:share": "node test/e2e/share.e2e.ts",
|
||||||
"proto": "pbjs -t static-module -w es6 --keep-case --no-delimited --no-service --no-comments --force-number proto/Mumble.proto -o src/core/mumble-pb.js && pbjs -t static-module -w es6 --keep-case --no-delimited --no-service --no-comments --force-number proto/MumbleUDP.proto -o src/core/mumble-udp-pb.js",
|
"proto": "pbjs -t static-module -w es6 --keep-case --no-delimited --no-service --no-comments --force-number proto/Mumble.proto -o src/core/mumble-pb.js && pbjs -t static-module -w es6 --keep-case --no-delimited --no-service --no-comments --force-number proto/MumbleUDP.proto -o src/core/mumble-udp-pb.js",
|
||||||
"screenshots": "node test/e2e/screenshots.ts"
|
"screenshots": "node test/e2e/screenshots.ts"
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ import { createServer } from 'vite';
|
|||||||
// The browser build for development: the web proxy on 127.0.0.1:8080 and the Vite dev server,
|
// The browser build for development: the web proxy on 127.0.0.1:8080 and the Vite dev server,
|
||||||
// which forwards /api to it (vite.config.ts). Without MUMH5_SERVERS the proxy allows any
|
// which forwards /api to it (vite.config.ts). Without MUMH5_SERVERS the proxy allows any
|
||||||
// server, private addresses included; it only listens on this machine.
|
// server, private addresses included; it only listens on this machine.
|
||||||
const env = { ...process.env, MUMH5_PORT: '8080', MUMH5_BIND: '127.0.0.1' };
|
const env = { MUMH5_STUN_BIND: '127.0.0.1', ...process.env, MUMH5_PORT: '8080', MUMH5_BIND: '127.0.0.1' };
|
||||||
if (!env.MUMH5_SERVERS && !env.MUMH5_ALLOW_ANY) Object.assign(env, { MUMH5_ALLOW_ANY: '1', MUMH5_ALLOW_PRIVATE: '1' });
|
if (!env.MUMH5_SERVERS && !env.MUMH5_ALLOW_ANY) Object.assign(env, { MUMH5_ALLOW_ANY: '1', MUMH5_ALLOW_PRIVATE: '1' });
|
||||||
|
|
||||||
const proxy = spawn(process.execPath, ['server/main.ts'], { stdio: 'inherit', env });
|
const proxy = spawn(process.execPath, ['server/main.ts'], { stdio: 'inherit', env });
|
||||||
|
|||||||
+2
-1
@@ -8,10 +8,11 @@ const config = configFromEnv(process.env);
|
|||||||
config.staticDir ??= [path.resolve(import.meta.dirname, '../dist-web')].find(d => existsSync(path.join(d, 'index.html'))) ?? null;
|
config.staticDir ??= [path.resolve(import.meta.dirname, '../dist-web')].find(d => existsSync(path.join(d, 'index.html'))) ?? null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { port } = await startProxy(config);
|
const { port, stunPort } = await startProxy(config);
|
||||||
console.log(`mumh5 proxy listening on http://${config.bind}:${port}`);
|
console.log(`mumh5 proxy listening on http://${config.bind}:${port}`);
|
||||||
console.log(config.allowAny ? `Allowed servers: any${config.allowPrivate ? ', private addresses included' : ' public address'}` : `Allowed servers: ${config.servers.map(s => `${s.host}:${s.port}`).join(', ')}`);
|
console.log(config.allowAny ? `Allowed servers: any${config.allowPrivate ? ', private addresses included' : ' public address'}` : `Allowed servers: ${config.servers.map(s => `${s.host}:${s.port}`).join(', ')}`);
|
||||||
if (config.sendProxy) console.log('Announcing client addresses with the PROXY protocol; the allowed servers must expect it');
|
if (config.sendProxy) console.log('Announcing client addresses with the PROXY protocol; the allowed servers must expect it');
|
||||||
|
console.log(stunPort ? `STUN for screen sharing on UDP port ${stunPort} (must be reachable from the internet)` : 'STUN is off; screen sharing between browser users will only work on the same network');
|
||||||
console.log(config.staticDir ? `Serving the web app from ${config.staticDir}` : 'No web build found (npm run build:web); serving the API only');
|
console.log(config.staticDir ? `Serving the web app from ${config.staticDir}` : 'No web build found (npm run build:web); serving the API only');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error((e as Error).message);
|
console.error((e as Error).message);
|
||||||
|
|||||||
+60
-3
@@ -4,6 +4,7 @@
|
|||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
import dns from 'node:dns';
|
import dns from 'node:dns';
|
||||||
import net from 'node:net';
|
import net from 'node:net';
|
||||||
|
import dgram from 'node:dgram';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { promises as fs } from 'node:fs';
|
import { promises as fs } from 'node:fs';
|
||||||
import { WebSocketServer, type WebSocket } from 'ws';
|
import { WebSocketServer, type WebSocket } from 'ws';
|
||||||
@@ -29,13 +30,16 @@ export interface ProxyConfig {
|
|||||||
// Only for servers behind something that understands it (go-mmproxy); plain Mumble does not.
|
// Only for servers behind something that understands it (go-mmproxy); plain Mumble does not.
|
||||||
sendProxy: boolean;
|
sendProxy: boolean;
|
||||||
staticDir: string | null;
|
staticDir: string | null;
|
||||||
|
// UDP port of the built-in STUN responder for screen sharing between browser users; null turns it off
|
||||||
|
stunPort: number | null;
|
||||||
|
stunBind: string;
|
||||||
maxConnections: number;
|
maxConnections: number;
|
||||||
maxPerAddress: number;
|
maxPerAddress: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const defaults: ProxyConfig = {
|
export const defaults: ProxyConfig = {
|
||||||
port: 8080, bind: '127.0.0.1', servers: [], allowAny: false, allowPrivate: false, origins: [],
|
port: 8080, bind: '127.0.0.1', servers: [], allowAny: false, allowPrivate: false, origins: [],
|
||||||
trustProxy: false, sendProxy: false, staticDir: null, maxConnections: 200, maxPerAddress: 8
|
trustProxy: false, sendProxy: false, staticDir: null, stunPort: null, stunBind: '::', maxConnections: 200, maxPerAddress: 8
|
||||||
};
|
};
|
||||||
|
|
||||||
// "host", "host:port", "[v6]:port", each optionally followed by "=Label"
|
// "host", "host:port", "[v6]:port", each optionally followed by "=Label"
|
||||||
@@ -64,6 +68,8 @@ export function configFromEnv(env: NodeJS.ProcessEnv): ProxyConfig {
|
|||||||
trustProxy: on(env.MUMH5_TRUST_PROXY),
|
trustProxy: on(env.MUMH5_TRUST_PROXY),
|
||||||
sendProxy: on(env.MUMH5_SEND_PROXY),
|
sendProxy: on(env.MUMH5_SEND_PROXY),
|
||||||
staticDir: env.MUMH5_STATIC ?? null,
|
staticDir: env.MUMH5_STATIC ?? null,
|
||||||
|
stunPort: Number(env.MUMH5_STUN_PORT ?? 3478) || null,
|
||||||
|
stunBind: env.MUMH5_STUN_BIND ?? defaults.stunBind,
|
||||||
maxConnections: Number(env.MUMH5_MAX_CONNECTIONS ?? defaults.maxConnections),
|
maxConnections: Number(env.MUMH5_MAX_CONNECTIONS ?? defaults.maxConnections),
|
||||||
maxPerAddress: Number(env.MUMH5_MAX_PER_ADDRESS ?? defaults.maxPerAddress)
|
maxPerAddress: Number(env.MUMH5_MAX_PER_ADDRESS ?? defaults.maxPerAddress)
|
||||||
};
|
};
|
||||||
@@ -93,6 +99,39 @@ const publicLookup: net.LookupFunction = (hostname, options, callback) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Answer to a STUN binding request (RFC 5389): tells the sender the address its packet came
|
||||||
|
// from, which is how two browsers behind routers find a direct route for screen sharing.
|
||||||
|
// Returns null for anything that is not a binding request. The answer is about as small as
|
||||||
|
// the request, so the port is of no use for amplifying traffic.
|
||||||
|
export function stunResponse(msg: Uint8Array, address: string, port: number): Uint8Array | null {
|
||||||
|
const COOKIE = 0x2112a442;
|
||||||
|
const view = new DataView(msg.buffer, msg.byteOffset, msg.byteLength);
|
||||||
|
if (msg.length < 20 || view.getUint16(0) !== 0x0001 || view.getUint32(4) !== COOKIE) return null;
|
||||||
|
if (view.getUint16(2) !== msg.length - 20) return null;
|
||||||
|
const v4 = address.replace(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i, '$1');
|
||||||
|
let bytes: number[];
|
||||||
|
if (net.isIPv4(v4)) bytes = v4.split('.').map(Number);
|
||||||
|
else if (net.isIPv6(address)) {
|
||||||
|
// Expand "::" and write the eight groups out as bytes
|
||||||
|
const [head, tail = ''] = address.split('%')[0].split('::');
|
||||||
|
const h = head ? head.split(':') : [], t = tail ? tail.split(':') : [];
|
||||||
|
const groups = address.includes('::') ? [...h, ...new Array(8 - h.length - t.length).fill('0'), ...t] : h;
|
||||||
|
bytes = groups.flatMap(g => { const n = parseInt(g, 16); return [n >> 8, n & 255]; });
|
||||||
|
} else return null;
|
||||||
|
const out = new Uint8Array(20 + 8 + bytes.length);
|
||||||
|
const o = new DataView(out.buffer);
|
||||||
|
o.setUint16(0, 0x0101); // binding success
|
||||||
|
o.setUint16(2, 8 + bytes.length);
|
||||||
|
out.set(msg.subarray(4, 20), 4); // cookie and transaction id
|
||||||
|
o.setUint16(20, 0x0020); // XOR-MAPPED-ADDRESS
|
||||||
|
o.setUint16(22, 4 + bytes.length);
|
||||||
|
out[25] = bytes.length === 4 ? 1 : 2;
|
||||||
|
o.setUint16(26, port ^ (COOKIE >>> 16));
|
||||||
|
// The address is masked with the cookie, and for IPv6 with the transaction id after it
|
||||||
|
for (let i = 0; i < bytes.length; i++) out[28 + i] = bytes[i] ^ msg[4 + i];
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
const TYPES: Record<string, string> = {
|
const TYPES: Record<string, string> = {
|
||||||
'.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8',
|
'.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8',
|
||||||
'.json': 'application/json', '.png': 'image/png', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.jpg': 'image/jpeg',
|
'.json': 'application/json', '.png': 'image/png', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.jpg': 'image/jpeg',
|
||||||
@@ -110,12 +149,13 @@ class HttpError extends Error {
|
|||||||
constructor(status: number, message: string) { super(message); this.status = status; }
|
constructor(status: number, message: string) { super(message); this.status = status; }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startProxy(config: ProxyConfig): Promise<{ port: number; close(): Promise<void> }> {
|
export async function startProxy(config: ProxyConfig): Promise<{ port: number; stunPort: number | null; close(): Promise<void> }> {
|
||||||
if (!config.allowAny && !config.servers.length) {
|
if (!config.allowAny && !config.servers.length) {
|
||||||
throw new Error('No servers allowed. Set MUMH5_SERVERS=host[:port][=Label],... or MUMH5_ALLOW_ANY=1.');
|
throw new Error('No servers allowed. Set MUMH5_SERVERS=host[:port][=Label],... or MUMH5_ALLOW_ANY=1.');
|
||||||
}
|
}
|
||||||
const staticDir = config.staticDir ? path.resolve(config.staticDir) : null;
|
const staticDir = config.staticDir ? path.resolve(config.staticDir) : null;
|
||||||
const perAddress = new Map<string, number>();
|
const perAddress = new Map<string, number>();
|
||||||
|
let stunPort: number | null = null;
|
||||||
// Identity requests per address in the current minute; key generation is the costly part
|
// Identity requests per address in the current minute; key generation is the costly part
|
||||||
const identityUse = new Map<string, number>();
|
const identityUse = new Map<string, number>();
|
||||||
const sweep = setInterval(() => identityUse.clear(), 60000);
|
const sweep = setInterval(() => identityUse.clear(), 60000);
|
||||||
@@ -154,7 +194,7 @@ export async function startProxy(config: ProxyConfig): Promise<{ port: number; c
|
|||||||
|
|
||||||
async function api(req: http.IncomingMessage, route: string): Promise<unknown> {
|
async function api(req: http.IncomingMessage, route: string): Promise<unknown> {
|
||||||
if (route === 'config' && req.method === 'GET') {
|
if (route === 'config' && req.method === 'GET') {
|
||||||
return { servers: config.servers, any: config.allowAny };
|
return { servers: config.servers, any: config.allowAny, stun: stunPort };
|
||||||
}
|
}
|
||||||
if (req.method !== 'POST') throw new HttpError(404, 'Not found');
|
if (req.method !== 'POST') throw new HttpError(404, 'Not found');
|
||||||
if (!originOk(req)) throw new HttpError(403, 'Origin not allowed');
|
if (!originOk(req)) throw new HttpError(403, 'Origin not allowed');
|
||||||
@@ -305,10 +345,27 @@ export async function startProxy(config: ProxyConfig): Promise<{ port: number; c
|
|||||||
server.once('error', reject);
|
server.once('error', reject);
|
||||||
server.listen(config.port, config.bind, resolve);
|
server.listen(config.port, config.bind, resolve);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// STUN on UDP. Failing to open it (port taken, no permission) only costs screen sharing its helper.
|
||||||
|
let stun: dgram.Socket | null = null;
|
||||||
|
if (config.stunPort != null) {
|
||||||
|
const socket = dgram.createSocket(net.isIPv4(config.stunBind) ? 'udp4' : 'udp6');
|
||||||
|
socket.on('message', (msg, from) => {
|
||||||
|
const answer = stunResponse(msg, from.address, from.port);
|
||||||
|
if (answer) socket.send(answer, from.port, from.address);
|
||||||
|
});
|
||||||
|
await new Promise<void>(resolve => {
|
||||||
|
socket.once('error', () => { socket.close(); resolve(); });
|
||||||
|
socket.bind(config.stunPort!, config.stunBind, () => { socket.removeAllListeners('error'); socket.on('error', () => {}); stun = socket; stunPort = socket.address().port; resolve(); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
port: (server.address() as net.AddressInfo).port,
|
port: (server.address() as net.AddressInfo).port,
|
||||||
|
stunPort,
|
||||||
close: () => new Promise<void>(resolve => {
|
close: () => new Promise<void>(resolve => {
|
||||||
clearInterval(sweep);
|
clearInterval(sweep);
|
||||||
|
(stun as dgram.Socket | null)?.close();
|
||||||
for (const ws of wss.clients) ws.terminate();
|
for (const ws of wss.clients) ws.terminate();
|
||||||
wss.close();
|
wss.close();
|
||||||
server.close(() => resolve());
|
server.close(() => resolve());
|
||||||
|
|||||||
+4
-1
@@ -15,6 +15,8 @@
|
|||||||
import ChannelDialog from './ui/ChannelDialog.svelte';
|
import ChannelDialog from './ui/ChannelDialog.svelte';
|
||||||
import PublicServers from './ui/PublicServers.svelte';
|
import PublicServers from './ui/PublicServers.svelte';
|
||||||
import ServerInfoDialog from './ui/ServerInfoDialog.svelte';
|
import ServerInfoDialog from './ui/ServerInfoDialog.svelte';
|
||||||
|
import ShareDialog from './ui/ShareDialog.svelte';
|
||||||
|
import { share } from './lib/share.svelte.ts';
|
||||||
import ResizeHandle from './ui/ResizeHandle.svelte';
|
import ResizeHandle from './ui/ResizeHandle.svelte';
|
||||||
import { identities } from './lib/identities.svelte.ts';
|
import { identities } from './lib/identities.svelte.ts';
|
||||||
import { session } from './lib/session.svelte.ts';
|
import { session } from './lib/session.svelte.ts';
|
||||||
@@ -129,7 +131,8 @@
|
|||||||
{#if ui.channelDialog && session.status === 'connected'}
|
{#if ui.channelDialog && session.status === 'connected'}
|
||||||
{#key ui.channelDialog}<ChannelDialog {...ui.channelDialog} onclose={() => (ui.channelDialog = null)} />{/key}
|
{#key ui.channelDialog}<ChannelDialog {...ui.channelDialog} onclose={() => (ui.channelDialog = null)} />{/key}
|
||||||
{/if}
|
{/if}
|
||||||
{#if ui.prompt}<PromptDialog {...ui.prompt} onclose={() => (ui.prompt = null)} />{/if}
|
{#if share.dialog}<ShareDialog onclose={() => (share.dialog = null)} />{/if}
|
||||||
|
{#if ui.prompt}<PromptDialog {...ui.prompt} onclose={() => { const p = ui.prompt; ui.prompt = null; p?.oncancel?.(); }} />{/if}
|
||||||
<ContextMenu />
|
<ContextMenu />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// Screen-share signalling between mumh5 clients, carried as Mumble plugin data so it works on
|
||||||
|
// any server and other clients ignore it. Murmur drops plugin data over 1000 bytes, so session
|
||||||
|
// descriptions are compressed and split into chunks.
|
||||||
|
export const SHARE_DATA_ID = 'mumh5.share';
|
||||||
|
|
||||||
|
export const SHARE = {
|
||||||
|
on: 1, // I am sharing my screen
|
||||||
|
off: 2, // I stopped
|
||||||
|
who: 3, // I just got here: who is sharing?
|
||||||
|
watch: 4, // let me watch
|
||||||
|
leave: 5, // I stopped watching
|
||||||
|
offer: 6, // session description from the sharer
|
||||||
|
answer: 7 // session description from the viewer
|
||||||
|
} as const;
|
||||||
|
export type ShareType = typeof SHARE[keyof typeof SHARE];
|
||||||
|
|
||||||
|
const CHUNK = 900;
|
||||||
|
const MAX_CHUNKS = 32;
|
||||||
|
const MAX_TEXT = 256 * 1024;
|
||||||
|
let nextId = 0;
|
||||||
|
|
||||||
|
async function deflate(bytes: Uint8Array): Promise<Uint8Array> {
|
||||||
|
const out = new Blob([bytes as Uint8Array<ArrayBuffer>]).stream().pipeThrough(new CompressionStream('deflate-raw'));
|
||||||
|
return new Uint8Array(await new Response(out).arrayBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stops reading once the output passes the cap, so a tiny message cannot expand without bound
|
||||||
|
async function inflate(bytes: Uint8Array): Promise<Uint8Array | null> {
|
||||||
|
const reader = new Blob([bytes as Uint8Array<ArrayBuffer>]).stream().pipeThrough(new DecompressionStream('deflate-raw')).getReader();
|
||||||
|
const parts: Uint8Array[] = [];
|
||||||
|
let size = 0;
|
||||||
|
try {
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
size += value.length;
|
||||||
|
if (size > MAX_TEXT) { await reader.cancel(); return null; }
|
||||||
|
parts.push(value);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const all = new Uint8Array(size);
|
||||||
|
let at = 0;
|
||||||
|
for (const p of parts) { all.set(p, at); at += p.length; }
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One packet for a bare signal, several for a signal with text: [type, id, index, total, ...data]
|
||||||
|
export async function encodeShare(type: ShareType, text?: string): Promise<Uint8Array[]> {
|
||||||
|
if (text == null) return [new Uint8Array([type])];
|
||||||
|
const data = await deflate(new TextEncoder().encode(text));
|
||||||
|
const total = Math.max(1, Math.ceil(data.length / CHUNK));
|
||||||
|
if (total > MAX_CHUNKS) throw new Error('Signal too large');
|
||||||
|
const id = nextId = (nextId + 1) & 255;
|
||||||
|
return Array.from({ length: total }, (_, i) => {
|
||||||
|
const part = data.subarray(i * CHUNK, (i + 1) * CHUNK);
|
||||||
|
const packet = new Uint8Array(4 + part.length);
|
||||||
|
packet.set([type, id, i, total]);
|
||||||
|
packet.set(part, 4);
|
||||||
|
return packet;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShareSignal { type: ShareType; text?: string }
|
||||||
|
|
||||||
|
// Puts chunked signals back together, per sender
|
||||||
|
export class ShareAssembler {
|
||||||
|
private pending = new Map<string, { parts: (Uint8Array | undefined)[]; got: number; at: number }>();
|
||||||
|
|
||||||
|
async push(sender: number, packet: Uint8Array, now = Date.now()): Promise<ShareSignal | null> {
|
||||||
|
const type = packet[0] as ShareType;
|
||||||
|
if (!Object.values(SHARE).includes(type)) return null;
|
||||||
|
if (packet.length === 1) return { type };
|
||||||
|
if (packet.length < 4) return null;
|
||||||
|
const [, id, index, total] = packet;
|
||||||
|
if (!total || total > MAX_CHUNKS || index >= total) return null;
|
||||||
|
// Abandoned transfers must not pile up
|
||||||
|
for (const [k, v] of this.pending) if (now - v.at > 15000) this.pending.delete(k);
|
||||||
|
if (this.pending.size > 64) this.pending.clear();
|
||||||
|
const key = `${sender}:${type}:${id}`;
|
||||||
|
let entry = this.pending.get(key);
|
||||||
|
if (!entry || entry.parts.length !== total) {
|
||||||
|
entry = { parts: new Array(total), got: 0, at: now };
|
||||||
|
this.pending.set(key, entry);
|
||||||
|
}
|
||||||
|
if (!entry.parts[index]) entry.got++;
|
||||||
|
entry.parts[index] = packet.slice(4);
|
||||||
|
if (entry.got < total) return null;
|
||||||
|
this.pending.delete(key);
|
||||||
|
const joined = new Uint8Array(entry.parts.reduce((n, p) => n + p!.length, 0));
|
||||||
|
let at = 0;
|
||||||
|
for (const p of entry.parts) { joined.set(p!, at); at += p!.length; }
|
||||||
|
const plain = await inflate(joined);
|
||||||
|
return plain ? { type, text: new TextDecoder().decode(plain) } : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-1
@@ -1,4 +1,5 @@
|
|||||||
import { session } from './session.svelte.ts';
|
import { session, sessions } from './session.svelte.ts';
|
||||||
|
import { share } from './share.svelte.ts';
|
||||||
import { ui } from './ui.svelte.ts';
|
import { ui } from './ui.svelte.ts';
|
||||||
import { store } from './settings.svelte.ts';
|
import { store } from './settings.svelte.ts';
|
||||||
import { voice, userKey } from './audio/voice.svelte.ts';
|
import { voice, userKey } from './audio/voice.svelte.ts';
|
||||||
@@ -67,6 +68,7 @@ export function userMenu(u: User): MenuItem[] {
|
|||||||
);
|
);
|
||||||
if (u.userId == null) items.push({ label: 'Register on this server', action: () => session.register(u.session) });
|
if (u.userId == null) items.push({ label: 'Register on this server', action: () => session.register(u.session) });
|
||||||
} else {
|
} else {
|
||||||
|
if (session.sharing[u.session]) items.push({ label: 'Watch screen', action: () => share.watch(sessions.active, u.session) });
|
||||||
items.push(
|
items.push(
|
||||||
{ label: 'Send message', action: () => session.selectView(`dm:${u.session}`) },
|
{ label: 'Send message', action: () => session.selectView(`dm:${u.session}`) },
|
||||||
{ label: 'Mute for me', checked: !!voice.settings.localMutes[userKey(u)], action: () => voice.setLocalMute(u, !voice.settings.localMutes[userKey(u)]) },
|
{ label: 'Mute for me', checked: !!voice.settings.localMutes[userKey(u)], action: () => voice.setLocalMute(u, !voice.settings.localMutes[userKey(u)]) },
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export const ICON_PATHS: Record<string, string> = {
|
|||||||
'headphones-off': 'M3 14v-2a9 9 0 0 1 18 0v2M21 14v7h-4v-7zM3 14v7h4v-7zM3 3l18 18',
|
'headphones-off': 'M3 14v-2a9 9 0 0 1 18 0v2M21 14v7h-4v-7zM3 14v7h4v-7zM3 3l18 18',
|
||||||
settings: 'M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6',
|
settings: 'M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6',
|
||||||
plus: 'M12 5v14M5 12h14',
|
plus: 'M12 5v14M5 12h14',
|
||||||
|
screen: 'M3 4h18v12H3zM8 20h8M12 16v4',
|
||||||
|
expand: 'M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5',
|
||||||
filter: 'M3 4h18l-7 8v7l-4 2v-9z',
|
filter: 'M3 4h18l-7 8v7l-4 2v-9z',
|
||||||
info: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zM12 11v6M12 7h.01',
|
info: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zM12 11v6M12 7h.01',
|
||||||
'arrow-right': 'M5 12h14M13 6l6 6-6 6',
|
'arrow-right': 'M5 12h14M13 6l6 6-6 6',
|
||||||
|
|||||||
@@ -29,6 +29,17 @@ interface NativeApi extends PlatformApi {
|
|||||||
pingServer(host: string, port: number): Promise<PingResult | null>;
|
pingServer(host: string, port: number): Promise<PingResult | null>;
|
||||||
onContextMenu(fn: (params: ContextParams) => void): void;
|
onContextMenu(fn: (params: ContextParams) => void): void;
|
||||||
editAction(action: string, arg?: unknown): void;
|
editAction(action: string, arg?: unknown): void;
|
||||||
|
// Screens and windows that can be shared; pick one before calling getDisplayMedia
|
||||||
|
// chosen: the system's own picker already asked (Wayland), so `sources` is the one answer
|
||||||
|
// Thumbnails are slower to get and can fail; ask for the names first
|
||||||
|
screenSources(thumbnails: boolean): Promise<{ sources: ScreenSource[]; chosen: boolean }>;
|
||||||
|
pickScreenSource(id: string): void;
|
||||||
|
// portal: the system shows its own source picker (Wayland). audio: how other programs' sound can be captured.
|
||||||
|
shareInfo(): Promise<{ portal: boolean; audio: 'pipewire' | 'loopback' | 'none'; micLabel: string }>;
|
||||||
|
shareAudioApps(): Promise<string[]>;
|
||||||
|
// Linux: route one program, or everything but this app, into a virtual microphone named micLabel
|
||||||
|
shareAudioStart(target: { kind: 'all' } | { kind: 'app'; name: string }): Promise<void>;
|
||||||
|
shareAudioStop(): Promise<void>;
|
||||||
open(connId: string, host: string, port: number, identityId?: string): Promise<void>;
|
open(connId: string, host: string, port: number, identityId?: string): Promise<void>;
|
||||||
send(connId: string, bytes: Uint8Array): void;
|
send(connId: string, bytes: Uint8Array): void;
|
||||||
close(connId: string): void;
|
close(connId: string): void;
|
||||||
@@ -53,6 +64,7 @@ export interface ContextParams {
|
|||||||
editFlags: { canUndo: boolean; canRedo: boolean; canCut: boolean; canCopy: boolean; canPaste: boolean; canSelectAll: boolean };
|
editFlags: { canUndo: boolean; canRedo: boolean; canCut: boolean; canCopy: boolean; canPaste: boolean; canSelectAll: boolean };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ScreenSource { id: string; name: string; thumbnail: string }
|
||||||
export interface PublicServer { name: string; host: string; port: number; url: string; country: string; countryCode: string; continent: string; ca: boolean }
|
export interface PublicServer { name: string; host: string; port: number; url: string; country: string; countryCode: string; continent: string; ca: boolean }
|
||||||
export interface PingResult { ping: number; users: number; maxUsers: number; bandwidth: number; version: string }
|
export interface PingResult { ping: number; users: number; maxUsers: number; bandwidth: number; version: string }
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createCodec } from '../core/proto.ts';
|
import { createCodec } from '../core/proto.ts';
|
||||||
import { MumbleClient, describeDenial, type Channel, type User, type TextMessage } from '../core/client.ts';
|
import { MumbleClient, describeDenial, type Channel, type User, type TextMessage } from '../core/client.ts';
|
||||||
import { native, openTransport } from './native.ts';
|
import { native, openTransport } from './native.ts';
|
||||||
|
import { share } from './share.svelte.ts';
|
||||||
import type { ServerCertInfo } from '../core/transport.ts';
|
import type { ServerCertInfo } from '../core/transport.ts';
|
||||||
import { store, type SavedServer } from './settings.svelte.ts';
|
import { store, type SavedServer } from './settings.svelte.ts';
|
||||||
import { renderIncoming, textToHtml, escapeHtml, formatBytes, type Embed } from './html.ts';
|
import { renderIncoming, textToHtml, escapeHtml, formatBytes, type Embed } from './html.ts';
|
||||||
@@ -60,6 +61,8 @@ export class Session {
|
|||||||
uploads = $state<UploadJob[]>([]);
|
uploads = $state<UploadJob[]>([]);
|
||||||
certPrompt = $state<CertPrompt | null>(null);
|
certPrompt = $state<CertPrompt | null>(null);
|
||||||
rtt = $state(0);
|
rtt = $state(0);
|
||||||
|
// Who in our channel is sharing their screen, by session (mumh5 clients only)
|
||||||
|
sharing = $state<Record<number, boolean>>({});
|
||||||
// Why the last connection ended, shown with a Reconnect button (null after a normal disconnect)
|
// Why the last connection ended, shown with a Reconnect button (null after a normal disconnect)
|
||||||
disconnectInfo = $state<{ kind: 'kicked' | 'banned' | 'lost' | 'rejected'; by: string; reason: string } | null>(null);
|
disconnectInfo = $state<{ kind: 'kicked' | 'banned' | 'lost' | 'rejected'; by: string; reason: string } | null>(null);
|
||||||
private removedBy: { kind: 'kicked' | 'banned'; by: string; reason: string } | null = null;
|
private removedBy: { kind: 'kicked' | 'banned'; by: string; reason: string } | null = null;
|
||||||
@@ -288,6 +291,8 @@ export class Session {
|
|||||||
const client = this.client;
|
const client = this.client;
|
||||||
this.client = null;
|
this.client = null;
|
||||||
client?.disconnect();
|
client?.disconnect();
|
||||||
|
share.closed(this);
|
||||||
|
this.sharing = {};
|
||||||
sessions.onClosed(this);
|
sessions.onClosed(this);
|
||||||
this.status = 'idle';
|
this.status = 'idle';
|
||||||
this.tick++;
|
this.tick++;
|
||||||
@@ -312,8 +317,10 @@ export class Session {
|
|||||||
// What we may do per channel, for menus (not rate limited by the server)
|
// What we may do per channel, for menus (not rate limited by the server)
|
||||||
for (const id of client.channels.keys()) client.requestPermissions(id);
|
for (const id of client.channels.keys()) client.requestPermissions(id);
|
||||||
if (client.welcomeText) this.system(this.view, client.welcomeText, true);
|
if (client.welcomeText) this.system(this.view, client.welcomeText, true);
|
||||||
|
share.entered(this);
|
||||||
bump();
|
bump();
|
||||||
});
|
});
|
||||||
|
client.on('pluginData', (sender, dataId, data) => { if (live()) share.receive(this, sender, dataId, data); });
|
||||||
client.on('channel', ch => {
|
client.on('channel', ch => {
|
||||||
if (live() && client.synced && !client.permissions.has(ch.id)) client.requestPermissions(ch.id);
|
if (live() && client.synced && !client.permissions.has(ch.id)) client.requestPermissions(ch.id);
|
||||||
bump();
|
bump();
|
||||||
@@ -337,6 +344,10 @@ export class Session {
|
|||||||
const self = client.self;
|
const self = client.self;
|
||||||
const before = this.lastChannel.get(user.session);
|
const before = this.lastChannel.get(user.session);
|
||||||
this.lastChannel.set(user.session, user.channelId);
|
this.lastChannel.set(user.session, user.channelId);
|
||||||
|
if (client.synced && self && (isNew || changed.includes('channelId'))) {
|
||||||
|
if (user.session === self.session) share.entered(this);
|
||||||
|
else share.userMoved(this, user);
|
||||||
|
}
|
||||||
if (client.synced && self && this.isVoice()) this.userSound(user.session === self.session, user, changed, actor, isNew, before, self.channelId);
|
if (client.synced && self && this.isVoice()) this.userSound(user.session === self.session, user, changed, actor, isNew, before, self.channelId);
|
||||||
if (client.synced && self) {
|
if (client.synced && self) {
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
@@ -366,6 +377,7 @@ export class Session {
|
|||||||
this.removedBy = { kind: ban ? 'banned' : 'kicked', by, reason };
|
this.removedBy = { kind: ban ? 'banned' : 'kicked', by, reason };
|
||||||
}
|
}
|
||||||
const self = client.self;
|
const self = client.self;
|
||||||
|
share.userMoved(this, user, true);
|
||||||
if (self && user.session !== self.session && this.isVoice() && user.channelId === self.channelId) {
|
if (self && user.session !== self.session && this.isVoice() && user.channelId === self.channelId) {
|
||||||
const byOther = actor != null && actor !== user.session;
|
const byOther = actor != null && actor !== user.session;
|
||||||
sounds.play(ban ? 'userBanned' : byOther ? 'userKicked' : 'userDisconnected');
|
sounds.play(ban ? 'userBanned' : byOther ? 'userKicked' : 'userDisconnected');
|
||||||
@@ -675,6 +687,12 @@ export class Session {
|
|||||||
|
|
||||||
// ─── Voice state and moderation ─────────────────────────────────────────────
|
// ─── Voice state and moderation ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
setSharing(session: number, on: boolean): void {
|
||||||
|
if (on) this.sharing[session] = true;
|
||||||
|
else delete this.sharing[session];
|
||||||
|
}
|
||||||
|
clearSharing(): void { this.sharing = {}; }
|
||||||
|
|
||||||
// The choice is remembered, so it also works while disconnected and carries to the next connect
|
// The choice is remembered, so it also works while disconnected and carries to the next connect
|
||||||
setSelfMute(v: boolean) {
|
setSelfMute(v: boolean) {
|
||||||
sessions.mute = v;
|
sessions.mute = v;
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ export interface Settings {
|
|||||||
theme: ThemeId;
|
theme: ThemeId;
|
||||||
publicListUrl: string; // where the public server list comes from
|
publicListUrl: string; // where the public server list comes from
|
||||||
arrangement: Arrangement;
|
arrangement: Arrangement;
|
||||||
|
shareQuality: 'low' | 'medium' | 'high'; // bitrate and frame rate of our own stream
|
||||||
|
shareVolume: number; // volume of streams we watch, 0 to 1
|
||||||
|
stunServer: string; // helps screen sharing connect through routers; empty contacts nobody
|
||||||
|
shareNoticeSeen: boolean; // the note that screen sharing reveals IP addresses was confirmed
|
||||||
hints: boolean; // tooltips (title attributes) when resting the pointer on buttons
|
hints: boolean; // tooltips (title attributes) when resting the pointer on buttons
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +56,10 @@ const defaults: Settings = {
|
|||||||
theme: 'dark',
|
theme: 'dark',
|
||||||
publicListUrl: 'https://publist.mumble.info/v1/list?version=1.5.0',
|
publicListUrl: 'https://publist.mumble.info/v1/list?version=1.5.0',
|
||||||
arrangement: 'side',
|
arrangement: 'side',
|
||||||
|
shareQuality: 'medium',
|
||||||
|
shareVolume: 1,
|
||||||
|
stunServer: '',
|
||||||
|
shareNoticeSeen: false,
|
||||||
hints: false
|
hints: false
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
// Screen sharing between mumh5 clients in the same channel. Mumble has no video, so the
|
||||||
|
// picture goes directly from the sharer to each viewer over WebRTC; only the setup messages
|
||||||
|
// travel through the Mumble server (as plugin data, see core/share-signal.ts). The server has
|
||||||
|
// checked the sender's certificate, so a stream is known to come from that user.
|
||||||
|
import { SHARE, SHARE_DATA_ID, encodeShare, ShareAssembler, type ShareType } from '../core/share-signal.ts';
|
||||||
|
import type { User } from '../core/client.ts';
|
||||||
|
import type { Session } from './session.svelte.ts';
|
||||||
|
import { desktop, isWeb } from './native.ts';
|
||||||
|
import { proxyStun } from './web.svelte.ts';
|
||||||
|
import { store } from './settings.svelte.ts';
|
||||||
|
import { ui } from './ui.svelte.ts';
|
||||||
|
|
||||||
|
// Every viewer is a separate upload from the sharer
|
||||||
|
const MAX_VIEWERS = 8;
|
||||||
|
export const QUALITY = {
|
||||||
|
low: { label: 'Low (1 Mbit/s, 30 fps)', bitrate: 1_000_000, fps: 30 },
|
||||||
|
medium: { label: 'Medium (4 Mbit/s, 30 fps)', bitrate: 4_000_000, fps: 30 },
|
||||||
|
high: { label: 'High (8 Mbit/s, 60 fps)', bitrate: 8_000_000, fps: 60 }
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Opus is negotiated as mono speech by default; shared sound is stereo and often music
|
||||||
|
const stereo = (sdp: string) => sdp.replace(/a=fmtp:(\d+) ([^\r\n]*useinbandfec=1[^\r\n]*)/g,
|
||||||
|
(line, pt, rest) => rest.includes('stereo=1') ? line : `a=fmtp:${pt} ${rest};stereo=1;sprop-stereo=1;maxaveragebitrate=192000`);
|
||||||
|
|
||||||
|
export interface Watching {
|
||||||
|
host: Session;
|
||||||
|
session: number;
|
||||||
|
stream: MediaStream | null;
|
||||||
|
state: 'connecting' | 'live' | 'failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
class ScreenShare {
|
||||||
|
// The server we are sharing on, and what we send
|
||||||
|
host = $state.raw<Session | null>(null);
|
||||||
|
stream = $state.raw<MediaStream | null>(null);
|
||||||
|
viewers = $state(0);
|
||||||
|
watching = $state.raw<Watching | null>(null);
|
||||||
|
error = $state('');
|
||||||
|
// The server the start dialog is open for
|
||||||
|
dialog = $state.raw<Session | null>(null);
|
||||||
|
private usesVirtualMic = false;
|
||||||
|
|
||||||
|
private peers = new Map<number, RTCPeerConnection>();
|
||||||
|
private watchPc: RTCPeerConnection | null = null;
|
||||||
|
private watchTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
private assemblers = new WeakMap<Session, ShareAssembler>();
|
||||||
|
|
||||||
|
// Plugin data needs a 1.4 server; older ones would not pass the signals on
|
||||||
|
supported(s: Session): boolean {
|
||||||
|
return !!s.client && s.client.serverVersionNum >= 0x010400 && typeof RTCPeerConnection !== 'undefined';
|
||||||
|
}
|
||||||
|
|
||||||
|
private iceServers(): RTCIceServer[] {
|
||||||
|
const stun = store.settings.stunServer.trim();
|
||||||
|
if (stun) return [{ urls: /^stuns?:/.test(stun) ? stun : `stun:${stun}` }];
|
||||||
|
// The browser build falls back to the proxy it is served from
|
||||||
|
const own = isWeb ? proxyStun() : null;
|
||||||
|
return own ? [{ urls: own }] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private others(s: Session): number[] {
|
||||||
|
const self = s.client?.self;
|
||||||
|
if (!self) return [];
|
||||||
|
return [...s.client!.users.values()].filter(u => u.session !== self.session && u.channelId === self.channelId).map(u => u.session);
|
||||||
|
}
|
||||||
|
|
||||||
|
private inMyChannel(s: Session, session: number): boolean {
|
||||||
|
const self = s.client?.self;
|
||||||
|
return !!self && s.client!.users.get(session)?.channelId === self.channelId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async send(s: Session, to: number[], type: ShareType, text?: string): Promise<void> {
|
||||||
|
if (!to.length) return;
|
||||||
|
for (const packet of await encodeShare(type, text)) s.client?.sendPluginData(to, SHARE_DATA_ID, packet);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sharer and viewer connect directly, so each learns the other's address; say so once
|
||||||
|
private notice(): Promise<boolean> {
|
||||||
|
if (store.settings.shareNoticeSeen) return Promise.resolve(true);
|
||||||
|
return new Promise(resolve => {
|
||||||
|
let answered = false;
|
||||||
|
ui.prompt = {
|
||||||
|
title: 'Screen sharing', label: '', confirm: 'Continue', input: 'none',
|
||||||
|
text: 'Screen sharing connects you directly to the other person. You see each other\'s IP address, like in a video call. Only mumh5 users in your channel can watch.',
|
||||||
|
onsubmit: () => { answered = true; store.settings.shareNoticeSeen = true; store.saveSettings(); resolve(true); },
|
||||||
|
oncancel: () => { if (!answered) resolve(false); }
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opens the dialog where the source, the sound and the quality are chosen
|
||||||
|
async start(s: Session): Promise<void> {
|
||||||
|
if (this.stream || this.dialog || !this.supported(s) || !(await this.notice())) return;
|
||||||
|
this.error = '';
|
||||||
|
this.dialog = s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The dialog hands over what it captured
|
||||||
|
go(s: Session, stream: MediaStream, usesVirtualMic: boolean): void {
|
||||||
|
this.dialog = null;
|
||||||
|
// Disconnected while the dialog was open
|
||||||
|
if (!s.client || this.stream) {
|
||||||
|
stream.getTracks().forEach(t => t.stop());
|
||||||
|
if (usesVirtualMic) desktop?.shareAudioStop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const t of stream.getVideoTracks()) {
|
||||||
|
t.contentHint = 'detail';
|
||||||
|
// The system's "stop sharing" control
|
||||||
|
t.addEventListener('ended', () => this.stop());
|
||||||
|
}
|
||||||
|
this.usesVirtualMic = usesVirtualMic;
|
||||||
|
this.host = s;
|
||||||
|
this.stream = stream;
|
||||||
|
this.send(s, this.others(s), SHARE.on);
|
||||||
|
s.setSharing(s.client.session!, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private release(): void {
|
||||||
|
this.dropPeers();
|
||||||
|
this.stream?.getTracks().forEach(t => t.stop());
|
||||||
|
this.stream = null;
|
||||||
|
this.host = null;
|
||||||
|
if (this.usesVirtualMic) desktop?.shareAudioStop();
|
||||||
|
this.usesVirtualMic = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
const s = this.host;
|
||||||
|
if (s?.client) {
|
||||||
|
this.send(s, this.others(s), SHARE.off);
|
||||||
|
if (s.client.session != null) s.setSharing(s.client.session, false);
|
||||||
|
}
|
||||||
|
this.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
private dropPeers(): void {
|
||||||
|
for (const pc of this.peers.values()) pc.close();
|
||||||
|
this.peers.clear();
|
||||||
|
this.viewers = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private dropPeer(session: number): void {
|
||||||
|
this.peers.get(session)?.close();
|
||||||
|
this.peers.delete(session);
|
||||||
|
this.viewers = this.peers.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
async watch(s: Session, session: number): Promise<void> {
|
||||||
|
if (!this.supported(s) || !s.sharing[session] || !(await this.notice())) return;
|
||||||
|
this.unwatch();
|
||||||
|
this.watching = { host: s, session, stream: null, state: 'connecting' };
|
||||||
|
this.send(s, [session], SHARE.watch);
|
||||||
|
this.watchTimer = setTimeout(() => this.setWatchState('failed'), 20000);
|
||||||
|
}
|
||||||
|
|
||||||
|
unwatch(): void {
|
||||||
|
const w = this.watching;
|
||||||
|
if (!w) return;
|
||||||
|
if (w.host.client) this.send(w.host, [w.session], SHARE.leave);
|
||||||
|
this.endWatch();
|
||||||
|
}
|
||||||
|
|
||||||
|
private endWatch(): void {
|
||||||
|
clearTimeout(this.watchTimer);
|
||||||
|
this.watchPc?.close();
|
||||||
|
this.watchPc = null;
|
||||||
|
this.watching = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setWatchState(state: Watching['state'], stream?: MediaStream): void {
|
||||||
|
const w = this.watching;
|
||||||
|
if (!w) return;
|
||||||
|
if (state !== 'connecting') clearTimeout(this.watchTimer);
|
||||||
|
this.watching = { ...w, state, stream: stream ?? w.stream };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Candidates travel inside the description, so wait for them instead of sending each one
|
||||||
|
private gathered(pc: RTCPeerConnection): Promise<void> {
|
||||||
|
if (pc.iceGatheringState === 'complete') return Promise.resolve();
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const done = () => { clearTimeout(timer); pc.removeEventListener('icegatheringstatechange', check); resolve(); };
|
||||||
|
const check = () => { if (pc.iceGatheringState === 'complete') done(); };
|
||||||
|
const timer = setTimeout(done, 3000);
|
||||||
|
pc.addEventListener('icegatheringstatechange', check);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async offerTo(s: Session, viewer: number): Promise<void> {
|
||||||
|
const stream = this.stream;
|
||||||
|
if (!stream || this.host !== s) return;
|
||||||
|
this.dropPeer(viewer);
|
||||||
|
if (this.peers.size >= MAX_VIEWERS) return;
|
||||||
|
const pc = new RTCPeerConnection({ iceServers: this.iceServers() });
|
||||||
|
this.peers.set(viewer, pc);
|
||||||
|
this.viewers = this.peers.size;
|
||||||
|
pc.addEventListener('connectionstatechange', () => {
|
||||||
|
if ((pc.connectionState === 'failed' || pc.connectionState === 'closed') && this.peers.get(viewer) === pc) this.dropPeer(viewer);
|
||||||
|
});
|
||||||
|
for (const track of stream.getTracks()) {
|
||||||
|
const sender = pc.addTrack(track, stream);
|
||||||
|
if (track.kind !== 'video') continue;
|
||||||
|
const params = sender.getParameters();
|
||||||
|
// Keep the picture sharp and let the frame rate drop when the connection is tight
|
||||||
|
params.degradationPreference = 'maintain-resolution';
|
||||||
|
for (const enc of params.encodings ?? []) enc.maxBitrate = QUALITY[store.settings.shareQuality].bitrate;
|
||||||
|
await sender.setParameters(params).catch(() => {});
|
||||||
|
}
|
||||||
|
await pc.setLocalDescription({ type: 'offer', sdp: stereo((await pc.createOffer()).sdp ?? '') });
|
||||||
|
await this.gathered(pc);
|
||||||
|
if (this.peers.get(viewer) === pc && pc.localDescription) await this.send(s, [viewer], SHARE.offer, pc.localDescription.sdp);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async answerTo(s: Session, sharer: number, sdp: string): Promise<void> {
|
||||||
|
const w = this.watching;
|
||||||
|
if (!w || w.host !== s || w.session !== sharer) return;
|
||||||
|
this.watchPc?.close();
|
||||||
|
const pc = new RTCPeerConnection({ iceServers: this.iceServers() });
|
||||||
|
this.watchPc = pc;
|
||||||
|
pc.addEventListener('track', e => { if (this.watchPc === pc) this.setWatchState('live', e.streams[0]); });
|
||||||
|
pc.addEventListener('connectionstatechange', () => {
|
||||||
|
if (pc.connectionState === 'failed' && this.watchPc === pc) this.setWatchState('failed');
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await pc.setRemoteDescription({ type: 'offer', sdp });
|
||||||
|
await pc.setLocalDescription({ type: 'answer', sdp: stereo((await pc.createAnswer()).sdp ?? '') });
|
||||||
|
await this.gathered(pc);
|
||||||
|
if (this.watchPc === pc && pc.localDescription) await this.send(s, [sharer], SHARE.answer, pc.localDescription.sdp);
|
||||||
|
} catch {
|
||||||
|
if (this.watchPc === pc) this.setWatchState('failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Called by the session ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async receive(s: Session, sender: number, dataId: string, data: Uint8Array): Promise<void> {
|
||||||
|
if (dataId !== SHARE_DATA_ID) return;
|
||||||
|
let assembler = this.assemblers.get(s);
|
||||||
|
if (!assembler) this.assemblers.set(s, assembler = new ShareAssembler());
|
||||||
|
const msg = await assembler.push(sender, data);
|
||||||
|
// Only people in our channel take part
|
||||||
|
if (!msg || !s.client || !this.inMyChannel(s, sender)) return;
|
||||||
|
switch (msg.type) {
|
||||||
|
case SHARE.on: s.setSharing(sender, true); break;
|
||||||
|
case SHARE.off:
|
||||||
|
s.setSharing(sender, false);
|
||||||
|
if (this.watching?.host === s && this.watching.session === sender) this.endWatch();
|
||||||
|
break;
|
||||||
|
case SHARE.who: if (this.host === s) this.send(s, [sender], SHARE.on); break;
|
||||||
|
case SHARE.watch: if (this.host === s) this.offerTo(s, sender).catch(() => this.dropPeer(sender)); break;
|
||||||
|
case SHARE.leave: if (this.host === s) this.dropPeer(sender); break;
|
||||||
|
case SHARE.offer: if (msg.text) this.answerTo(s, sender, msg.text); break;
|
||||||
|
case SHARE.answer:
|
||||||
|
if (this.host === s && msg.text) this.peers.get(sender)?.setRemoteDescription({ type: 'answer', sdp: msg.text }).catch(() => this.dropPeer(sender));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// We connected or moved: ask who is sharing here, and tell the new channel about our stream
|
||||||
|
entered(s: Session): void {
|
||||||
|
if (!this.supported(s)) return;
|
||||||
|
const self = s.client!.session!;
|
||||||
|
s.clearSharing();
|
||||||
|
if (this.watching?.host === s) this.unwatch();
|
||||||
|
if (this.host === s) {
|
||||||
|
this.dropPeers();
|
||||||
|
s.setSharing(self, true);
|
||||||
|
this.send(s, this.others(s), SHARE.on);
|
||||||
|
}
|
||||||
|
this.send(s, this.others(s), SHARE.who);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Someone else connected, moved or left
|
||||||
|
userMoved(s: Session, user: User, gone = false): void {
|
||||||
|
if (!s.client?.self || user.session === s.client.session) return;
|
||||||
|
if (!gone && this.inMyChannel(s, user.session)) {
|
||||||
|
if (this.host === s) this.send(s, [user.session], SHARE.on);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
s.setSharing(user.session, false);
|
||||||
|
if (this.host === s) this.dropPeer(user.session);
|
||||||
|
if (this.watching?.host === s && this.watching.session === user.session) this.endWatch();
|
||||||
|
}
|
||||||
|
|
||||||
|
closed(s: Session): void {
|
||||||
|
if (this.host === s) this.release();
|
||||||
|
if (this.dialog === s) this.dialog = null;
|
||||||
|
if (this.watching?.host === s) this.endWatch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const share = new ScreenShare();
|
||||||
@@ -9,6 +9,8 @@ export interface PromptRequest {
|
|||||||
initial?: string;
|
initial?: string;
|
||||||
text?: string;
|
text?: string;
|
||||||
onsubmit: (value: string) => void;
|
onsubmit: (value: string) => void;
|
||||||
|
// Closed without confirming
|
||||||
|
oncancel?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CertRequest {
|
export interface CertRequest {
|
||||||
|
|||||||
+10
-1
@@ -135,14 +135,17 @@ export interface ProxyServer { host: string; port: number; label: string }
|
|||||||
class ProxyInfo {
|
class ProxyInfo {
|
||||||
servers = $state<ProxyServer[]>([]);
|
servers = $state<ProxyServer[]>([]);
|
||||||
any = $state(false);
|
any = $state(false);
|
||||||
|
// UDP port of the proxy's STUN responder, if it runs one
|
||||||
|
stun = $state<number | null>(null);
|
||||||
loaded = $state(false);
|
loaded = $state(false);
|
||||||
error = $state('');
|
error = $state('');
|
||||||
|
|
||||||
async load(): Promise<void> {
|
async load(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const c = await call<{ servers: ProxyServer[]; any: boolean }>('config');
|
const c = await call<{ servers: ProxyServer[]; any: boolean; stun?: number | null }>('config');
|
||||||
this.servers = c.servers;
|
this.servers = c.servers;
|
||||||
this.any = c.any;
|
this.any = c.any;
|
||||||
|
this.stun = c.stun ?? null;
|
||||||
this.error = '';
|
this.error = '';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.error = (e as Error).message;
|
this.error = (e as Error).message;
|
||||||
@@ -151,3 +154,9 @@ class ProxyInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
export const proxyInfo = new ProxyInfo();
|
export const proxyInfo = new ProxyInfo();
|
||||||
|
|
||||||
|
// The proxy's own STUN address: a host the user already uses, so nothing new is contacted
|
||||||
|
export function proxyStun(): string | null {
|
||||||
|
if (!proxyInfo.stun || !base) return null;
|
||||||
|
try { return `stun:${new URL(base).hostname}:${proxyInfo.stun}`; } catch { return null; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
import { drag } from '../lib/drag.svelte.ts';
|
import { drag } from '../lib/drag.svelte.ts';
|
||||||
import { sounds } from '../lib/audio/sounds.svelte.ts';
|
import { sounds } from '../lib/audio/sounds.svelte.ts';
|
||||||
import { renderIncoming } from '../lib/html.ts';
|
import { renderIncoming } from '../lib/html.ts';
|
||||||
|
import { share } from '../lib/share.svelte.ts';
|
||||||
|
import { sessions } from '../lib/session.svelte.ts';
|
||||||
import type { Channel } from '../core/client.ts';
|
import type { Channel } from '../core/client.ts';
|
||||||
|
|
||||||
let { channel, depth = 0, onnavigate }: { channel: Channel; depth?: number; onnavigate: () => void } = $props();
|
let { channel, depth = 0, onnavigate }: { channel: Channel; depth?: number; onnavigate: () => void } = $props();
|
||||||
@@ -154,6 +156,17 @@
|
|||||||
<Avatar name={u.name} size={22} speaking={voice.talking[u.session] || (isSelf && voice.transmitting)} />
|
<Avatar name={u.name} size={22} speaking={voice.talking[u.session] || (isSelf && voice.transmitting)} />
|
||||||
<span class="label" class:talking={voice.talking[u.session] || (isSelf && voice.transmitting)}>{store.displayName(u)}</span>
|
<span class="label" class:talking={voice.talking[u.session] || (isSelf && voice.transmitting)}>{store.displayName(u)}</span>
|
||||||
{#if u.userId != null}<span class="reg" title="Registered">R</span>{/if}
|
{#if u.userId != null}<span class="reg" title="Registered">R</span>{/if}
|
||||||
|
{#if session.sharing[u.session]}
|
||||||
|
{#if isSelf}
|
||||||
|
<span class="state sharing" title="You are sharing your screen"><Icon name="screen" size={14} /></span>
|
||||||
|
{:else}
|
||||||
|
<!-- A button cannot sit inside the row's button -->
|
||||||
|
<span class="state sharing" role="button" tabindex="0" title="Sharing their screen. Click to watch" aria-label="Watch the screen of {store.displayName(u)}"
|
||||||
|
onclick={e => { e.stopPropagation(); clearTimeout(timer); share.watch(sessions.active, u.session); }}
|
||||||
|
ondblclick={e => e.stopPropagation()}
|
||||||
|
onkeydown={e => { if (e.key === 'Enter') { e.stopPropagation(); share.watch(sessions.active, u.session); } }}><Icon name="screen" size={14} /></span>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
{#if u.comment || u.commentHash}<span class="state" title="Has a description"><Icon name="message" size={12} /></span>{/if}
|
{#if u.comment || u.commentHash}<span class="state" title="Has a description"><Icon name="message" size={12} /></span>{/if}
|
||||||
{#if u.prioritySpeaker}<span class="state" title="Priority speaker"><Icon name="channel" size={14} /></span>{/if}
|
{#if u.prioritySpeaker}<span class="state" title="Priority speaker"><Icon name="channel" size={14} /></span>{/if}
|
||||||
{#if u.recording}<span class="rec" title="Recording">REC</span>{/if}
|
{#if u.recording}<span class="rec" title="Recording">REC</span>{/if}
|
||||||
@@ -207,5 +220,7 @@
|
|||||||
.reg { font-size: 10px; font-weight: 700; color: var(--accent-hover); border: 1px solid var(--accent); padding: 0 3px; line-height: 14px; }
|
.reg { font-size: 10px; font-weight: 700; color: var(--accent-hover); border: 1px solid var(--accent); padding: 0 3px; line-height: 14px; }
|
||||||
.state { color: var(--text-faint); display: inline-flex; }
|
.state { color: var(--text-faint); display: inline-flex; }
|
||||||
.state.admin { color: var(--danger); }
|
.state.admin { color: var(--danger); }
|
||||||
|
.state.sharing { color: var(--speaking); padding: 3px; }
|
||||||
|
.state.sharing[role=button]:hover { background: var(--bg-active); }
|
||||||
.rec { font-size: 10px; font-weight: 700; color: var(--danger); border: 1px solid var(--danger); padding: 0 3px; }
|
.rec { font-size: 10px; font-weight: 700; color: var(--danger); border: 1px solid var(--danger); padding: 0 3px; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+1
-1
@@ -27,7 +27,7 @@
|
|||||||
<button class="icon-btn" onclick={onpanel} title="Members"><Icon name="users" /></button>
|
<button class="icon-btn" onclick={onpanel} title="Members"><Icon name="users" /></button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if showStage && session.status === 'connected'}<VoiceStage />{/if}
|
{#if session.status === 'connected'}<VoiceStage always={showStage} />{/if}
|
||||||
|
|
||||||
{#if session.status === 'idle' && session.disconnectInfo}
|
{#if session.status === 'idle' && session.disconnectInfo}
|
||||||
{@const d = session.disconnectInfo}
|
{@const d = session.disconnectInfo}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
import { voice } from '../lib/audio/voice.svelte.ts';
|
import { voice } from '../lib/audio/voice.svelte.ts';
|
||||||
import { renderIncoming } from '../lib/html.ts';
|
import { renderIncoming } from '../lib/html.ts';
|
||||||
import RichEditor from './RichEditor.svelte';
|
import RichEditor from './RichEditor.svelte';
|
||||||
|
import { share } from '../lib/share.svelte.ts';
|
||||||
|
import { sessions } from '../lib/session.svelte.ts';
|
||||||
|
|
||||||
let { onnavigate, onclose }: { onnavigate: () => void; onclose: () => void } = $props();
|
let { onnavigate, onclose }: { onnavigate: () => void; onclose: () => void } = $props();
|
||||||
|
|
||||||
@@ -124,6 +126,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if session.sharing[user.session]}
|
||||||
|
<div class="sharing">
|
||||||
|
<Icon name="screen" size={16} />
|
||||||
|
<span>{isSelf ? 'You are sharing your screen' : 'Sharing their screen'}</span>
|
||||||
|
{#if isSelf}<button class="btn" onclick={() => share.stop()}>Stop sharing</button>
|
||||||
|
{:else if share.watching?.session === user.session}<button class="btn" onclick={() => share.unwatch()}>Stop watching</button>
|
||||||
|
{:else}<button class="btn primary" onclick={() => share.watch(sessions.active, user.session)}>Watch</button>{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if isSelf}
|
{#if isSelf}
|
||||||
<div class="actions"><button class="btn" onclick={() => changeNamePrompt(user)}>Change name</button></div>
|
<div class="actions"><button class="btn" onclick={() => changeNamePrompt(user)}>Change name</button></div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -158,6 +170,9 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.sharing { display: flex; align-items: center; gap: 8px; margin-top: 12px; padding: 8px 10px; font-size: 13px; color: var(--speaking); border: 1px solid var(--line); background: var(--bg-0); }
|
||||||
|
.sharing span { flex: 1; min-width: 0; color: var(--text); }
|
||||||
|
.sharing .btn { padding: 4px 10px; font-size: 12px; }
|
||||||
.panel { position: relative; max-width: 100%; height: 100%; flex: none; display: flex; flex-direction: column; background: var(--bg-1); border-left: 1px solid var(--line); min-height: 0; }
|
.panel { position: relative; max-width: 100%; height: 100%; flex: none; display: flex; flex-direction: column; background: var(--bg-1); border-left: 1px solid var(--line); min-height: 0; }
|
||||||
header { height: 52px; flex: none; display: flex; align-items: center; gap: 8px; padding: 0 8px 0 14px; border-bottom: 1px solid var(--line); color: var(--text-dim); }
|
header { height: 52px; flex: none; display: flex; align-items: center; gap: 8px; padding: 0 8px 0 14px; border-bottom: 1px solid var(--line); color: var(--text-dim); }
|
||||||
h2 { flex: 1; min-width: 0; margin: 0; font-size: 15px; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
h2 { flex: 1; min-width: 0; margin: 0; font-size: 15px; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
|||||||
@@ -59,6 +59,10 @@
|
|||||||
|
|
||||||
{#if tab === 'voice'}
|
{#if tab === 'voice'}
|
||||||
<VoiceSettings />
|
<VoiceSettings />
|
||||||
|
<h3>Screen sharing</h3>
|
||||||
|
<label for="s-stun">STUN server <span class="opt">(optional)</span></label>
|
||||||
|
<input id="s-stun" bind:value={s.stunServer} onchange={() => store.saveSettings()} placeholder="stun.example.com:3478" autocomplete="off" />
|
||||||
|
<p class="help">Screen sharing connects you directly to the people watching. Across the internet both the person sharing and the people watching need a STUN server here to find a route; it learns your IP address but never sees the stream. Empty means no outside server is contacted, which only works on the same network.</p>
|
||||||
{:else if tab === 'sounds'}
|
{:else if tab === 'sounds'}
|
||||||
<SoundSettings />
|
<SoundSettings />
|
||||||
{:else if tab === 'appearance'}
|
{:else if tab === 'appearance'}
|
||||||
@@ -119,6 +123,7 @@
|
|||||||
.tabs button { padding: 8px 14px; color: var(--text-dim); font-weight: 600; white-space: nowrap; }
|
.tabs button { padding: 8px 14px; color: var(--text-dim); font-weight: 600; white-space: nowrap; }
|
||||||
.tabs button.active { color: var(--text); box-shadow: inset 0 -2px 0 var(--accent); }
|
.tabs button.active { color: var(--text); box-shadow: inset 0 -2px 0 var(--accent); }
|
||||||
h3 { margin: 22px 0 0; font-size: 14px; }
|
h3 { margin: 22px 0 0; font-size: 14px; }
|
||||||
|
.opt { text-transform: none; font-weight: 400; letter-spacing: 0; color: var(--text-faint); }
|
||||||
.help { margin: 10px 0 0; font-size: 13px; color: var(--text-dim); }
|
.help { margin: 10px 0 0; font-size: 13px; color: var(--text-dim); }
|
||||||
.row { display: flex; align-items: flex-end; gap: 8px; }
|
.row { display: flex; align-items: flex-end; gap: 8px; }
|
||||||
.row > div { flex: 1; }
|
.row > div { flex: 1; }
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
import Modal from './Modal.svelte';
|
||||||
|
import { share, QUALITY } from '../lib/share.svelte.ts';
|
||||||
|
import { desktop, type ScreenSource } from '../lib/native.ts';
|
||||||
|
import { store } from '../lib/settings.svelte.ts';
|
||||||
|
|
||||||
|
// Everything is chosen here, with a preview, before anything is sent: what to show, which
|
||||||
|
// sound goes with it and the quality. In a browser, and on Wayland, the system's own dialog
|
||||||
|
// picks the screen or window.
|
||||||
|
let { onclose }: { onclose: () => void } = $props();
|
||||||
|
const target = share.dialog!;
|
||||||
|
|
||||||
|
let info = $state<{ portal: boolean; audio: 'pipewire' | 'loopback' | 'browser' | 'none'; micLabel: string }>({ portal: true, audio: desktop ? 'none' : 'browser', micLabel: '' });
|
||||||
|
let sources = $state<ScreenSource[]>([]);
|
||||||
|
let selected = $state('');
|
||||||
|
let preview = $state.raw<MediaStream | null>(null);
|
||||||
|
let apps = $state<string[]>([]);
|
||||||
|
// '' none, 'all' everything, 'app:<name>' one program
|
||||||
|
let audio = $state('');
|
||||||
|
let browserAudio = $state(false);
|
||||||
|
let busy = $state(false);
|
||||||
|
let error = $state('');
|
||||||
|
let handedOver = false;
|
||||||
|
let video = $state<HTMLVideoElement>();
|
||||||
|
$effect(() => { if (video) video.srcObject = preview; });
|
||||||
|
|
||||||
|
const fps = () => QUALITY[store.settings.shareQuality].fps;
|
||||||
|
|
||||||
|
let listing = $state(false);
|
||||||
|
async function loadSources() {
|
||||||
|
if (!desktop) return;
|
||||||
|
listing = true;
|
||||||
|
try {
|
||||||
|
sources = (await desktop.screenSources(false)).sources;
|
||||||
|
} finally {
|
||||||
|
listing = false;
|
||||||
|
}
|
||||||
|
// Pictures follow when they are ready
|
||||||
|
const pictures = new Map((await desktop.screenSources(true)).sources.map(s => [s.id, s.thumbnail]));
|
||||||
|
sources = sources.map(s => ({ ...s, thumbnail: pictures.get(s.id) ?? s.thumbnail }));
|
||||||
|
}
|
||||||
|
const loadApps = async () => { if (desktop && info.audio === 'pipewire') apps = await desktop.shareAudioApps(); };
|
||||||
|
|
||||||
|
if (desktop) {
|
||||||
|
desktop.shareInfo().then(async i => {
|
||||||
|
info = i;
|
||||||
|
loadApps();
|
||||||
|
if (!i.portal) await loadSources();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropPreview() {
|
||||||
|
preview?.getTracks().forEach(t => t.stop());
|
||||||
|
preview = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function show(id: string) {
|
||||||
|
error = '';
|
||||||
|
dropPreview();
|
||||||
|
selected = id;
|
||||||
|
try {
|
||||||
|
desktop!.pickScreenSource(id);
|
||||||
|
preview = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: fps() }, audio: false });
|
||||||
|
} catch (e) {
|
||||||
|
error = `Could not capture this: ${(e as Error).message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The system's or the browser's picker
|
||||||
|
async function chooseElsewhere() {
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
if (desktop) {
|
||||||
|
const { sources: picked } = await desktop.screenSources(false);
|
||||||
|
if (!picked.length) return;
|
||||||
|
sources = picked;
|
||||||
|
await show(picked[0].id);
|
||||||
|
} else {
|
||||||
|
dropPreview();
|
||||||
|
preview = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: fps() }, audio: browserAudio });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if ((e as Error).name !== 'NotAllowedError') error = `Could not capture the screen: ${(e as Error).message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The virtual microphone shows up as a recording device a moment after it is created
|
||||||
|
async function virtualMic(): Promise<MediaStreamTrack> {
|
||||||
|
const find = async () => (await navigator.mediaDevices.enumerateDevices()).find(d => d.kind === 'audioinput' && d.label.includes(info.micLabel));
|
||||||
|
let device = await find();
|
||||||
|
for (let i = 0; i < 20 && !device; i++) {
|
||||||
|
await new Promise(r => setTimeout(r, 150));
|
||||||
|
device = await find();
|
||||||
|
}
|
||||||
|
if (!device) throw new Error('The audio capture device did not appear.');
|
||||||
|
const s = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: { deviceId: { exact: device.deviceId }, echoCancellation: false, autoGainControl: false, noiseSuppression: false, channelCount: 2 }
|
||||||
|
});
|
||||||
|
return s.getAudioTracks()[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
if (!preview) return;
|
||||||
|
busy = true;
|
||||||
|
error = '';
|
||||||
|
let usesVirtualMic = false;
|
||||||
|
try {
|
||||||
|
if (audio && info.audio === 'pipewire') {
|
||||||
|
usesVirtualMic = true;
|
||||||
|
await desktop!.shareAudioStart(audio === 'all' ? { kind: 'all' } : { kind: 'app', name: audio.slice(4) });
|
||||||
|
preview.addTrack(await virtualMic());
|
||||||
|
} else if (audio && info.audio === 'loopback') {
|
||||||
|
// System sound comes with a capture request; only its audio track is kept
|
||||||
|
desktop!.pickScreenSource(selected);
|
||||||
|
const extra = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true });
|
||||||
|
extra.getVideoTracks().forEach(t => t.stop());
|
||||||
|
const track = extra.getAudioTracks()[0];
|
||||||
|
if (track) preview.addTrack(track);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (usesVirtualMic) desktop!.shareAudioStop();
|
||||||
|
error = `Could not capture the sound: ${(e as Error).message}`;
|
||||||
|
busy = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handedOver = true;
|
||||||
|
share.go(target, preview, usesVirtualMic);
|
||||||
|
onclose();
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy(() => { if (!handedOver) dropPreview(); });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal title="Share your screen" {onclose} width={760}>
|
||||||
|
<p class="help">mumh5 users in your channel can watch. Nothing is sent until you press Start sharing.</p>
|
||||||
|
|
||||||
|
{#if !desktop && info.audio === 'browser'}
|
||||||
|
<label class="check"><input type="checkbox" bind:checked={browserAudio} disabled={!!preview} /> Include sound (the browser offers what it can share, usually a tab's sound)</label>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if desktop && !info.portal}
|
||||||
|
<div class="row head"><label for="sh-src">Screen or window</label><button class="link" onclick={loadSources}>Refresh</button></div>
|
||||||
|
<ul class="sources" id="sh-src">
|
||||||
|
{#each sources as s (s.id)}
|
||||||
|
<li>
|
||||||
|
<button class:active={selected === s.id} aria-pressed={selected === s.id} onclick={() => show(s.id)}>
|
||||||
|
{#if s.thumbnail}<img src={s.thumbnail} alt="" />{:else}<span class="blank"></span>{/if}
|
||||||
|
<span class="name">{s.name}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{:else}
|
||||||
|
<li class="dim">{listing ? 'Looking for screens and windows...' : 'Nothing found. Try Refresh.'}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{:else}
|
||||||
|
<div class="row"><button class="btn" onclick={chooseElsewhere}>{preview ? 'Choose another screen or window...' : 'Choose a screen or window...'}</button></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<span class="label">Preview</span>
|
||||||
|
<div class="preview">
|
||||||
|
<!-- svelte-ignore a11y_media_has_caption -->
|
||||||
|
<video bind:this={video} autoplay playsinline muted></video>
|
||||||
|
{#if !preview}<p>Nothing chosen yet</p>{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row two">
|
||||||
|
{#if desktop}
|
||||||
|
<div>
|
||||||
|
<label for="sh-audio">Sound</label>
|
||||||
|
{#if info.audio === 'pipewire'}
|
||||||
|
<select id="sh-audio" bind:value={audio} onfocus={loadApps}>
|
||||||
|
<option value="">No sound</option>
|
||||||
|
<option value="all">Everything except mumh5</option>
|
||||||
|
{#each apps as a (a)}<option value={`app:${a}`}>{a}</option>{/each}
|
||||||
|
</select>
|
||||||
|
{:else if info.audio === 'loopback'}
|
||||||
|
<select id="sh-audio" bind:value={audio}>
|
||||||
|
<option value="">No sound</option>
|
||||||
|
<option value="all">Whole system (viewers also hear the voice chat again)</option>
|
||||||
|
</select>
|
||||||
|
{:else}
|
||||||
|
<select id="sh-audio" disabled><option>Not available on this system</option></select>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div>
|
||||||
|
<label for="sh-quality">Quality</label>
|
||||||
|
<select id="sh-quality" bind:value={store.settings.shareQuality} onchange={() => store.saveSettings()}>
|
||||||
|
{#each Object.entries(QUALITY) as [id, q] (id)}<option value={id}>{q.label}</option>{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}<p class="err">{error}</p>{/if}
|
||||||
|
|
||||||
|
{#snippet footer()}
|
||||||
|
<button class="btn" onclick={onclose}>Cancel</button>
|
||||||
|
<button class="btn primary" disabled={!preview || busy} onclick={start}>{busy ? 'Starting...' : 'Start sharing'}</button>
|
||||||
|
{/snippet}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.help { margin: 8px 0 0; font-size: 13px; color: var(--text-dim); }
|
||||||
|
.row { display: flex; align-items: center; gap: 8px; margin-top: 12px; }
|
||||||
|
.row.head { justify-content: space-between; margin-top: 0; }
|
||||||
|
.row.two { align-items: flex-end; gap: 12px; }
|
||||||
|
.row.two > div { flex: 1; min-width: 0; }
|
||||||
|
.label { display: block; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-dim); margin: 14px 0 6px; }
|
||||||
|
.link { color: var(--link); font-size: 13px; margin-top: 8px; }
|
||||||
|
.sources { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px; max-height: 190px; overflow-y: auto; }
|
||||||
|
.sources button { width: 100%; display: flex; flex-direction: column; gap: 4px; padding: 6px; text-align: left; border: 1px solid var(--line); background: var(--bg-0); }
|
||||||
|
.sources button:hover { border-color: var(--text-faint); }
|
||||||
|
.sources button.active { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
|
||||||
|
img, .blank { width: 100%; aspect-ratio: 16 / 9; object-fit: contain; background: #000; display: block; }
|
||||||
|
.name { font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.preview { position: relative; background: #000; aspect-ratio: 16 / 9; max-height: 240px; margin: 0 auto; width: 100%; }
|
||||||
|
.preview video { width: 100%; height: 100%; object-fit: contain; display: block; }
|
||||||
|
.preview p { position: absolute; inset: 0; margin: 0; display: flex; align-items: center; justify-content: center; color: #aaa; font-size: 13px; }
|
||||||
|
.check { display: flex; align-items: center; gap: 8px; text-transform: none; font-weight: 400; letter-spacing: 0; font-size: 14px; color: var(--text); margin-top: 12px; }
|
||||||
|
.check input { width: auto; }
|
||||||
|
.dim { color: var(--text-dim); font-size: 13px; }
|
||||||
|
.err { color: var(--danger); font-size: 13px; margin: 10px 0 0; }
|
||||||
|
</style>
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
import { PERM } from '../core/client.ts';
|
import { PERM } from '../core/client.ts';
|
||||||
import ResizeHandle from './ResizeHandle.svelte';
|
import ResizeHandle from './ResizeHandle.svelte';
|
||||||
import { voice } from '../lib/audio/voice.svelte.ts';
|
import { voice } from '../lib/audio/voice.svelte.ts';
|
||||||
|
import { share } from '../lib/share.svelte.ts';
|
||||||
|
|
||||||
// resizable: false in the phone drawer, which keeps a fixed width
|
// resizable: false in the phone drawer, which keeps a fixed width
|
||||||
// handleEdge: which side the resize handle sits on (left when the list is on the right, classic layout)
|
// handleEdge: which side the resize handle sits on (left when the list is on the right, classic layout)
|
||||||
@@ -50,6 +51,14 @@
|
|||||||
ui.panelOpen = true;
|
ui.panelOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sharing runs on one server at a time; the button stops it from anywhere
|
||||||
|
const sharingHere = $derived(!!share.stream);
|
||||||
|
const canShare = $derived(session.status === 'connected' && share.supported(sessions.active));
|
||||||
|
function toggleShare() {
|
||||||
|
if (share.stream) share.stop();
|
||||||
|
else share.start(sessions.active);
|
||||||
|
}
|
||||||
|
|
||||||
// One button for the whole tree: open everything, then close everything
|
// One button for the whole tree: open everything, then close everything
|
||||||
let allOpen = $state(false);
|
let allOpen = $state(false);
|
||||||
function toggleAll() {
|
function toggleAll() {
|
||||||
@@ -159,6 +168,10 @@
|
|||||||
onclick={() => session.setSelfDeaf(!deafened)} oncontextmenu={e => openQuick(e, 'output')}>
|
onclick={() => session.setSelfDeaf(!deafened)} oncontextmenu={e => openQuick(e, 'output')}>
|
||||||
<Icon name={deafened ? 'headphones-off' : 'headphones'} size={18} />
|
<Icon name={deafened ? 'headphones-off' : 'headphones'} size={18} />
|
||||||
</button>
|
</button>
|
||||||
|
<button class="icon-btn" class:live={sharingHere} title={sharingHere ? 'Stop sharing your screen' : 'Share your screen with your channel'}
|
||||||
|
aria-label={sharingHere ? 'Stop sharing your screen' : 'Share your screen'} disabled={!sharingHere && !canShare} onclick={toggleShare}>
|
||||||
|
<Icon name="screen" size={18} />
|
||||||
|
</button>
|
||||||
<span class="sep"></span>
|
<span class="sep"></span>
|
||||||
<button class="icon-btn" title="Change your description" aria-label="Change your description" disabled={!self} onclick={editDescription}>
|
<button class="icon-btn" title="Change your description" aria-label="Change your description" disabled={!self} onclick={editDescription}>
|
||||||
<Icon name="message" size={18} />
|
<Icon name="message" size={18} />
|
||||||
@@ -216,5 +229,6 @@
|
|||||||
.who strong, .who span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.who strong, .who span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.who span { font-size: 12px; color: var(--text-dim); }
|
.who span { font-size: 12px; color: var(--text-dim); }
|
||||||
.info, .info:hover { color: var(--warn); }
|
.info, .info:hover { color: var(--warn); }
|
||||||
|
.live, .live:hover { color: var(--speaking); }
|
||||||
.offline-who { flex: 1 1 110px; padding: 2px 2px 2px 6px; color: var(--text-dim); }
|
.offline-who { flex: 1 1 110px; padding: 2px 2px 2px 6px; color: var(--text-dim); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+139
-9
@@ -7,11 +7,45 @@
|
|||||||
import { ui } from '../lib/ui.svelte.ts';
|
import { ui } from '../lib/ui.svelte.ts';
|
||||||
import { userMenu } from '../lib/actions.ts';
|
import { userMenu } from '../lib/actions.ts';
|
||||||
import { voice } from '../lib/audio/voice.svelte.ts';
|
import { voice } from '../lib/audio/voice.svelte.ts';
|
||||||
|
import { share } from '../lib/share.svelte.ts';
|
||||||
|
import { sessions } from '../lib/session.svelte.ts';
|
||||||
|
|
||||||
|
// People in your voice channel as tiles. Shown on windows too narrow for the member list
|
||||||
|
// (always), and on any window while someone in the channel shares a screen: a sharer's tile
|
||||||
|
// carries the picture, and clicking it puts it on a large stage with the tiles as a strip.
|
||||||
|
let { always = false }: { always?: boolean } = $props();
|
||||||
|
|
||||||
// People in your voice channel as tiles, for windows too narrow for the member list
|
|
||||||
const self = $derived(session.self);
|
const self = $derived(session.self);
|
||||||
const channel = $derived(self ? session.channel(self.channelId) : undefined);
|
const channel = $derived(self ? session.channel(self.channelId) : undefined);
|
||||||
const people = $derived(self ? session.usersIn(self.channelId) : []);
|
const people = $derived(self ? session.usersIn(self.channelId) : []);
|
||||||
|
const here = (s: { server?: { id: string } | null } | null | undefined) => !!s && s.server?.id === session.server?.id;
|
||||||
|
|
||||||
|
const watching = $derived(share.watching && here(share.watching.host) ? share.watching : null);
|
||||||
|
const mine = $derived(share.stream && here(share.host) ? share.stream : null);
|
||||||
|
const anySharing = $derived(people.some(u => session.sharing[u.session]));
|
||||||
|
const visible = $derived(!!self && (always || anySharing || !!watching || !!mine));
|
||||||
|
|
||||||
|
// The picture a tile can show: our own stream, or the one we are watching
|
||||||
|
function streamOf(s: number): MediaStream | null {
|
||||||
|
if (s === self?.session) return mine;
|
||||||
|
return watching?.session === s ? watching.stream : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tile on the large stage
|
||||||
|
let focus = $state<number | null>(null);
|
||||||
|
const focused = $derived(focus != null ? streamOf(focus) : null);
|
||||||
|
const focusUser = $derived(focus != null ? session.user(focus) : undefined);
|
||||||
|
// A stream we just started watching goes to the stage; it leaves when the stream ends
|
||||||
|
let lastWatch: number | null = null;
|
||||||
|
$effect(() => {
|
||||||
|
const now = watching?.session ?? null;
|
||||||
|
if (now !== lastWatch) {
|
||||||
|
if (now != null) focus = now;
|
||||||
|
else if (focus === lastWatch) focus = null;
|
||||||
|
lastWatch = now;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
$effect(() => { if (focus === self?.session && !mine) focus = null; });
|
||||||
|
|
||||||
let collapsed = $state(load());
|
let collapsed = $state(load());
|
||||||
function load(): boolean {
|
function load(): boolean {
|
||||||
@@ -23,10 +57,39 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const talking = (s: number) => !!voice.talking[s] || (s === self?.session && voice.transmitting);
|
const talking = (s: number) => !!voice.talking[s] || (s === self?.session && voice.transmitting);
|
||||||
|
|
||||||
|
// The same stream can sit in a tile and on the stage at once
|
||||||
|
function media(node: HTMLMediaElement, stream: MediaStream | null) {
|
||||||
|
node.srcObject = stream;
|
||||||
|
return { update(next: MediaStream | null) { if (node.srcObject !== next) node.srcObject = next; } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sound comes from one hidden player, so it plays once however many pictures are shown
|
||||||
|
let player = $state<HTMLAudioElement>();
|
||||||
|
const hasSound = $derived(!!watching?.stream?.getAudioTracks().length);
|
||||||
|
$effect(() => {
|
||||||
|
if (!player) return;
|
||||||
|
player.volume = store.settings.shareVolume;
|
||||||
|
player.muted = store.settings.shareVolume === 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
function tileClick(session_: number) {
|
||||||
|
if (streamOf(session_)) focus = focus === session_ ? null : session_;
|
||||||
|
else if (session.sharing[session_] && session_ !== self?.session) share.watch(sessions.active, session_);
|
||||||
|
else { session.showUser(session_); ui.panelOpen = true; }
|
||||||
|
}
|
||||||
|
|
||||||
|
let frame = $state<HTMLElement>();
|
||||||
|
function fullscreen() {
|
||||||
|
if (document.fullscreenElement) document.exitFullscreen();
|
||||||
|
else frame?.requestFullscreen().catch(() => {});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if self}
|
{#if share.error}<div class="bar bad" role="alert"><span>{share.error}</span><button class="btn" onclick={() => (share.error = '')}>Dismiss</button></div>{/if}
|
||||||
<section class="stage" aria-label="Voice channel">
|
|
||||||
|
{#if visible && self}
|
||||||
|
<section class="stage" class:meeting={!!focused} aria-label="Voice channel">
|
||||||
<header>
|
<header>
|
||||||
<button class="title" onclick={toggle} aria-expanded={!collapsed}>
|
<button class="title" onclick={toggle} aria-expanded={!collapsed}>
|
||||||
<svg class="caret" class:collapsed width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><path d="M2 3l3 4 3-4" fill="none" stroke="currentColor" stroke-width="1.6" /></svg>
|
<svg class="caret" class:collapsed width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><path d="M2 3l3 4 3-4" fill="none" stroke="currentColor" stroke-width="1.6" /></svg>
|
||||||
@@ -38,6 +101,10 @@
|
|||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
{#if mine}
|
||||||
|
<span class="status" role="status">You are sharing your screen{mine.getAudioTracks().length ? ' with sound' : ''}{share.viewers ? `, ${share.viewers} watching` : ''}</span>
|
||||||
|
<button class="btn small" onclick={() => share.stop()}>Stop sharing</button>
|
||||||
|
{/if}
|
||||||
<button class="icon-btn" class:on={self.selfMute} aria-label={self.selfMute ? 'Unmute' : 'Mute'} title={self.selfMute ? 'Unmute' : 'Mute'}
|
<button class="icon-btn" class:on={self.selfMute} aria-label={self.selfMute ? 'Unmute' : 'Mute'} title={self.selfMute ? 'Unmute' : 'Mute'}
|
||||||
onclick={() => session.setSelfMute(!self.selfMute)}>
|
onclick={() => session.setSelfMute(!self.selfMute)}>
|
||||||
<Icon name={self.selfMute ? 'mic-off' : 'mic'} size={18} />
|
<Icon name={self.selfMute ? 'mic-off' : 'mic'} size={18} />
|
||||||
@@ -47,16 +114,52 @@
|
|||||||
<Icon name={self.selfDeaf ? 'headphones-off' : 'headphones'} size={18} />
|
<Icon name={self.selfDeaf ? 'headphones-off' : 'headphones'} size={18} />
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{#if watching?.stream}<audio bind:this={player} use:media={watching.stream} autoplay></audio>{/if}
|
||||||
|
|
||||||
|
{#if focused && focus != null}
|
||||||
|
<div class="spot" role="region" aria-label="Screen share" bind:this={frame}>
|
||||||
|
<div class="spot-head">
|
||||||
|
<Icon name="screen" size={16} />
|
||||||
|
<span class="spot-who">{focus === self.session ? 'Your screen' : focusUser ? store.displayName(focusUser) : 'Screen share'}</span>
|
||||||
|
{#if focus !== self.session && hasSound}
|
||||||
|
<Icon name={store.settings.shareVolume === 0 ? 'headphones-off' : 'headphones'} size={16} />
|
||||||
|
<input class="vol" type="range" min="0" max="1" step="0.01" bind:value={store.settings.shareVolume} onchange={() => store.saveSettings()}
|
||||||
|
aria-label="Stream volume" title="Stream volume" />
|
||||||
|
{/if}
|
||||||
|
<button class="icon-btn" title="Full screen" aria-label="Full screen" onclick={fullscreen}><Icon name="expand" size={16} /></button>
|
||||||
|
{#if focus !== self.session}<button class="btn small" onclick={() => share.unwatch()}>Stop watching</button>{/if}
|
||||||
|
<button class="icon-btn" title="Back to tiles" aria-label="Back to tiles" onclick={() => (focus = null)}><Icon name="x" size={16} /></button>
|
||||||
|
</div>
|
||||||
|
<!-- svelte-ignore a11y_media_has_caption -->
|
||||||
|
<video use:media={focused} autoplay playsinline muted ondblclick={fullscreen}></video>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if !collapsed}
|
{#if !collapsed}
|
||||||
<ul class="tiles">
|
<ul class="tiles" class:strip={!!focused}>
|
||||||
{#each people as u (u.session)}
|
{#each people as u (u.session)}
|
||||||
<li>
|
{@const stream = streamOf(u.session)}
|
||||||
<button class="tile" class:talking={talking(u.session)} class:self={u.session === self.session}
|
{@const sharing = !!session.sharing[u.session]}
|
||||||
onclick={() => { session.showUser(u.session); ui.panelOpen = true; }}
|
{@const isSelf = u.session === self.session}
|
||||||
oncontextmenu={e => menu.show(e, userMenu(u))} title={store.displayName(u)}>
|
<li class:wide={!!stream}>
|
||||||
|
<button class="tile" class:talking={talking(u.session)} class:self={isSelf} class:video={!!stream} class:focused={focus === u.session}
|
||||||
|
onclick={() => tileClick(u.session)}
|
||||||
|
oncontextmenu={e => menu.show(e, userMenu(u))}
|
||||||
|
title={stream ? (focus === u.session ? 'Back to tiles' : 'Show large') : sharing && !isSelf ? 'Sharing their screen. Click to watch' : store.displayName(u)}
|
||||||
|
aria-label={stream ? `${focus === u.session ? 'Shrink' : 'Enlarge'} the screen of ${store.displayName(u)}` : sharing && !isSelf ? `Watch the screen of ${store.displayName(u)}` : store.displayName(u)}>
|
||||||
|
{#if stream}
|
||||||
|
<!-- svelte-ignore a11y_media_has_caption -->
|
||||||
|
<video use:media={stream} autoplay playsinline muted></video>
|
||||||
|
{:else}
|
||||||
<Avatar name={u.name} size={44} speaking={talking(u.session)} />
|
<Avatar name={u.name} size={44} speaking={talking(u.session)} />
|
||||||
|
{/if}
|
||||||
<span class="who">{store.displayName(u)}</span>
|
<span class="who">{store.displayName(u)}</span>
|
||||||
{#if u.deaf || u.selfDeaf}
|
{#if watching?.session === u.session && watching.state !== 'live'}
|
||||||
|
<span class="note">{watching.state === 'connecting' ? 'Connecting...' : 'Could not connect'}</span>
|
||||||
|
{/if}
|
||||||
|
{#if sharing}<span class="badge live"><Icon name="screen" size={13} /></span>
|
||||||
|
{:else if u.deaf || u.selfDeaf}
|
||||||
<span class="badge" class:admin={u.deaf}><Icon name="headphones-off" size={13} /></span>
|
<span class="badge" class:admin={u.deaf}><Icon name="headphones-off" size={13} /></span>
|
||||||
{:else if u.mute || u.selfMute || u.suppress}
|
{:else if u.mute || u.selfMute || u.suppress}
|
||||||
<span class="badge" class:admin={u.mute || u.suppress}><Icon name="mic-off" size={13} /></span>
|
<span class="badge" class:admin={u.mute || u.suppress}><Icon name="mic-off" size={13} /></span>
|
||||||
@@ -65,6 +168,9 @@
|
|||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
{#if watching?.state === 'failed'}
|
||||||
|
<p class="failed">Could not connect to the stream. Across the internet both of you need a STUN server set in Settings, Voice (the person sharing has to restart the stream after setting it).</p>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -89,4 +195,28 @@
|
|||||||
.tile.self .who { color: var(--text); font-weight: 600; }
|
.tile.self .who { color: var(--text); font-weight: 600; }
|
||||||
.badge { position: absolute; top: 4px; right: 4px; display: inline-flex; color: var(--text-faint); }
|
.badge { position: absolute; top: 4px; right: 4px; display: inline-flex; color: var(--text-faint); }
|
||||||
.badge.admin { color: var(--danger); }
|
.badge.admin { color: var(--danger); }
|
||||||
|
.badge.live { color: var(--speaking); }
|
||||||
|
|
||||||
|
/* Streams */
|
||||||
|
.bar { flex: none; display: flex; align-items: center; gap: 8px; padding: 6px 12px; font-size: 13px; background: var(--bg-1); border-bottom: 1px solid var(--line); }
|
||||||
|
.bar span { flex: 1; min-width: 0; }
|
||||||
|
.bar.bad { color: var(--danger); }
|
||||||
|
.btn.small, .bar .btn { padding: 4px 10px; font-size: 12px; flex: none; }
|
||||||
|
.status { font-size: 12px; color: var(--speaking); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; margin-right: 4px; }
|
||||||
|
.tiles li.wide { grid-column: span 2; }
|
||||||
|
.tile.video { padding: 0 0 6px; overflow: hidden; }
|
||||||
|
.tile.video video { width: 100%; aspect-ratio: 16 / 9; object-fit: contain; background: #000; display: block; }
|
||||||
|
.tile.focused { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
|
||||||
|
.note { font-size: 11px; color: var(--text-faint); }
|
||||||
|
.failed { margin: 0; padding: 0 12px 10px; font-size: 12px; color: var(--danger); }
|
||||||
|
/* Meeting view: the chosen picture large, everyone else in one row below */
|
||||||
|
.stage.meeting { display: flex; flex-direction: column; max-height: 72%; min-height: 220px; }
|
||||||
|
.spot { flex: 1; min-height: 0; display: flex; flex-direction: column; background: #000; }
|
||||||
|
.spot-head { flex: none; display: flex; align-items: center; gap: 8px; padding: 2px 6px 2px 12px; background: var(--bg-0); color: var(--text-dim); font-size: 13px; }
|
||||||
|
.spot-who { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); font-weight: 600; }
|
||||||
|
.spot video { flex: 1; min-height: 0; width: 100%; object-fit: contain; background: #000; display: block; }
|
||||||
|
.vol { width: 110px; flex: none; padding: 0; }
|
||||||
|
.tiles.strip { flex: none; display: flex; overflow-x: auto; overflow-y: hidden; padding-top: 8px; max-height: none; }
|
||||||
|
.tiles.strip li { flex: 0 0 96px; }
|
||||||
|
.tiles.strip li.wide { flex-basis: 150px; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -886,6 +886,8 @@ try {
|
|||||||
// Custom icon from the tile's context menu: square-cropped and shown instead of initials
|
// Custom icon from the tile's context menu: square-cropped and shown instead of initials
|
||||||
const iconFile = path.join(userData, 'icon.png');
|
const iconFile = path.join(userData, 'icon.png');
|
||||||
writeFileSync(iconFile, testPng(300, 200));
|
writeFileSync(iconFile, testPng(300, 200));
|
||||||
|
// With hints off, the title of whatever the pointer rests on is set aside; move off the tile first
|
||||||
|
await page.mouse.move(700, 400);
|
||||||
await page.locator('.rail .tile[title^="Second"]').click({ button: 'right' });
|
await page.locator('.rail .tile[title^="Second"]').click({ button: 'right' });
|
||||||
const chooser = page.waitForEvent('filechooser');
|
const chooser = page.waitForEvent('filechooser');
|
||||||
await page.getByRole('menuitem', { name: 'Change icon...' }).click();
|
await page.getByRole('menuitem', { name: 'Change icon...' }).click();
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
// Screen sharing between two instances of the built app on a real Mumble server.
|
||||||
|
// npm run build && MUMBLE_TEST_HOST=localhost:64739 npm run test:e2e:share
|
||||||
|
import { _electron as electron, type Page } from 'playwright-core';
|
||||||
|
import electronPath from 'electron';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdtempSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const target = process.env.MUMBLE_TEST_HOST;
|
||||||
|
if (!target) {
|
||||||
|
console.log('MUMBLE_TEST_HOST not set, skipping');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
const [host, port] = target.split(':');
|
||||||
|
const root = path.resolve(import.meta.dirname, '../..');
|
||||||
|
const { ELECTRON_RUN_AS_NODE, ...env } = process.env;
|
||||||
|
const tag = Date.now() % 100000;
|
||||||
|
|
||||||
|
async function launch() {
|
||||||
|
const app = await electron.launch({
|
||||||
|
executablePath: electronPath as unknown as string,
|
||||||
|
args: [root, `--user-data-dir=${mkdtempSync(path.join(tmpdir(), 'mumh5-e2e-'))}`, '--ozone-platform=x11',
|
||||||
|
'--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'],
|
||||||
|
env: env as Record<string, string>
|
||||||
|
});
|
||||||
|
const page = await app.firstWindow();
|
||||||
|
page.on('console', m => { if (m.type() === 'error') console.log('[renderer]', m.text()); });
|
||||||
|
await page.setViewportSize({ width: 1280, height: 800 });
|
||||||
|
return { app, page };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identity wizard, then connect; the username comes from the identity
|
||||||
|
async function join(page: Page, name: string) {
|
||||||
|
await page.getByRole('dialog', { name: 'Set up your identity' }).waitFor();
|
||||||
|
await page.getByRole('button', { name: /Create a new identity/ }).click();
|
||||||
|
await page.getByLabel('Name', { exact: true }).fill(name);
|
||||||
|
await page.getByRole('button', { name: 'Create', exact: true }).click();
|
||||||
|
await page.getByRole('button', { name: 'Skip for now' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Done' }).click();
|
||||||
|
await page.getByTitle('Add a server').click();
|
||||||
|
await page.getByLabel('Address').fill(host);
|
||||||
|
await page.getByLabel('Port').fill(port);
|
||||||
|
await page.getByRole('button', { name: 'Save and connect' }).click();
|
||||||
|
await page.getByText(/Connected/).first().waitFor();
|
||||||
|
}
|
||||||
|
|
||||||
|
const alice = await launch();
|
||||||
|
const bob = await launch();
|
||||||
|
const aliceName = `share-a-${tag}`, bobName = `share-b-${tag}`;
|
||||||
|
try {
|
||||||
|
await join(alice.page, aliceName);
|
||||||
|
|
||||||
|
// Alice starts sharing: a note about IP addresses once, then the source picker
|
||||||
|
const shareBtn = alice.page.locator('.me').getByRole('button', { name: 'Share your screen' });
|
||||||
|
await shareBtn.click();
|
||||||
|
await alice.page.getByText(/You see each other's IP address/).waitFor();
|
||||||
|
await alice.page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
// The dialog: pick a source, see the preview, then start
|
||||||
|
const picker = alice.page.getByRole('dialog', { name: 'Share your screen' });
|
||||||
|
const startBtn = picker.getByRole('button', { name: 'Start sharing' });
|
||||||
|
const pickAndStart = async () => {
|
||||||
|
assert.ok(await startBtn.isDisabled(), 'nothing can start before a source is chosen');
|
||||||
|
// Listing is slow on the bare test display and can come back empty the first time
|
||||||
|
for (let i = 0; i < 6 && !(await picker.locator('.sources button').count()); i++) {
|
||||||
|
await Promise.race([picker.locator('.sources button').first().waitFor({ timeout: 40000 }), picker.getByText('Nothing found').waitFor({ timeout: 40000 })]).catch(() => {});
|
||||||
|
if (await picker.getByText('Nothing found').count()) await picker.getByRole('button', { name: 'Refresh' }).click();
|
||||||
|
}
|
||||||
|
await picker.locator('.sources button').first().click();
|
||||||
|
await alice.page.waitForFunction(() => (document.querySelector('.preview video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 15000 });
|
||||||
|
await startBtn.click();
|
||||||
|
};
|
||||||
|
await picker.getByLabel('Quality').selectOption('low');
|
||||||
|
await pickAndStart();
|
||||||
|
await alice.page.getByText('You are sharing your screen').waitFor();
|
||||||
|
await alice.page.locator('.sidebar .row.user.self').getByTitle('You are sharing your screen').waitFor();
|
||||||
|
// The sharer's own tile shows what is being sent; clicking it makes it large
|
||||||
|
await alice.page.waitForFunction(() => (document.querySelector('.stage .tile.self video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 15000 });
|
||||||
|
await alice.page.locator('.stage .tile.self').click();
|
||||||
|
const ownView = alice.page.getByRole('region', { name: 'Screen share' });
|
||||||
|
await ownView.getByText('Your screen').waitFor();
|
||||||
|
await ownView.getByRole('button', { name: 'Back to tiles' }).click();
|
||||||
|
await ownView.waitFor({ state: 'detached' });
|
||||||
|
console.log('ok: sharing started from the dialog, own tile shows the stream');
|
||||||
|
|
||||||
|
// Bob connects afterwards and still learns about the stream
|
||||||
|
await join(bob.page, bobName);
|
||||||
|
// Next to the name in the channel list, and on the tile above the chat
|
||||||
|
const watch = bob.page.locator('.sidebar').getByRole('button', { name: `Watch the screen of ${aliceName}`, exact: true });
|
||||||
|
await watch.waitFor();
|
||||||
|
const tile = bob.page.locator('.stage .tile', { hasText: aliceName });
|
||||||
|
await tile.waitFor();
|
||||||
|
console.log('ok: a late joiner sees who is sharing');
|
||||||
|
|
||||||
|
await tile.click();
|
||||||
|
await bob.page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
const view = bob.page.getByRole('region', { name: 'Screen share' });
|
||||||
|
await view.getByText(aliceName).waitFor();
|
||||||
|
await bob.page.waitForFunction(() => (document.querySelector('.spot video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 20000 });
|
||||||
|
const size = await bob.page.evaluate(() => { const v = document.querySelector('.spot video') as HTMLVideoElement; return [v.videoWidth, v.videoHeight]; });
|
||||||
|
await alice.page.getByText('You are sharing your screen, 1 watching').waitFor();
|
||||||
|
console.log(`ok: bob receives the picture (${size[0]}x${size[1]}), alice sees one viewer`);
|
||||||
|
|
||||||
|
// Like a meeting: back to tiles keeps the picture in the tile, a click makes it large again
|
||||||
|
await view.getByRole('button', { name: 'Back to tiles' }).click();
|
||||||
|
await view.waitFor({ state: 'detached' });
|
||||||
|
await bob.page.waitForFunction(() => (document.querySelector('.stage .tile.video video') as HTMLVideoElement | null)?.videoWidth! > 0);
|
||||||
|
await tile.click();
|
||||||
|
await view.waitFor();
|
||||||
|
console.log('ok: stream in the tile, large on click');
|
||||||
|
|
||||||
|
// Leaving and coming back
|
||||||
|
await view.getByRole('button', { name: 'Stop watching' }).click();
|
||||||
|
await view.waitFor({ state: 'detached' });
|
||||||
|
await alice.page.getByText('You are sharing your screen', { exact: true }).waitFor();
|
||||||
|
// The sharer's profile says so and offers to watch
|
||||||
|
await bob.page.locator('.sidebar .row.user', { hasText: aliceName }).click();
|
||||||
|
const profile = bob.page.locator('.panel .sharing');
|
||||||
|
await profile.getByText('Sharing their screen').waitFor();
|
||||||
|
await profile.getByRole('button', { name: 'Watch' }).click();
|
||||||
|
await profile.getByRole('button', { name: 'Stop watching' }).waitFor();
|
||||||
|
await bob.page.waitForFunction(() => (document.querySelector('.spot video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 20000 });
|
||||||
|
console.log('ok: stop watching, watch again from the profile');
|
||||||
|
|
||||||
|
// Alice stops: the view and the indicator go away
|
||||||
|
await alice.page.locator('.me').getByRole('button', { name: 'Stop sharing your screen' }).click();
|
||||||
|
await view.waitFor({ state: 'detached' });
|
||||||
|
await watch.waitFor({ state: 'detached' });
|
||||||
|
await profile.waitFor({ state: 'detached' });
|
||||||
|
assert.equal(await alice.page.getByText('You are sharing your screen').count(), 0);
|
||||||
|
console.log('ok: stopping ends the stream for viewers');
|
||||||
|
|
||||||
|
// A sharer who disconnects takes the stream with them
|
||||||
|
await shareBtn.click();
|
||||||
|
await pickAndStart();
|
||||||
|
await watch.waitFor();
|
||||||
|
await alice.page.getByTitle('Disconnect').click();
|
||||||
|
await watch.waitFor({ state: 'detached' });
|
||||||
|
console.log('ok: disconnecting ends the stream');
|
||||||
|
|
||||||
|
console.log('SHARE E2E PASSED');
|
||||||
|
} finally {
|
||||||
|
await alice.app.close().catch(() => {});
|
||||||
|
await bob.app.close().catch(() => {});
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// A bare browser window for the web E2E: no preload, so the page runs as it would in a browser.
|
// A bare browser window for the web E2E: no preload, so the page runs as it would in a browser.
|
||||||
const { app, BrowserWindow, session } = require('electron');
|
const { app, BrowserWindow, desktopCapturer, session } = require('electron');
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
|
|
||||||
@@ -11,6 +11,12 @@ app.whenReady().then(() => {
|
|||||||
item.setSavePath(file);
|
item.setSavePath(file);
|
||||||
item.once('done', (_ev, state) => { if (state === 'completed') fs.writeFileSync(file + '.done', ''); });
|
item.once('done', (_ev, state) => { if (state === 'completed') fs.writeFileSync(file + '.done', ''); });
|
||||||
});
|
});
|
||||||
|
// Stands in for the browser's own "choose what to share" dialog: always the first screen
|
||||||
|
session.defaultSession.setDisplayMediaRequestHandler(async (_request, callback) => {
|
||||||
|
let sources = [];
|
||||||
|
for (let i = 0; i < 10 && !sources.length; i++) sources = await desktopCapturer.getSources({ types: ['screen'] });
|
||||||
|
try { callback(sources.length ? { video: sources[0] } : {}); } catch { /* refused */ }
|
||||||
|
});
|
||||||
const win = new BrowserWindow({ width: 1280, height: 800, webPreferences: { contextIsolation: true, sandbox: true, nodeIntegration: false } });
|
const win = new BrowserWindow({ width: 1280, height: 800, webPreferences: { contextIsolation: true, sandbox: true, nodeIntegration: false } });
|
||||||
win.loadURL(process.env.MUMH5_WEB_URL);
|
win.loadURL(process.env.MUMH5_WEB_URL);
|
||||||
});
|
});
|
||||||
|
|||||||
+33
-2
@@ -42,7 +42,7 @@ function within<T>(p: Promise<T>, label: string, ms = 10000): Promise<T> {
|
|||||||
const proxyPort = 18000 + Math.floor(Math.random() * 1000);
|
const proxyPort = 18000 + Math.floor(Math.random() * 1000);
|
||||||
const { ELECTRON_RUN_AS_NODE, ...env } = process.env;
|
const { ELECTRON_RUN_AS_NODE, ...env } = process.env;
|
||||||
const proxy = spawn(process.execPath, [path.join(root, 'dist-proxy/proxy.mjs')], {
|
const proxy = spawn(process.execPath, [path.join(root, 'dist-proxy/proxy.mjs')], {
|
||||||
env: { ...env, MUMH5_PORT: String(proxyPort), MUMH5_SERVERS: `${target}=Test Server` }, stdio: ['ignore', 'pipe', 'inherit']
|
env: { ...env, MUMH5_PORT: String(proxyPort), MUMH5_SERVERS: `${target}=Test Server`, MUMH5_STUN_PORT: String(proxyPort + 1000), MUMH5_STUN_BIND: '127.0.0.1' }, stdio: ['ignore', 'pipe', 'inherit']
|
||||||
});
|
});
|
||||||
await within(new Promise<void>((res, rej) => {
|
await within(new Promise<void>((res, rej) => {
|
||||||
proxy.stdout.on('data', d => { if (String(d).includes('listening')) res(); });
|
proxy.stdout.on('data', d => { if (String(d).includes('listening')) res(); });
|
||||||
@@ -50,12 +50,14 @@ await within(new Promise<void>((res, rej) => {
|
|||||||
}), 'proxy start');
|
}), 'proxy start');
|
||||||
|
|
||||||
const downloads = mkdtempSync(path.join(tmpdir(), 'mumh5-e2e-dl-'));
|
const downloads = mkdtempSync(path.join(tmpdir(), 'mumh5-e2e-dl-'));
|
||||||
const app = await electron.launch({
|
const browser = () => electron.launch({
|
||||||
executablePath: electronPath as unknown as string,
|
executablePath: electronPath as unknown as string,
|
||||||
args: [path.join(root, 'test/e2e/web-shell.cjs'), `--user-data-dir=${mkdtempSync(path.join(tmpdir(), 'mumh5-e2e-'))}`, '--ozone-platform=x11',
|
args: [path.join(root, 'test/e2e/web-shell.cjs'), `--user-data-dir=${mkdtempSync(path.join(tmpdir(), 'mumh5-e2e-'))}`, '--ozone-platform=x11',
|
||||||
'--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'],
|
'--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'],
|
||||||
env: { ...env, MUMH5_WEB_URL: `http://127.0.0.1:${proxyPort}/`, MUMH5_DOWNLOADS: downloads } as Record<string, string>
|
env: { ...env, MUMH5_WEB_URL: `http://127.0.0.1:${proxyPort}/`, MUMH5_DOWNLOADS: downloads } as Record<string, string>
|
||||||
});
|
});
|
||||||
|
const app = await browser();
|
||||||
|
let second: Awaited<ReturnType<typeof browser>> | null = null;
|
||||||
const bob = await headless('bob');
|
const bob = await headless('bob');
|
||||||
const name = `webalice${Date.now() % 100000}`;
|
const name = `webalice${Date.now() % 100000}`;
|
||||||
try {
|
try {
|
||||||
@@ -160,9 +162,38 @@ try {
|
|||||||
assert.equal(hash(), before, 'same certificate after a reload');
|
assert.equal(hash(), before, 'same certificate after a reload');
|
||||||
console.log('ok: identity kept across a reload');
|
console.log('ok: identity kept across a reload');
|
||||||
|
|
||||||
|
// Screen sharing between two browsers: the proxy's own STUN is used without any setting
|
||||||
|
assert.equal((await (await fetch(`http://127.0.0.1:${proxyPort}/api/config`)).json()).stun, proxyPort + 1000);
|
||||||
|
second = await browser();
|
||||||
|
const page2 = await second.firstWindow();
|
||||||
|
page2.on('console', m => { if (m.type() === 'error') console.log('[page2]', m.text()); });
|
||||||
|
await page2.setViewportSize({ width: 1280, height: 800 });
|
||||||
|
await page2.getByRole('button', { name: /Create a new identity/ }).click();
|
||||||
|
await page2.getByLabel('Name', { exact: true }).fill(`webcarol${Date.now() % 100000}`);
|
||||||
|
await page2.getByRole('button', { name: 'Create', exact: true }).click();
|
||||||
|
await page2.getByRole('button', { name: 'Skip for now' }).click();
|
||||||
|
await page2.getByRole('button', { name: 'Done' }).click();
|
||||||
|
await page2.getByTitle('Add a server').click();
|
||||||
|
await page2.getByRole('button', { name: 'Save and connect' }).click();
|
||||||
|
await page2.getByText(/Connected/).first().waitFor();
|
||||||
|
|
||||||
|
await page.locator('.me').getByRole('button', { name: 'Share your screen' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
const dialog = page.getByRole('dialog', { name: 'Share your screen' });
|
||||||
|
await dialog.getByRole('button', { name: 'Choose a screen or window...' }).click();
|
||||||
|
await page.waitForFunction(() => (document.querySelector('.preview video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 60000 });
|
||||||
|
await dialog.getByRole('button', { name: 'Start sharing' }).click();
|
||||||
|
await page.getByText('You are sharing your screen').waitFor();
|
||||||
|
await page2.locator('.stage .tile', { hasText: name }).click();
|
||||||
|
await page2.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await page2.waitForFunction(() => (document.querySelector('.spot video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 30000 });
|
||||||
|
await page.getByText('You are sharing your screen, 1 watching').waitFor();
|
||||||
|
console.log('ok: screen sharing between two browsers');
|
||||||
|
|
||||||
console.log('WEB E2E PASSED');
|
console.log('WEB E2E PASSED');
|
||||||
} finally {
|
} finally {
|
||||||
bob.disconnect();
|
bob.disconnect();
|
||||||
await app.close().catch(() => {});
|
await app.close().catch(() => {});
|
||||||
|
await second?.close().catch(() => {});
|
||||||
proxy.kill();
|
proxy.kill();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { audioApps, planLinks, VIRTUAL_MIC } from '../electron/pipewire.ts';
|
||||||
|
|
||||||
|
// A cut-down pw-dump: the virtual microphone (10), this app (20, process 500), a browser whose
|
||||||
|
// stream has no name of its own (30, named by its client), a mono game (40) and a music player (50)
|
||||||
|
const node = (id: number, props: Record<string, unknown>) => ({ id, type: 'PipeWire:Interface:Node', info: { props } });
|
||||||
|
const client = (id: number, name: string, pid: number) => ({ id, type: 'PipeWire:Interface:Client', info: { props: { 'application.name': name, 'application.process.id': pid } } });
|
||||||
|
const port = (id: number, nodeId: number, direction: string, channel: string) => ({ id, type: 'PipeWire:Interface:Port', info: { direction, props: { 'node.id': nodeId, 'audio.channel': channel } } });
|
||||||
|
const link = (id: number, outPort: number, inPort: number, inNode: number) =>
|
||||||
|
({ id, type: 'PipeWire:Interface:Link', info: { 'output-port-id': outPort, 'input-port-id': inPort, 'input-node-id': inNode } as any });
|
||||||
|
|
||||||
|
const graph = [
|
||||||
|
node(10, { 'node.name': VIRTUAL_MIC, 'media.class': 'Audio/Source/Virtual' }), port(11, 10, 'input', 'FL'), port(12, 10, 'input', 'FR'),
|
||||||
|
port(13, 10, 'output', 'FL'), port(14, 10, 'output', 'FR'),
|
||||||
|
client(2, 'mumh5', 500), node(20, { 'media.class': 'Stream/Output/Audio', 'node.name': 'mumh5', 'client.id': 2 }), port(21, 20, 'output', 'FL'), port(22, 20, 'output', 'FR'),
|
||||||
|
client(3, 'Firefox', 600), node(30, { 'media.class': 'Stream/Output/Audio', 'node.name': 'Firefox', 'client.id': 3 }), port(31, 30, 'output', 'FL'), port(32, 30, 'output', 'FR'),
|
||||||
|
node(40, { 'media.class': 'Stream/Output/Audio', 'application.name': 'Game', 'application.process.id': 700 }), port(41, 40, 'output', 'MONO'),
|
||||||
|
node(50, { 'media.class': 'Stream/Output/Audio', 'application.name': 'Spotify' }), port(51, 50, 'output', 'FL'), port(52, 50, 'output', 'FR'), port(53, 50, 'output', 'LFE'),
|
||||||
|
node(60, { 'media.class': 'Audio/Sink', 'node.name': 'speakers' })
|
||||||
|
];
|
||||||
|
|
||||||
|
test('lists programs that play sound, without this app', () => {
|
||||||
|
assert.deepEqual(audioApps(graph, [500]), ['Firefox', 'Game', 'Spotify']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('whole system links every program but this app', () => {
|
||||||
|
const { add, remove } = planLinks(graph, { kind: 'all' }, [500]);
|
||||||
|
assert.deepEqual(add.sort(), [[31, 11], [32, 12], [41, 11], [41, 12], [51, 11], [52, 12]].sort());
|
||||||
|
assert.deepEqual(remove, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('one program links only that program', () => {
|
||||||
|
assert.deepEqual(planLinks(graph, { kind: 'app', name: 'Firefox' }, [500]).add, [[31, 11], [32, 12]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('existing links are kept, stale ones removed', () => {
|
||||||
|
const linked = [...graph, link(90, 31, 11, 10), link(91, 51, 11, 10), link(92, 21, 60, 60)];
|
||||||
|
const { add, remove } = planLinks(linked, { kind: 'app', name: 'Firefox' }, [500]);
|
||||||
|
assert.deepEqual(add, [[32, 12]]);
|
||||||
|
// Spotify's link goes; the link to the speakers is none of our business
|
||||||
|
assert.deepEqual(remove, [91]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nothing without the virtual microphone', () => {
|
||||||
|
assert.deepEqual(planLinks(graph.slice(5), { kind: 'all' }, []), { add: [], remove: [] });
|
||||||
|
});
|
||||||
+46
-2
@@ -2,8 +2,10 @@
|
|||||||
// real Mumble server and are skipped unless MUMBLE_TEST_HOST is set (see server.test.ts).
|
// real Mumble server and are skipped unless MUMBLE_TEST_HOST is set (see server.test.ts).
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import net from 'node:net';
|
import net from 'node:net';
|
||||||
|
import dgram from 'node:dgram';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { proxyLine } from '../electron/tls-transport.ts';
|
import { proxyLine } from '../electron/tls-transport.ts';
|
||||||
|
import { stunResponse } from '../server/proxy.ts';
|
||||||
import { startProxy, defaults, parseServers, isPrivateAddress, type ProxyConfig } from '../server/proxy.ts';
|
import { startProxy, defaults, parseServers, isPrivateAddress, type ProxyConfig } from '../server/proxy.ts';
|
||||||
import { WebSocket as WsClient } from 'ws';
|
import { WebSocket as WsClient } from 'ws';
|
||||||
import { WebSocketTransport } from '../src/core/ws-transport.ts';
|
import { WebSocketTransport } from '../src/core/ws-transport.ts';
|
||||||
@@ -40,7 +42,7 @@ test('refuses to start without allowed servers', async () => {
|
|||||||
|
|
||||||
test('config lists the allowed servers', async () => {
|
test('config lists the allowed servers', async () => {
|
||||||
await withProxy({ servers: parseServers('voice.example.org=Friends') }, async base => {
|
await withProxy({ servers: parseServers('voice.example.org=Friends') }, async base => {
|
||||||
assert.deepEqual(await (await fetch(`${base}/api/config`)).json(), { servers: [{ host: 'voice.example.org', port: 64738, label: 'Friends' }], any: false });
|
assert.deepEqual(await (await fetch(`${base}/api/config`)).json(), { servers: [{ host: 'voice.example.org', port: 64738, label: 'Friends' }], any: false, stun: null });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -64,6 +66,47 @@ test('identity create, describe, export and import round trip', async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A binding request: type, length 0, magic cookie, 12 byte transaction id
|
||||||
|
const stunRequest = () => Uint8Array.from([0, 1, 0, 0, 0x21, 0x12, 0xa4, 0x42, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
|
||||||
|
|
||||||
|
test('STUN answers carry the sender address, masked as the protocol asks', () => {
|
||||||
|
const v4 = stunResponse(stunRequest(), '203.0.113.7', 54321)!;
|
||||||
|
assert.deepEqual([...v4.subarray(0, 4)], [1, 1, 0, 12]);
|
||||||
|
assert.deepEqual([...v4.subarray(4, 20)], [...stunRequest().subarray(4, 20)]);
|
||||||
|
assert.deepEqual([...v4.subarray(20, 26)], [0, 0x20, 0, 8, 0, 1]);
|
||||||
|
assert.equal(((v4[26] << 8) | v4[27]) ^ 0x2112, 54321);
|
||||||
|
assert.deepEqual([...v4.subarray(28)].map((b, i) => b ^ [0x21, 0x12, 0xa4, 0x42][i]), [203, 0, 113, 7]);
|
||||||
|
// An IPv4 sender seen through an IPv6 socket is still IPv4
|
||||||
|
assert.deepEqual([...stunResponse(stunRequest(), '::ffff:203.0.113.7', 54321)!], [...v4]);
|
||||||
|
const v6 = stunResponse(stunRequest(), '2001:db8::7', 1)!;
|
||||||
|
assert.equal(v6[25], 2);
|
||||||
|
const mask = stunRequest().subarray(4, 20);
|
||||||
|
assert.deepEqual([...v6.subarray(28)].map((b, i) => b ^ mask[i]), [0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 7]);
|
||||||
|
// Not a binding request
|
||||||
|
assert.equal(stunResponse(new Uint8Array(20), '203.0.113.7', 1), null);
|
||||||
|
assert.equal(stunResponse(stunRequest().subarray(0, 12), '203.0.113.7', 1), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the proxy answers STUN over UDP and announces the port', async () => {
|
||||||
|
const proxy = await startProxy({ ...defaults, port: 0, origins: ['*'], servers: parseServers('voice.example.org'), stunPort: 0, stunBind: '127.0.0.1' });
|
||||||
|
try {
|
||||||
|
assert.ok(proxy.stunPort);
|
||||||
|
assert.equal((await (await fetch(`http://127.0.0.1:${proxy.port}/api/config`)).json()).stun, proxy.stunPort);
|
||||||
|
const client = dgram.createSocket('udp4');
|
||||||
|
const answer = await new Promise<Buffer>((resolve, reject) => {
|
||||||
|
client.once('message', resolve);
|
||||||
|
client.once('error', reject);
|
||||||
|
client.send(stunRequest(), proxy.stunPort!, '127.0.0.1');
|
||||||
|
setTimeout(() => reject(new Error('no STUN answer')), 3000);
|
||||||
|
});
|
||||||
|
assert.equal(((answer[26] << 8) | answer[27]) ^ 0x2112, client.address().port);
|
||||||
|
assert.deepEqual([...answer.subarray(28)].map((b, i) => b ^ [0x21, 0x12, 0xa4, 0x42][i]), [127, 0, 0, 1]);
|
||||||
|
client.close();
|
||||||
|
} finally {
|
||||||
|
await proxy.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('requests from other origins are refused', async () => {
|
test('requests from other origins are refused', async () => {
|
||||||
await withProxy({ servers: parseServers('voice.example.org'), origins: [] }, async base => {
|
await withProxy({ servers: parseServers('voice.example.org'), origins: [] }, async base => {
|
||||||
const cross = await post(base, 'identity/create', { name: 'x' }, { Origin: 'https://evil.example' });
|
const cross = await post(base, 'identity/create', { name: 'x' }, { Origin: 'https://evil.example' });
|
||||||
@@ -181,7 +224,8 @@ test('two clients talk through the proxy', { skip: !target }, async () => {
|
|||||||
const a = await connect(`proxy-a-${Date.now() % 100000}`);
|
const a = await connect(`proxy-a-${Date.now() % 100000}`);
|
||||||
const b = await connect(`proxy-b-${Date.now() % 100000}`);
|
const b = await connect(`proxy-b-${Date.now() % 100000}`);
|
||||||
const got = new Promise<string>(resolve => b.on('text', m => resolve(m.html)));
|
const got = new Promise<string>(resolve => b.on('text', m => resolve(m.html)));
|
||||||
a.sendText({ channels: [0] }, 'hello through the proxy');
|
// A direct message, so other test files listening in the root channel are not disturbed
|
||||||
|
a.sendText({ users: [b.session!] }, 'hello through the proxy');
|
||||||
assert.equal(await got, 'hello through the proxy');
|
assert.equal(await got, 'hello through the proxy');
|
||||||
// No UDP in a browser: voice stays on the TCP tunnel
|
// No UDP in a browser: voice stays on the TCP tunnel
|
||||||
assert.equal(a.udpOk, false);
|
assert.equal(a.udpOk, false);
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { SHARE, encodeShare, ShareAssembler } from '../src/core/share-signal.ts';
|
||||||
|
|
||||||
|
test('bare signals are one byte', async () => {
|
||||||
|
const packets = await encodeShare(SHARE.on);
|
||||||
|
assert.deepEqual(packets, [new Uint8Array([SHARE.on])]);
|
||||||
|
assert.deepEqual(await new ShareAssembler().push(1, packets[0]), { type: SHARE.on });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('session descriptions survive chunking, in any order', async () => {
|
||||||
|
// Random-looking text compresses badly, forcing several chunks
|
||||||
|
const sdp = Array.from({ length: 400 }, (_, i) => `a=candidate:${(i * 2654435761 >>> 0).toString(36)} 1 udp ${i} 2001:db8::${i} typ host`).join('\r\n');
|
||||||
|
const packets = await encodeShare(SHARE.offer, sdp);
|
||||||
|
assert.ok(packets.length > 1, 'needs more than one packet');
|
||||||
|
// Murmur drops plugin data over 1000 bytes
|
||||||
|
assert.ok(packets.every(p => p.length <= 1000));
|
||||||
|
const assembler = new ShareAssembler();
|
||||||
|
let result = null;
|
||||||
|
for (const p of [...packets].reverse()) result = await assembler.push(7, p);
|
||||||
|
assert.deepEqual(result, { type: SHARE.offer, text: sdp });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('chunks from different senders do not mix', async () => {
|
||||||
|
const a = await encodeShare(SHARE.answer, Array.from({ length: 400 }, (_, i) => (i * 40503 * 2654435761 >>> 0).toString(36)).join(' '));
|
||||||
|
assert.ok(a.length > 1);
|
||||||
|
const assembler = new ShareAssembler();
|
||||||
|
assert.equal(await assembler.push(1, a[0]), null);
|
||||||
|
for (const p of a.slice(1)) assert.equal(await assembler.push(2, p), null);
|
||||||
|
let result = null;
|
||||||
|
for (const p of a.slice(1)) result = await assembler.push(1, p);
|
||||||
|
assert.equal(result?.type, SHARE.answer);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('garbage is ignored', async () => {
|
||||||
|
const assembler = new ShareAssembler();
|
||||||
|
assert.equal(await assembler.push(1, new Uint8Array([99])), null);
|
||||||
|
assert.equal(await assembler.push(1, new Uint8Array([SHARE.offer, 1])), null);
|
||||||
|
assert.equal(await assembler.push(1, new Uint8Array([SHARE.offer, 1, 5, 2, 1, 2, 3])), null);
|
||||||
|
// A complete transfer that is not valid compressed data
|
||||||
|
assert.equal(await assembler.push(1, new Uint8Array([SHARE.offer, 1, 0, 1, 255, 255, 255, 255])), null);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user