diff --git a/CHANGELOG.md b/CHANGELOG.md index 0567791..a52f0ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to mumh5. Versions follow the `version` in `package.json`. ### Added -- Screen sharing between mumh5 users in a channel (picture only so far): a toolbar button starts it, others click the indicator next to your name to watch. Streams go directly between clients; a STUN server can be set in Settings, Voice. +- Screen sharing between mumh5 users in a channel: a dialog to choose the screen or window (with preview), the sound and the quality; sound from one program or the whole system on Linux through PipeWire. Viewers watch in the voice tiles, enlarge a stream like in a meeting and set its volume. Streams go directly between clients; a STUN server can be set in Settings, Voice. - Browser version: `npm run build:web` builds the web app and a small self-hosted proxy that bridges browsers to Mumble servers on an allowlist. Identities are kept in the browser, voice goes through the TCP tunnel. See "Browser version" in the README. - Link previews in chat: title, description and image for web links (up to two per message). By default they are fetched by your f0ckm upload host, so the linked sites never see your IP address. Can be switched to "fetched by this computer" or off in Settings, Chat and files. - 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. diff --git a/CLAUDE.md b/CLAUDE.md index 04efade..2b1ba21 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,7 @@ A reused test server keeps registrations and channels from earlier runs; tests m - `src/core/` (browser-safe TypeScript, also runs in Node for tests): framing and codec (`proto.ts`), the Mumble client state machine (`client.ts`), voice packet formats (`voice-packet.ts`). No DOM, no Electron, no Node imports here. - `src/lib/native.ts`: `desktop` is the Electron preload API or null; `native` is what both platforms provide (identities, certificates), backed by `web.svelte.ts` in the browser build (`isWeb`, vite `--mode web`). Desktop-only features check `desktop`. - `src/lib/`: app state. `session.svelte.ts` has one `Session` per server plus the `sessions` manager; `session` is a Proxy to the active one. `audio/voice.svelte.ts` is the voice engine (WebCodecs Opus, capture and playback AudioWorklets). `html.ts` sanitizes incoming HTML and serializes outgoing rich text. -- 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 desktop source picker is `share:sources` / `share:pick` in `electron/main.ts`. +- 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). 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. @@ -64,7 +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. - `window.prompt` does not exist in Electron; use `ui.prompt`. -- `desktopCapturer.getSources` can return an empty list on its first calls under X11; main retries. On Wayland the call itself opens the system picker, so it is never repeated. +- `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. ## Svelte reactivity pitfalls (all hit before) diff --git a/README.md b/README.md index 9800877..4a820df 100644 --- a/README.md +++ b/README.md @@ -78,11 +78,12 @@ mumh5 keeps the foundation and replaces the experience. ### Screen sharing -- Share a screen or a window with the mumh5 users in your channel; they see an indicator next to your name and click it to watch -- No server setup and no extra account: the setup messages travel through the Mumble server, the picture goes directly between the two clients (WebRTC) -- Picture only for now, up to 8 viewers. Regular Mumble clients do not see streams +- 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. Behind most home routers a STUN server (Settings, Voice) is needed; none is contacted unless you enter one -- Tested between two desktop instances on one machine; connections across the internet and the browser build are untested +- 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 - Messages grouped by author, direct messages, unread badges @@ -262,7 +263,7 @@ Without an upload host, mumh5 still sends images, scaled to fit the server's lim - System-wide push to talk - Whisper and shout - Rich chat between mumh5 users: replies, reactions, edits, typing indicators -- Screen sharing: sound (one application or the whole system, also on Linux through PipeWire), quality settings, and 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 --- diff --git a/electron/main.ts b/electron/main.ts index c33439d..2c5465e 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -9,6 +9,7 @@ import { fetchLinkPreview } from './link-preview.ts'; import { fetchPublicListWith, pingServer } from './publist.ts'; import { openTls } from './tls-transport.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; @@ -131,20 +132,39 @@ ipcMain.handle('preview:fetch', (_e, url: string) => fetchLinkPreview(String(url // `chosen` tells the renderer not to ask a second time. let screenSources: Electron.DesktopCapturerSource[] = []; let pickedSource: string | null = null; -ipcMain.handle('share:sources', async () => { - const wayland = process.platform === 'linux' && app.commandLine.getSwitchValue('ozone-platform') !== 'x11' && - (process.env.XDG_SESSION_TYPE === 'wayland' || !!process.env.WAYLAND_DISPLAY); - const list = () => desktopCapturer.getSources({ types: ['screen', 'window'], thumbnailSize: { width: 320, height: 180 } }); - screenSources = await list(); - // The X11 capturer can come back empty on its first calls. Not on Wayland, where empty - // means the user cancelled the system's picker and asking again would reopen it. - for (let i = 0; i < 8 && !screenSources.length && !wayland; i++) { +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)); - screenSources = await list(); + found = await list(); } + if (found.length || !thumbnails) screenSources = found; return { - chosen: wayland && screenSources.length === 1, - sources: screenSources.map(s => ({ id: s.id, name: s.name, thumbnail: s.thumbnail.isEmpty() ? '' : s.thumbnail.toDataURL() })) + 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); }); @@ -224,11 +244,15 @@ app.whenReady().then(async () => { cb({ requestHeaders: details.requestHeaders }); }); - session.defaultSession.setDisplayMediaRequestHandler((_request, callback) => { + session.defaultSession.setDisplayMediaRequestHandler((request, callback) => { const source = screenSources.find(s => s.id === pickedSource); pickedSource = null; // Without a picked source the request is refused - try { callback(source ? { video: source } : {}); } catch { /* 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 diff --git a/electron/pipewire.ts b/electron/pipewire.ts new file mode 100644 index 0000000..6e5bffc --- /dev/null +++ b/electron/pipewire.ts @@ -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 } } + +function run(cmd: string, args: string[], maxBuffer = 64 * 1024 * 1024): Promise { + return new Promise((resolve, reject) => { + execFile(cmd, args, { maxBuffer, timeout: 5000 }, (err, stdout) => err ? reject(err) : resolve(stdout)); + }); +} + +const dump = async (): Promise => 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): { 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(); + 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(); + 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 { + 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 | undefined; + private busy: Promise = Promise.resolve(); + private ownPids: () => number[]; + + constructor(ownPids: () => number[]) { this.ownPids = ownPids; } + + async apps(): Promise { + 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 { + 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 { + 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 { + 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 */ } + } +} diff --git a/electron/preload.ts b/electron/preload.ts index e8c2b76..2f1dbc7 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -21,8 +21,12 @@ contextBridge.exposeInMainWorld('mumh5Native', { onContextMenu: (fn: (params: unknown) => void) => { ipcRenderer.on('context-menu', (_e, p) => fn(p)); }, 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)); }, - screenSources: () => ipcRenderer.invoke('share:sources'), + 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), identities: { list: () => ipcRenderer.invoke('identities:list'), diff --git a/src/App.svelte b/src/App.svelte index e39257b..eef5bc1 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -15,7 +15,8 @@ import ChannelDialog from './ui/ChannelDialog.svelte'; import PublicServers from './ui/PublicServers.svelte'; import ServerInfoDialog from './ui/ServerInfoDialog.svelte'; - import SharePicker from './ui/SharePicker.svelte'; + import ShareDialog from './ui/ShareDialog.svelte'; + import { share } from './lib/share.svelte.ts'; import ResizeHandle from './ui/ResizeHandle.svelte'; import { identities } from './lib/identities.svelte.ts'; import { session } from './lib/session.svelte.ts'; @@ -130,7 +131,7 @@ {#if ui.channelDialog && session.status === 'connected'} {#key ui.channelDialog} (ui.channelDialog = null)} />{/key} {/if} - +{#if share.dialog} (share.dialog = null)} />{/if} {#if ui.prompt} { const p = ui.prompt; ui.prompt = null; p?.oncancel?.(); }} />{/if} diff --git a/src/lib/native.ts b/src/lib/native.ts index 705be72..32f23ce 100644 --- a/src/lib/native.ts +++ b/src/lib/native.ts @@ -31,8 +31,15 @@ interface NativeApi extends PlatformApi { 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 - screenSources(): Promise<{ sources: ScreenSource[]; chosen: boolean }>; + // 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; + // Linux: route one program, or everything but this app, into a virtual microphone named micLabel + shareAudioStart(target: { kind: 'all' } | { kind: 'app'; name: string }): Promise; + shareAudioStop(): Promise; open(connId: string, host: string, port: number, identityId?: string): Promise; send(connId: string, bytes: Uint8Array): void; close(connId: string): void; diff --git a/src/lib/settings.svelte.ts b/src/lib/settings.svelte.ts index 5dff61b..707837e 100644 --- a/src/lib/settings.svelte.ts +++ b/src/lib/settings.svelte.ts @@ -24,6 +24,8 @@ export interface Settings { theme: ThemeId; publicListUrl: string; // where the public server list comes from 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 @@ -54,6 +56,8 @@ const defaults: Settings = { theme: 'dark', publicListUrl: 'https://publist.mumble.info/v1/list?version=1.5.0', arrangement: 'side', + shareQuality: 'medium', + shareVolume: 1, stunServer: '', shareNoticeSeen: false, hints: false diff --git a/src/lib/share.svelte.ts b/src/lib/share.svelte.ts index d0d93bc..7413365 100644 --- a/src/lib/share.svelte.ts +++ b/src/lib/share.svelte.ts @@ -5,13 +5,21 @@ 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, type ScreenSource } from './native.ts'; +import { desktop } from './native.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; -const MAX_BITRATE = 4_000_000; +export const QUALITY = { + low: { label: 'Low (1 Mbit/s, 30 fps)', bitrate: 1_000_000, fps: 30 }, + medium: { label: 'Medium (4 Mbit/s, 30 fps)', bitrate: 4_000_000, fps: 30 }, + high: { label: 'High (8 Mbit/s, 60 fps)', bitrate: 8_000_000, fps: 60 } +} as const; + +// Opus is negotiated as mono speech by default; shared sound is stereo and often music +const stereo = (sdp: string) => sdp.replace(/a=fmtp:(\d+) ([^\r\n]*useinbandfec=1[^\r\n]*)/g, + (line, pt, rest) => rest.includes('stereo=1') ? line : `a=fmtp:${pt} ${rest};stereo=1;sprop-stereo=1;maxaveragebitrate=192000`); export interface Watching { host: Session; @@ -27,8 +35,9 @@ class ScreenShare { viewers = $state(0); watching = $state.raw(null); error = $state(''); - // Source choice in the desktop app (the browser brings its own picker) - picker = $state.raw<{ sources: ScreenSource[]; choose: (id: string | null) => void } | null>(null); + // The server the start dialog is open for + dialog = $state.raw(null); + private usesVirtualMic = false; private peers = new Map(); private watchPc: RTCPeerConnection | null = null; @@ -75,55 +84,50 @@ class ScreenShare { }); } - private async capture(): Promise { - if (desktop) { - const { sources, chosen } = await desktop.screenSources(); - // Cancelled in the system's picker - if (!sources.length) throw new Error(''); - const id = chosen ? sources[0].id - : await new Promise(choose => { this.picker = { sources, choose }; }); - this.picker = null; - if (!id) throw new Error(''); - desktop.pickScreenSource(id); - } - return navigator.mediaDevices.getDisplayMedia({ video: { frameRate: 30 }, audio: false }); + // Opens the dialog where the source, the sound and the quality are chosen + async start(s: Session): Promise { + if (this.stream || this.dialog || !this.supported(s) || !(await this.notice())) return; + this.error = ''; + this.dialog = s; } - async start(s: Session): Promise { - if (this.stream || !this.supported(s) || !(await this.notice())) return; - this.error = ''; - let stream: MediaStream; - try { - stream = await this.capture(); - } catch (e) { - // Cancelling the picker is not an error worth showing - const msg = (e as Error).message; - if (msg && (e as Error).name !== 'NotAllowedError') this.error = `Could not capture the screen: ${msg}`; + // 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; } - // Disconnected while the picker was open - if (!s.client) return stream.getTracks().forEach(t => t.stop()); 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.dropPeers(); - this.stream?.getTracks().forEach(t => t.stop()); - this.stream = null; - this.host = null; + this.release(); } private dropPeers(): void { @@ -195,10 +199,10 @@ class ScreenShare { 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 = MAX_BITRATE; + for (const enc of params.encodings ?? []) enc.maxBitrate = QUALITY[store.settings.shareQuality].bitrate; await sender.setParameters(params).catch(() => {}); } - await pc.setLocalDescription(await pc.createOffer()); + 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); } @@ -215,7 +219,7 @@ class ScreenShare { }); try { await pc.setRemoteDescription({ type: 'offer', sdp }); - await pc.setLocalDescription(await pc.createAnswer()); + 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 { @@ -275,12 +279,8 @@ class ScreenShare { } closed(s: Session): void { - if (this.host === s) { - this.dropPeers(); - this.stream?.getTracks().forEach(t => t.stop()); - this.stream = null; - this.host = null; - } + if (this.host === s) this.release(); + if (this.dialog === s) this.dialog = null; if (this.watching?.host === s) this.endWatch(); } } diff --git a/src/ui/Chat.svelte b/src/ui/Chat.svelte index ff9590a..60b2346 100644 --- a/src/ui/Chat.svelte +++ b/src/ui/Chat.svelte @@ -3,7 +3,6 @@ import ChatLog from './ChatLog.svelte'; import Composer from './Composer.svelte'; import VoiceStage from './VoiceStage.svelte'; - import ShareView from './ShareView.svelte'; import { ui } from '../lib/ui.svelte.ts'; import { session } from '../lib/session.svelte.ts'; @@ -28,8 +27,7 @@ - {#if showStage && session.status === 'connected'}{/if} - {#if session.status === 'connected'}{/if} + {#if session.status === 'connected'}{/if} {#if session.status === 'idle' && session.disconnectInfo} {@const d = session.disconnectInfo} diff --git a/src/ui/RightPanel.svelte b/src/ui/RightPanel.svelte index 61431a1..f8de5b0 100644 --- a/src/ui/RightPanel.svelte +++ b/src/ui/RightPanel.svelte @@ -13,6 +13,8 @@ import { voice } from '../lib/audio/voice.svelte.ts'; import { renderIncoming } from '../lib/html.ts'; 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(); @@ -124,6 +126,16 @@ + {#if session.sharing[user.session]} + + {/if} + {#if isSelf}
{/if} @@ -158,6 +170,9 @@ diff --git a/src/ui/SharePicker.svelte b/src/ui/SharePicker.svelte deleted file mode 100644 index 7a39ff1..0000000 --- a/src/ui/SharePicker.svelte +++ /dev/null @@ -1,35 +0,0 @@ - - -{#if picker} - picker.choose(null)} width={720}> -

Pick a screen or a window. mumh5 users in your channel can then watch it.

-
    - {#each picker.sources as s (s.id)} -
  • - -
  • - {/each} -
- {#snippet footer()} - - {/snippet} -
-{/if} - - diff --git a/src/ui/ShareView.svelte b/src/ui/ShareView.svelte deleted file mode 100644 index e9dc267..0000000 --- a/src/ui/ShareView.svelte +++ /dev/null @@ -1,60 +0,0 @@ - - -{#if mine} -
- - You are sharing your screen{share.viewers ? `, ${share.viewers} watching` : ''} - -
-{/if} -{#if share.error}{/if} - -{#if watching} -
-
- - {sharer ? store.displayName(sharer) : 'Screen share'} - - -
-
- - - {#if watching.state === 'connecting'}

Connecting...

- {:else if watching.state === 'failed'}

Could not connect. You and the other person may both be behind routers that block direct connections; a STUN server in Settings, Voice can help.

{/if} -
-
-{/if} - - diff --git a/src/ui/VoiceStage.svelte b/src/ui/VoiceStage.svelte index d489011..8fab3d2 100644 --- a/src/ui/VoiceStage.svelte +++ b/src/ui/VoiceStage.svelte @@ -7,11 +7,45 @@ import { ui } from '../lib/ui.svelte.ts'; import { userMenu } from '../lib/actions.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 channel = $derived(self ? session.channel(self.channelId) : undefined); 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(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()); function load(): boolean { @@ -23,10 +57,39 @@ } 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(); + 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(); + function fullscreen() { + if (document.fullscreenElement) document.exitFullscreen(); + else frame?.requestFullscreen().catch(() => {}); + } -{#if self} -
+{#if share.error}{/if} + +{#if visible && self} +
+ {#if mine} + You are sharing your screen{mine.getAudioTracks().length ? ' with sound' : ''}{share.viewers ? `, ${share.viewers} watching` : ''} + + {/if}
+ + {#if watching?.stream}{/if} + + {#if focused && focus != null} +
+
+ + {focus === self.session ? 'Your screen' : focusUser ? store.displayName(focusUser) : 'Screen share'} + {#if focus !== self.session && hasSound} + + store.saveSettings()} + aria-label="Stream volume" title="Stream volume" /> + {/if} + + {#if focus !== self.session}{/if} + +
+ + +
+ {/if} + {#if !collapsed} -
    +
      {#each people as u (u.session)} -
    • -
    • {/each}
    + {#if watching?.state === 'failed'} +

    Could not connect to the stream. You and the other person may both be behind routers that block direct connections; a STUN server in Settings, Voice can help.

    + {/if} {/if}
{/if} @@ -89,4 +195,28 @@ .tile.self .who { color: var(--text); font-weight: 600; } .badge { position: absolute; top: 4px; right: 4px; display: inline-flex; color: var(--text-faint); } .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; } diff --git a/test/e2e/app.e2e.ts b/test/e2e/app.e2e.ts index 06e8866..0889fa3 100644 --- a/test/e2e/app.e2e.ts +++ b/test/e2e/app.e2e.ts @@ -886,6 +886,8 @@ try { // Custom icon from the tile's context menu: square-cropped and shown instead of initials const iconFile = path.join(userData, 'icon.png'); writeFileSync(iconFile, testPng(300, 200)); + // 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' }); const chooser = page.waitForEvent('filechooser'); await page.getByRole('menuitem', { name: 'Change icon...' }).click(); diff --git a/test/e2e/share.e2e.ts b/test/e2e/share.e2e.ts index f4246bf..3956cee 100644 --- a/test/e2e/share.e2e.ts +++ b/test/e2e/share.e2e.ts @@ -56,45 +56,83 @@ try { 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' }); - await picker.locator('.sources button').first().click(); + 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(); - console.log('ok: sharing started from the picker'); + // 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); - const watch = bob.page.getByRole("button", { name: `Watch the screen of ${aliceName}`, exact: true }); + // 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 watch.click(); + 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('.watch video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 20000 }); - const size = await bob.page.evaluate(() => { const v = document.querySelector('.watch video') as HTMLVideoElement; return [v.videoWidth, v.videoHeight]; }); + 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(); - await watch.click(); - await bob.page.waitForFunction(() => (document.querySelector('.watch video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 20000 }); - console.log('ok: stop watching and watch again'); + // 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 picker.locator('.sources button').first().click(); + await pickAndStart(); await watch.waitFor(); await alice.page.getByTitle('Disconnect').click(); await watch.waitFor({ state: 'detached' }); diff --git a/test/pipewire.test.ts b/test/pipewire.test.ts new file mode 100644 index 0000000..c83ed2a --- /dev/null +++ b/test/pipewire.test.ts @@ -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) => ({ 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: [] }); +}); diff --git a/test/proxy.test.ts b/test/proxy.test.ts index 68f1ec5..4ed867f 100644 --- a/test/proxy.test.ts +++ b/test/proxy.test.ts @@ -181,7 +181,8 @@ test('two clients talk through the proxy', { skip: !target }, async () => { const a = await connect(`proxy-a-${Date.now() % 100000}`); const b = await connect(`proxy-b-${Date.now() % 100000}`); const got = new Promise(resolve => b.on('text', m => resolve(m.html))); - a.sendText({ channels: [0] }, 'hello through the proxy'); + // A direct message, so other test files listening in the root channel are not disturbed + a.sendText({ users: [b.session!] }, 'hello through the proxy'); assert.equal(await got, 'hello through the proxy'); // No UDP in a browser: voice stays on the TCP tunnel assert.equal(a.udpOk, false);