From 819db06bc66ba6d01808f1b7b225d7d232db9512 Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Thu, 1 Oct 2026 23:52:18 +0200 Subject: [PATCH] Camera sharing, and the proxy's relay for the desktop app - A camera is a second source in the share dialog; streams carry their kind, so icons and wording follow. Browsers without screen capture (phones) start on the camera. - Settings, Voice: the address of a mumh5 web version as relay; its STUN and relay are then offered with every stream and take over when no direct connection comes up. - The proxy hands out relay credentials at GET /api/relay, readable from any origin. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 ++ CLAUDE.md | 2 +- README.md | 6 ++-- server/proxy.ts | 30 ++++++++++------- src/lib/actions.ts | 2 +- src/lib/icons.ts | 1 + src/lib/session.svelte.ts | 8 ++--- src/lib/settings.svelte.ts | 2 ++ src/lib/share.svelte.ts | 36 +++++++++++++-------- src/lib/web.svelte.ts | 36 ++++++++++----------- src/ui/ChannelNode.svelte | 7 ++-- src/ui/RightPanel.svelte | 4 +-- src/ui/SettingsDialog.svelte | 5 +++ src/ui/ShareDialog.svelte | 62 ++++++++++++++++++++++++++++++++---- src/ui/Sidebar.svelte | 6 ++-- src/ui/VoiceStage.svelte | 22 +++++++------ test/e2e/share.e2e.ts | 36 +++++++++++++++++++++ test/e2e/web.e2e.ts | 12 +++++++ test/proxy.test.ts | 7 ++-- 19 files changed, 208 insertions(+), 78 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a52f0ca..2ddcf2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable changes to mumh5. Versions follow the `version` in `package.json`. ### Added +- Camera sharing, as a second source in the screen sharing dialog. +- A relay for streams in the web proxy, used automatically by the browser version and, with its address entered in Settings, by the desktop app, for networks and browsers that allow no direct connection. - 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. diff --git a/CLAUDE.md b/CLAUDE.md index 05a353b..7c790ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,7 @@ A reused test server keeps registrations and channels from earlier runs; tests m ## Architecture - `electron/` (Node, main process): window, TLS sockets to Mumble servers (`tls-transport.ts`), encrypted UDP voice (`udp-voice.ts`, `ocb2.ts`, tested against Mumble's OCB2 vectors), identities and PKCS#12 (`identity.ts`, `identity-store.ts`), certificate parsing (`certs.ts`), tray (`tray.ts`). The renderer only gets the narrow `window.mumh5Native` API from `preload.ts` (context isolation, sandbox). -- `server/` (Node): the web proxy. `proxy.ts` serves `dist-web`, bridges WebSocket connections to Mumble over TLS (reusing `electron/tls-transport.ts`) and has stateless identity endpoints. It stores nothing; the browser keeps identities in localStorage and sends one with each connect. `turn.ts` is its STUN and TURN server for screen sharing between browser users (UDP and TCP on one port, credentials from `/api/turn`, secret made up at start). In the browser build `localStorage mumh5.iceDebug = relay` or `relay-tcp` limits a client to relayed routes, which is how the E2E exercises the relay. +- `server/` (Node): the web proxy. `proxy.ts` serves `dist-web`, bridges WebSocket connections to Mumble over TLS (reusing `electron/tls-transport.ts`) and has stateless identity endpoints. It stores nothing; the browser keeps identities in localStorage and sends one with each connect. `turn.ts` is its STUN and TURN server for screen sharing between browser users (UDP and TCP on one port, credentials from `GET /api/relay`, which any origin may read so the desktop app can use a proxy as its relay; secret made up at start). `localStorage mumh5.iceDebug = relay` or `relay-tcp` limits a client to relayed routes, which is how the E2E exercises the relay. - `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. diff --git a/README.md b/README.md index e0c26dd..482bee9 100644 --- a/README.md +++ b/README.md @@ -78,11 +78,11 @@ mumh5 keeps the foundation and replaces the experience. ### Screen sharing -- Share a screen or a window with the mumh5 users in your channel. A dialog lets you choose what to show, with a live preview, which sound goes with it and the quality, before anything is sent +- Share a screen, a window or a camera 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. The tiles above the chat fill a stage you can drag taller or shorter. A stream opens large, with everyone as a strip of tiles below, like a meeting, and can move to a window of its own; in the stacked layout it is just the picture, as wide as the chat. Sounds announce streams and viewers. Viewers set the stream's volume - No server setup and no extra account: the setup messages travel through the Mumble server, the stream goes directly between the two clients (WebRTC), up to 8 viewers. Regular Mumble clients do not see streams -- Direct connections mean sharer and viewer see each other's IP address; mumh5 says so before the first use. Across the internet both sides need a STUN server. The browser version uses the one built into its proxy, and the proxy's relay when no direct connection is possible (mobile networks, Vanadium); in the desktop app you choose one in Settings, Voice (Google, Cloudflare, or any address such as your proxy's), and none is contacted unless you do +- Direct connections mean sharer and viewer see each other's IP address; mumh5 says so before the first use. Across the internet both sides need a STUN server. The browser version uses the one built into its proxy, and the proxy's relay when no direct connection is possible (mobile networks, Vanadium); in the desktop app you choose a STUN server in Settings, Voice (Google, Cloudflare, or any address), and can enter the address of a mumh5 web version as a relay, which then takes over by itself when no direct connection comes up. Nothing is contacted unless you set it - 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 @@ -177,7 +177,7 @@ docker compose up -d --build The container uses the host's network: the proxy listens on `127.0.0.1:8080` for your reverse proxy, and a Mumble server on the same machine is reachable as `localhost`. -For screen sharing, open these in the firewall (and forward them on a router in front of the server): port 3478 for UDP and TCP, and UDP 49160-49359. They carry STUN and the relay, which the browser version uses without any setting. Relayed streams pass through your server and use its bandwidth, a few Mbit/s per viewer; only people using your site get credentials for it, and there is no bandwidth cap yet. +For screen sharing, open these in the firewall (and forward them on a router in front of the server): port 3478 for UDP and TCP, and UDP 49160-49359. They carry STUN and the relay, which the browser version uses without any setting. Relayed streams pass through your server and use its bandwidth, a few Mbit/s per viewer; anyone who knows the site's address can get credentials for it (the desktop app asks from outside the site), limited by connection quotas per address; there is no bandwidth cap yet. | Variable | Default | Meaning | | --- | --- | --- | diff --git a/server/proxy.ts b/server/proxy.ts index c80bff2..891812a 100644 --- a/server/proxy.ts +++ b/server/proxy.ts @@ -167,6 +167,20 @@ export async function startProxy(config: ProxyConfig): Promise<{ port: number; s if (route === 'config' && req.method === 'GET') { return { servers: config.servers, any: config.allowAny, stun: stunPort, turn: stunPort != null && config.turn }; } + // What a client needs for screen sharing: the STUN port and, with the relay on, credentials + // for it. Open to any origin, because the desktop app asks from outside this site; checking + // the origin would not keep a script out anyway. The quotas are what limit use. + if (route === 'relay' && req.method === 'GET') { + if (stunPort == null) throw new HttpError(404, 'No STUN or relay here'); + if (!config.turn) return { port: stunPort }; + const addr = addressOf(req); + const used = (identityUse.get(addr) ?? 0) + 1; + identityUse.set(addr, used); + if (used > 20) throw new HttpError(429, 'Too many requests, try again in a minute'); + // The username is the time it runs out; the relay recomputes the password from it + const username = String(Math.floor(Date.now() / 1000) + CREDENTIAL_TTL); + return { port: stunPort, username, credential: turnCredential(turnSecret, username), ttl: CREDENTIAL_TTL }; + } if (req.method !== 'POST') throw new HttpError(404, 'Not found'); if (!originOk(req)) throw new HttpError(403, 'Origin not allowed'); const body = await readJson(req); @@ -176,16 +190,6 @@ export async function startProxy(config: ProxyConfig): Promise<{ port: number; s identityUse.set(addr, used); if (used > 20) throw new HttpError(429, 'Too many requests, try again in a minute'); } - if (route === 'turn') { - if (stunPort == null || !config.turn) throw new HttpError(404, 'No relay here'); - const addr = addressOf(req); - const used = (identityUse.get(addr) ?? 0) + 1; - identityUse.set(addr, used); - if (used > 20) throw new HttpError(429, 'Too many requests, try again in a minute'); - // The username is the time it runs out; the relay recomputes the password from it - const username = String(Math.floor(Date.now() / 1000) + CREDENTIAL_TTL); - return { username, credential: turnCredential(turnSecret, username), ttl: CREDENTIAL_TTL, port: stunPort }; - } switch (route) { // Nothing is stored here: the browser keeps its identities and sends one along when it connects case 'identity/create': @@ -234,7 +238,11 @@ export async function startProxy(config: ProxyConfig): Promise<{ port: number; s const work = pathname.startsWith('/api/') ? api(req, pathname.slice(5)).then(result => { const data = Buffer.from(JSON.stringify(result)); - res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': data.length, 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' }); + res.writeHead(200, { + 'Content-Type': 'application/json', 'Content-Length': data.length, 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', + // The desktop app reads this one from another origin + ...(pathname === '/api/relay' ? { 'Access-Control-Allow-Origin': '*' } : {}) + }); res.end(data); }) : serveStatic(req, res, pathname); diff --git a/src/lib/actions.ts b/src/lib/actions.ts index 443a70d..55f2cae 100644 --- a/src/lib/actions.ts +++ b/src/lib/actions.ts @@ -69,7 +69,7 @@ export function userMenu(u: User): MenuItem[] { ); if (u.userId == null) items.push({ label: 'Register on this server', action: () => session.register(u.session) }); } else { - if (session.sharing[u.session]) items.push({ label: 'Watch screen', action: () => share.watch(sessions.active, u.session) }); + if (session.sharing[u.session]) items.push({ label: `Watch ${session.sharing[u.session]}`, action: () => share.watch(sessions.active, u.session) }); items.push( { label: 'Send message', action: () => session.selectView(`dm:${u.session}`) }, { label: 'Mute for me', checked: !!voice.settings.localMutes[userKey(u)], action: () => voice.setLocalMute(u, !voice.settings.localMutes[userKey(u)]) }, diff --git a/src/lib/icons.ts b/src/lib/icons.ts index cffde88..ec24d21 100644 --- a/src/lib/icons.ts +++ b/src/lib/icons.ts @@ -6,6 +6,7 @@ export const ICON_PATHS: Record = { 'headphones-off': 'M3 14v-2a9 9 0 0 1 18 0v2M21 14v7h-4v-7zM3 14v7h4v-7zM3 3l18 18', settings: 'M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6', plus: 'M12 5v14M5 12h14', + camera: 'M2 6h14v12H2zM16 10l6-3v10l-6-3', screen: 'M3 4h18v12H3zM8 20h8M12 16v4', popout: 'M14 4h6v6M20 4l-9 9M18 14v6H4V6h6', expand: 'M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5', diff --git a/src/lib/session.svelte.ts b/src/lib/session.svelte.ts index 27301e5..2f0bba0 100644 --- a/src/lib/session.svelte.ts +++ b/src/lib/session.svelte.ts @@ -1,7 +1,7 @@ import { createCodec } from '../core/proto.ts'; import { MumbleClient, describeDenial, type Channel, type User, type TextMessage } from '../core/client.ts'; import { native, openTransport } from './native.ts'; -import { share } from './share.svelte.ts'; +import { share, type ShareKind } from './share.svelte.ts'; import type { ServerCertInfo } from '../core/transport.ts'; import { store, type SavedServer } from './settings.svelte.ts'; import { renderIncoming, textToHtml, escapeHtml, formatBytes, type Embed } from './html.ts'; @@ -62,7 +62,7 @@ export class Session { certPrompt = $state(null); rtt = $state(0); // Who in our channel is sharing their screen, by session (mumh5 clients only) - sharing = $state>({}); + sharing = $state>({}); // 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); private removedBy: { kind: 'kicked' | 'banned'; by: string; reason: string } | null = null; @@ -687,8 +687,8 @@ export class Session { // ─── Voice state and moderation ───────────────────────────────────────────── - setSharing(session: number, on: boolean): void { - if (on) this.sharing[session] = true; + setSharing(session: number, kind: ShareKind | false): void { + if (kind) this.sharing[session] = kind; else delete this.sharing[session]; } clearSharing(): void { this.sharing = {}; } diff --git a/src/lib/settings.svelte.ts b/src/lib/settings.svelte.ts index 707837e..1aa2b6e 100644 --- a/src/lib/settings.svelte.ts +++ b/src/lib/settings.svelte.ts @@ -26,6 +26,7 @@ export interface Settings { arrangement: Arrangement; shareQuality: 'low' | 'medium' | 'high'; // bitrate and frame rate of our own stream shareVolume: number; // volume of streams we watch, 0 to 1 + relayServer: string; // a mumh5 web proxy whose STUN and relay to use for screen sharing 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 @@ -58,6 +59,7 @@ const defaults: Settings = { arrangement: 'side', shareQuality: 'medium', shareVolume: 1, + relayServer: '', stunServer: '', shareNoticeSeen: false, hints: false diff --git a/src/lib/share.svelte.ts b/src/lib/share.svelte.ts index f274876..3863f3d 100644 --- a/src/lib/share.svelte.ts +++ b/src/lib/share.svelte.ts @@ -6,7 +6,7 @@ import { SHARE, SHARE_DATA_ID, encodeShare, ShareAssembler, type ShareType } fro import type { User } from '../core/client.ts'; import type { Session } from './session.svelte.ts'; import { desktop, isWeb } from './native.ts'; -import { proxyIce } from './web.svelte.ts'; +import { proxyIce, relayIce } from './web.svelte.ts'; import { store } from './settings.svelte.ts'; import { ui } from './ui.svelte.ts'; import { sounds } from './audio/sounds.svelte.ts'; @@ -23,6 +23,9 @@ export const QUALITY = { 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`); +// What a stream shows. Sent along with the announcement, so others see the right icon. +export type ShareKind = 'screen' | 'camera'; + export interface Watching { host: Session; session: number; @@ -34,6 +37,7 @@ class ScreenShare { // The server we are sharing on, and what we send host = $state.raw(null); stream = $state.raw(null); + kind = $state('screen'); viewers = $state(0); watching = $state.raw(null); error = $state(''); @@ -58,9 +62,11 @@ class ScreenShare { private async iceServers(): Promise { const stun = store.settings.stunServer.trim(); const chosen: RTCIceServer[] = stun ? [{ urls: /^stuns?:/.test(stun) ? stun : `stun:${stun}` }] : []; - // The browser build also has the proxy it is served from: its STUN, and its relay for - // networks and browsers that allow no direct connection - return isWeb ? [...chosen, ...await proxyIce()] : chosen; + // A mumh5 proxy adds its STUN and its relay, which takes over by itself when no direct + // connection comes up (mobile and company networks, browsers that forbid direct UDP). The + // browser build has the proxy it is served from; the desktop app the one set in Settings. + const relay = store.settings.relayServer.trim(); + return [...chosen, ...(relay ? await relayIce(relay) : isWeb ? await proxyIce() : [])]; } // For testing the relay: localStorage mumh5.iceDebug = "relay" uses only relayed routes, @@ -112,7 +118,7 @@ class ScreenShare { } // The dialog hands over what it captured - go(s: Session, stream: MediaStream, usesVirtualMic: boolean): void { + go(s: Session, stream: MediaStream, usesVirtualMic: boolean, kind: ShareKind = 'screen'): void { this.dialog = null; // Disconnected while the dialog was open if (!s.client || this.stream) { @@ -121,15 +127,17 @@ class ScreenShare { return; } for (const t of stream.getVideoTracks()) { - t.contentHint = 'detail'; + // Text on a screen must stay sharp; a face may blur a little but should move smoothly + t.contentHint = kind === 'camera' ? 'motion' : 'detail'; // The system's "stop sharing" control t.addEventListener('ended', () => this.stop()); } this.usesVirtualMic = usesVirtualMic; + this.kind = kind; this.host = s; this.stream = stream; - this.send(s, this.others(s), SHARE.on); - s.setSharing(s.client.session!, true); + this.send(s, this.others(s), SHARE.on, kind); + s.setSharing(s.client.session!, kind); sounds.play('shareStarted'); } @@ -228,7 +236,7 @@ class ScreenShare { 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'; + params.degradationPreference = this.kind === 'camera' ? 'maintain-framerate' : 'maintain-resolution'; for (const enc of params.encodings ?? []) enc.maxBitrate = QUALITY[store.settings.shareQuality].bitrate; await sender.setParameters(params).catch(() => {}); } @@ -283,14 +291,14 @@ class ScreenShare { switch (msg.type) { case SHARE.on: if (!s.sharing[sender] && Date.now() - (this.enteredAt.get(s) ?? 0) > 3000) sounds.play('shareStarted'); - s.setSharing(sender, true); + s.setSharing(sender, msg.text === 'camera' ? 'camera' : 'screen'); break; case SHARE.off: if (s.sharing[sender]) sounds.play('shareStopped'); 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.who: if (this.host === s) this.send(s, [sender], SHARE.on, this.kind); 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; @@ -309,8 +317,8 @@ class ScreenShare { 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); + s.setSharing(self, this.kind); + this.send(s, this.others(s), SHARE.on, this.kind); } this.send(s, this.others(s), SHARE.who); } @@ -319,7 +327,7 @@ class ScreenShare { 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); + if (this.host === s) this.send(s, [user.session], SHARE.on, this.kind); return; } // A sharer who left or disconnected: their stream is over for us diff --git a/src/lib/web.svelte.ts b/src/lib/web.svelte.ts index 35f77f5..37566db 100644 --- a/src/lib/web.svelte.ts +++ b/src/lib/web.svelte.ts @@ -137,8 +137,6 @@ class ProxyInfo { any = $state(false); // UDP port of the proxy's STUN responder, if it runs one stun = $state(null); - // Whether it also relays streams for browsers that cannot connect directly - turn = $state(false); loaded = $state(false); error = $state(''); @@ -148,7 +146,6 @@ class ProxyInfo { this.servers = c.servers; this.any = c.any; this.stun = c.stun ?? null; - this.turn = !!c.turn; this.error = ''; } catch (e) { this.error = (e as Error).message; @@ -158,24 +155,27 @@ class ProxyInfo { } export const proxyInfo = new ProxyInfo(); -// STUN and relay on the proxy itself: a host the user already uses, so nothing new is contacted. -// The relay needs credentials, which the proxy hands out for a few hours at a time. -let relay: { servers: RTCIceServer[]; until: number } | null = null; -export async function proxyIce(): Promise { - if (!proxyInfo.loaded) await proxyInfo.load(); - if (!proxyInfo.stun || !base) return []; - let host: string; - try { host = new URL(base).hostname; } catch { return []; } - const stun: RTCIceServer = { urls: `stun:${host}:${proxyInfo.stun}` }; - if (!proxyInfo.turn) return [stun]; - if (relay && relay.until > Date.now()) return relay.servers; +// STUN and the relay of a mumh5 proxy, for screen sharing. The browser build uses the proxy it +// is served from, a host the user already uses; the desktop app one the user entered. The relay +// needs credentials, which the proxy hands out for a few hours at a time. +const relays = new Map(); +export async function relayIce(address: string): Promise { + let url: URL; + try { url = new URL(/^https?:\/\//i.test(address) ? address : `https://${address}`); } catch { return []; } + const root = url.href.replace(/\/*$/, '/'); + const known = relays.get(root); + if (known && known.until > Date.now()) return known.servers; try { - const c = await call<{ username: string; credential: string; ttl: number; port: number }>('turn', {}); + const res = await fetch(`${root}api/relay`); + if (!res.ok) return []; + const c = await res.json() as { port: number; username?: string; credential?: string; ttl?: number }; + const servers: RTCIceServer[] = [{ urls: `stun:${url.hostname}:${c.port}` }]; // UDP where it is allowed, TCP for networks and browsers that forbid it - const servers = [stun, { urls: [`turn:${host}:${c.port}?transport=udp`, `turn:${host}:${c.port}?transport=tcp`], username: c.username, credential: c.credential }]; - relay = { servers, until: Date.now() + Math.max(60, c.ttl - 3600) * 1000 }; + if (c.username) servers.push({ urls: [`turn:${url.hostname}:${c.port}?transport=udp`, `turn:${url.hostname}:${c.port}?transport=tcp`], username: c.username, credential: c.credential }); + relays.set(root, { servers, until: Date.now() + Math.max(60, (c.ttl ?? 3600) - 3000) * 1000 }); return servers; } catch { - return [stun]; + return []; } } +export const proxyIce = (): Promise => base ? relayIce(base) : Promise.resolve([]); diff --git a/src/ui/ChannelNode.svelte b/src/ui/ChannelNode.svelte index e171521..5912643 100644 --- a/src/ui/ChannelNode.svelte +++ b/src/ui/ChannelNode.svelte @@ -169,14 +169,15 @@ {store.displayName(u)} {#if u.userId != null}R{/if} {#if session.sharing[u.session]} + {@const kind = session.sharing[u.session]} {#if isSelf} - + {:else} - + onkeydown={e => { if (e.key === 'Enter') { e.stopPropagation(); share.watch(sessions.active, u.session); } }}> {/if} {/if} {#if u.comment || u.commentHash}{/if} diff --git a/src/ui/RightPanel.svelte b/src/ui/RightPanel.svelte index f8de5b0..dbfdaf2 100644 --- a/src/ui/RightPanel.svelte +++ b/src/ui/RightPanel.svelte @@ -128,8 +128,8 @@ {#if session.sharing[user.session]}