Screen sharing #5
@@ -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 (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.
|
||||||
- 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/
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -46,6 +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/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 desktop source picker is `share:sources` / `share:pick` in `electron/main.ts`.
|
||||||
- `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; main retries. 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`.
|
||||||
|
|||||||
@@ -76,6 +76,14 @@ 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
|
||||||
|
- 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
|
||||||
|
|
||||||
### 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
|
||||||
@@ -254,7 +262,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: sound (one application or the whole system, also on Linux through PipeWire), quality settings, and an optional self-hosted relay for many viewers
|
||||||
- Release builds for all platforms
|
- Release builds for all platforms
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+37
-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';
|
||||||
@@ -12,6 +12,11 @@ import { udpChannel } from './udp-voice.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 +125,30 @@ 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;
|
||||||
|
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++) {
|
||||||
|
await new Promise(r => setTimeout(r, 250));
|
||||||
|
screenSources = await list();
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
chosen: wayland && screenSources.length === 1,
|
||||||
|
sources: screenSources.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 +224,13 @@ 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 { callback(source ? { 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));
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ 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: () => ipcRenderer.invoke('share:sources'),
|
||||||
|
pickScreenSource: (id: string) => ipcRenderer.send('share:pick', id),
|
||||||
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"
|
||||||
},
|
},
|
||||||
|
|||||||
+3
-1
@@ -15,6 +15,7 @@
|
|||||||
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 SharePicker from './ui/SharePicker.svelte';
|
||||||
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 +130,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}
|
<SharePicker />
|
||||||
|
{#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,10 @@ 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
|
||||||
|
screenSources(): Promise<{ sources: ScreenSource[]; chosen: boolean }>;
|
||||||
|
pickScreenSource(id: string): 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 +57,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,8 @@ 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;
|
||||||
|
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 +54,8 @@ 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',
|
||||||
|
stunServer: '',
|
||||||
|
shareNoticeSeen: false,
|
||||||
hints: false
|
hints: false
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
// 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, type ScreenSource } 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 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('');
|
||||||
|
// Source choice in the desktop app (the browser brings its own picker)
|
||||||
|
picker = $state.raw<{ sources: ScreenSource[]; choose: (id: string | null) => void } | null>(null);
|
||||||
|
|
||||||
|
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();
|
||||||
|
return stun ? [{ urls: /^stuns?:/.test(stun) ? stun : `stun:${stun}` }] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
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); }
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async capture(): Promise<MediaStream> {
|
||||||
|
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<string | null>(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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(s: Session): Promise<void> {
|
||||||
|
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}`;
|
||||||
|
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.host = s;
|
||||||
|
this.stream = stream;
|
||||||
|
this.send(s, this.others(s), SHARE.on);
|
||||||
|
s.setSharing(s.client.session!, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = MAX_BITRATE;
|
||||||
|
await sender.setParameters(params).catch(() => {});
|
||||||
|
}
|
||||||
|
await pc.setLocalDescription(await pc.createOffer());
|
||||||
|
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(await pc.createAnswer());
|
||||||
|
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.dropPeers();
|
||||||
|
this.stream?.getTracks().forEach(t => t.stop());
|
||||||
|
this.stream = null;
|
||||||
|
this.host = 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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import ChatLog from './ChatLog.svelte';
|
import ChatLog from './ChatLog.svelte';
|
||||||
import Composer from './Composer.svelte';
|
import Composer from './Composer.svelte';
|
||||||
import VoiceStage from './VoiceStage.svelte';
|
import VoiceStage from './VoiceStage.svelte';
|
||||||
|
import ShareView from './ShareView.svelte';
|
||||||
import { ui } from '../lib/ui.svelte.ts';
|
import { ui } from '../lib/ui.svelte.ts';
|
||||||
import { session } from '../lib/session.svelte.ts';
|
import { session } from '../lib/session.svelte.ts';
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if showStage && session.status === 'connected'}<VoiceStage />{/if}
|
{#if showStage && session.status === 'connected'}<VoiceStage />{/if}
|
||||||
|
{#if session.status === 'connected'}<ShareView />{/if}
|
||||||
|
|
||||||
{#if session.status === 'idle' && session.disconnectInfo}
|
{#if session.status === 'idle' && session.disconnectInfo}
|
||||||
{@const d = session.disconnectInfo}
|
{@const d = session.disconnectInfo}
|
||||||
|
|||||||
@@ -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. Behind most home routers that needs a STUN server to find a route; it learns your IP address but never sees the stream. Empty means no outside server is contacted, which works on the same network and often over IPv6.</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,35 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Modal from './Modal.svelte';
|
||||||
|
import { share } from '../lib/share.svelte.ts';
|
||||||
|
|
||||||
|
// Desktop app only: choose the screen or window to share
|
||||||
|
const picker = $derived(share.picker);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if picker}
|
||||||
|
<Modal title="Share your screen" onclose={() => picker.choose(null)} width={720}>
|
||||||
|
<p class="help">Pick a screen or a window. mumh5 users in your channel can then watch it.</p>
|
||||||
|
<ul class="sources">
|
||||||
|
{#each picker.sources as s (s.id)}
|
||||||
|
<li>
|
||||||
|
<button onclick={() => picker.choose(s.id)}>
|
||||||
|
{#if s.thumbnail}<img src={s.thumbnail} alt="" />{:else}<span class="blank"></span>{/if}
|
||||||
|
<span class="name">{s.name}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{#snippet footer()}
|
||||||
|
<button class="btn" onclick={() => picker.choose(null)}>Cancel</button>
|
||||||
|
{/snippet}
|
||||||
|
</Modal>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.help { margin: 8px 0 0; font-size: 13px; color: var(--text-dim); }
|
||||||
|
.sources { list-style: none; margin: 12px 0 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 10px; }
|
||||||
|
.sources button { width: 100%; display: flex; flex-direction: column; gap: 6px; padding: 8px; text-align: left; border: 1px solid var(--line); background: var(--bg-0); }
|
||||||
|
.sources button:hover { border-color: var(--accent); }
|
||||||
|
img, .blank { width: 100%; aspect-ratio: 16 / 9; object-fit: contain; background: #000; display: block; }
|
||||||
|
.name { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from './Icon.svelte';
|
||||||
|
import { share } from '../lib/share.svelte.ts';
|
||||||
|
import { session } from '../lib/session.svelte.ts';
|
||||||
|
import { store } from '../lib/settings.svelte.ts';
|
||||||
|
|
||||||
|
// Above the chat: the stream being watched, and a note while we are sharing ourselves
|
||||||
|
const watching = $derived(share.watching && share.watching.host.server?.id === session.server?.id ? share.watching : null);
|
||||||
|
const sharer = $derived(watching ? session.user(watching.session) : undefined);
|
||||||
|
const mine = $derived(share.stream && share.host?.server?.id === session.server?.id);
|
||||||
|
|
||||||
|
let video = $state<HTMLVideoElement>();
|
||||||
|
let frame = $state<HTMLElement>();
|
||||||
|
$effect(() => { if (video) video.srcObject = watching?.stream ?? null; });
|
||||||
|
|
||||||
|
function fullscreen() {
|
||||||
|
if (document.fullscreenElement) document.exitFullscreen();
|
||||||
|
else frame?.requestFullscreen().catch(() => {});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if mine}
|
||||||
|
<div class="bar" role="status">
|
||||||
|
<Icon name="screen" size={16} />
|
||||||
|
<span>You are sharing your screen{share.viewers ? `, ${share.viewers} watching` : ''}</span>
|
||||||
|
<button class="btn" onclick={() => share.stop()}>Stop sharing</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if share.error}<div class="bar bad" role="alert"><span>{share.error}</span><button class="btn" onclick={() => (share.error = '')}>Dismiss</button></div>{/if}
|
||||||
|
|
||||||
|
{#if watching}
|
||||||
|
<section class="watch" aria-label="Screen share" bind:this={frame}>
|
||||||
|
<header>
|
||||||
|
<Icon name="screen" size={16} />
|
||||||
|
<span class="who">{sharer ? store.displayName(sharer) : 'Screen share'}</span>
|
||||||
|
<button class="icon-btn" title="Full screen" aria-label="Full screen" onclick={fullscreen}><Icon name="expand" size={16} /></button>
|
||||||
|
<button class="icon-btn" title="Stop watching" aria-label="Stop watching" onclick={() => share.unwatch()}><Icon name="x" size={16} /></button>
|
||||||
|
</header>
|
||||||
|
<div class="stagebox">
|
||||||
|
<!-- svelte-ignore a11y_media_has_caption -->
|
||||||
|
<video bind:this={video} autoplay playsinline muted ondblclick={fullscreen}></video>
|
||||||
|
{#if watching.state === 'connecting'}<p class="note">Connecting...</p>
|
||||||
|
{:else if watching.state === 'failed'}<p class="note">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.</p>{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.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); color: var(--text-dim); }
|
||||||
|
.bar span { flex: 1; min-width: 0; }
|
||||||
|
.bar .btn { padding: 4px 10px; font-size: 12px; }
|
||||||
|
.bar.bad { color: var(--danger); }
|
||||||
|
.watch { flex: none; display: flex; flex-direction: column; max-height: 62%; min-height: 160px; background: #000; border-bottom: 1px solid var(--line); }
|
||||||
|
.watch header { flex: none; display: flex; align-items: center; gap: 8px; padding: 2px 6px 2px 12px; background: var(--bg-1); color: var(--text-dim); font-size: 13px; }
|
||||||
|
.who { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); font-weight: 600; }
|
||||||
|
.stagebox { position: relative; flex: 1; min-height: 0; display: flex; }
|
||||||
|
video { width: 100%; height: 100%; min-height: 0; object-fit: contain; background: #000; display: block; }
|
||||||
|
.note { position: absolute; inset: 0; margin: 0; display: flex; align-items: center; justify-content: center; text-align: center; padding: 16px; color: #ddd; font-size: 14px; }
|
||||||
|
.watch:fullscreen { max-height: none; }
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// 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();
|
||||||
|
const picker = alice.page.getByRole('dialog', { name: 'Share your screen' });
|
||||||
|
await picker.locator('.sources button').first().click();
|
||||||
|
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');
|
||||||
|
|
||||||
|
// 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 });
|
||||||
|
await watch.waitFor();
|
||||||
|
console.log('ok: a late joiner sees who is sharing');
|
||||||
|
|
||||||
|
await watch.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 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`);
|
||||||
|
|
||||||
|
// 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');
|
||||||
|
|
||||||
|
// 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' });
|
||||||
|
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 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(() => {});
|
||||||
|
}
|
||||||
@@ -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