Add screen sharing between mumh5 users (picture only)
- Signals travel as Mumble plugin data (compressed, chunked under Murmur's 1000 byte limit); the picture goes directly to each viewer over WebRTC - Toolbar button with a source picker in the desktop app, an indicator next to people who share, click to watch, a view above the chat with full screen - Late joiners learn about running streams; moving, stopping or disconnecting ends them for viewers - A note about IP addresses before the first use, optional STUN server setting - Unit tests for the signal codec and an E2E with two app instances Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+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 os from 'node:os';
|
||||
import path from 'node:path';
|
||||
@@ -12,6 +12,11 @@ import { udpChannel } from './udp-voice.ts';
|
||||
|
||||
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;
|
||||
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)));
|
||||
|
||||
// ─── 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.handle('platform:info', () => ({ os: process.platform, osVersion: os.release() }));
|
||||
@@ -195,6 +224,13 @@ app.whenReady().then(async () => {
|
||||
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
|
||||
session.defaultSession.setPermissionRequestHandler((_wc, permission, cb) => {
|
||||
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)); },
|
||||
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'),
|
||||
pickScreenSource: (id: string) => ipcRenderer.send('share:pick', id),
|
||||
describeCerts: (ders: Uint8Array[]) => ipcRenderer.invoke('certs:describe', ders),
|
||||
identities: {
|
||||
list: () => ipcRenderer.invoke('identities:list'),
|
||||
|
||||
Reference in New Issue
Block a user