openvibers/openvibe.bot Commits

Screen share links: the owner shares a machine's live screen to watch, or to watch and use (start/stop, mouse and keyboard as the owner at the machine's level); the share page sits in OpenVibe's workspaces (#161)

OP OpenVibers committed 19 days ago · d11f95ba6e · 8 files, +508 −4

Changes

ddiff --git a/README.md b/README.md
iindex 9742d73..92fe7ad 100644
---- a/README.md
++++ b/README.md
@@@ -51,6 +51,22 @@ Sitemap entries use the release commit time for these code-backed pages.
- **The viewer.** `GET` answers the session's viewer `signaling_url` once the machine publishes, and the page plays it with the panel's WebRTC viewer.
- **Stop.** `DELETE` (or Stop) tells the machine to stop and retires the key. Removing a machine archives its stream.
- **Refused** with `bot.computer_off` when the machine does not offer it (`screen.stream`), and no key is made.
++- **Screen share links** (`server/screen-shares.js`, migration `0022_screen_shares.sql`; owner 2026-10-25: a screen a group
++ watches and uses together). On the screen page the owner makes a link: **watch** (the live screen) or **watch and use**
++ (also start or stop the screen, and the mouse and keyboard). It goes where the group works, such as a workspace room's
++ Screen on OpenVibe.Chat.
++ - **The link** is shown once; Bot keeps its sha256. It lasts 1-365 days (30 by default), and stops at once when revoked
++ or when the machine is removed. The owner's screen page lists the links (where each went, its kind, end date, last
++ use) with a Stop button.
++ - **The page** `/screen/:token` plays the screen like the owner's page. Its CSP lets it sit in a frame on
++ `openvibe.chat`, `openvibe.network` and `BOT_EMBED_ORIGINS`. It sends `Referrer-Policy: no-referrer` and is noindex.
++ `GET /screen/:token/state.json` is the state.
++ - **Using it** (use links only, `public/screen-share.js`): `POST /screen/:token/start|stop`, and `POST /screen/:token/input` with a
++ `bot.command@1` input value (click, double click, right click, move, scroll at a position in the picture, typed
++ text, or a key or chord by name). Each is an `input` command to the machine **as its owner**, judged by the level
++ the owner set there (`ask` waits for approval on the machine; `observe` refuses), 8 a second at most per link.
++ The audit keeps the action and position (typed text only as a character count) under the machine. Nothing else
++ passes: no programs, no files, no choice of where the screen goes.
- A server-side ONVIF camera (`camera.onvif`) is not installed on a machine: it gets no `--driver` and Bot attaches it itself from `BOT_ONVIF_CAMERAS` (see Deploy), so its panel works as soon as the camera answers.
- The robot's panel is `openvibe.bot/panel/<rob_…>` (`GET /panel/:id`, rendered from the profile; `docs/protocol.md` §3). The device-side bring-up steps for the Adeept ADR036 kit live with the driver, in OpenVibe.Node's `docs/hardware-adeept.md` (T15 step 1).
- A robot can use its own `local.rob_…` profile, stored with that robot and validated against `bot.robot-profile@1` and Bot's capability, driver and widget registries. On the owner's robot page, the Buttons form adds or removes rows and saves each button's name, label, optional keyboard key, cooldown in milliseconds and hold setting. The Video click checkbox enables a separate point cooldown. Only the owner can save or return to the robot's catalogue profile; each change is audited.
ddiff --git a/migrations/0022_screen_shares.sql b/migrations/0022_screen_shares.sql
nnew file mode 100644
iindex 0000000..dadb4ae
---- /dev/null
++++ b/migrations/0022_screen_shares.sql
@@@ -0,0 +1,18 @@
++-- phase: expand
++-- Screen share links (server/screen-shares.js; owner 2026-10-25: a screen people watch and use together, in a room).
++-- A machine's owner makes a link to its live screen: watch only, or watch and use (the mouse and keyboard, at the level
++-- the owner set on the machine). The link is shown once; Bot keeps only its hash. Revoking, removing the machine or the
++-- end date stops it at once.
++CREATE TABLE IF NOT EXISTS machine_screen_shares (
++ id text PRIMARY KEY, -- shr_<ULID>
++ device_id text NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
++ owner_subject text NOT NULL,
++ token_hash text NOT NULL UNIQUE, -- sha256 of the link's token
++ label text, -- where the owner put it ("staff room")
++ control boolean NOT NULL DEFAULT false, -- true: holders may use the mouse and keyboard
++ created_at timestamptz NOT NULL DEFAULT now(),
++ expires_at timestamptz,
++ revoked_at timestamptz,
++ last_used_at timestamptz
++);
++CREATE INDEX IF NOT EXISTS machine_screen_shares_device_idx ON machine_screen_shares (device_id) WHERE revoked_at IS NULL;
ddiff --git a/public/panel.css b/public/panel.css
iindex af2a52a..548ac84 100644
---- a/public/panel.css
++++ b/public/panel.css
@@@ -367,3 +367,23 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
.machine-screen { margin: 1rem 0 0.75rem; border: 1px solid var(--line); border-radius: 10px; overflow: hidden; }
.machine-screen-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem; margin-bottom: 0.75rem; }
.machine-screen-actions form { margin: 0; }
++
++/* Screen share links (server/screen-shares.js): the owner's list and form, and the shared screen (/screen/:token),
++ which often sits in a frame beside a room's conversation. */
++.machine-shares { margin-top: 28px; }
++.machine-shares h2 { font-size: 1.1rem; }
++.share-form { display: flex; flex-wrap: wrap; gap: 10px; align-items: end; }
++.share-form .field { flex: 1 1 180px; }
++.share-link { width: 100%; margin-top: 6px; font-family: ui-monospace, monospace; }
++.share-list { list-style: none; padding: 0; margin: 12px 0 0; display: grid; gap: 8px; }
++.share-list li { display: flex; flex-wrap: wrap; gap: 6px 12px; align-items: center; padding: 8px 10px; border: 1px solid var(--line, #2a313c); border-radius: 10px; }
++.inline-form { display: inline; margin: 0; }
++.share-body .page { padding-top: 12px; }
++.share-head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 4px 12px; }
++.share-head h1 { margin: 0; font-size: 1.15rem; }
++.share-mode { margin: 0; }
++.share-screen { margin: 10px 0; }
++.share-screen video { width: 100%; height: auto; max-height: calc(100vh - 150px); object-fit: contain; background: #000; }
++.share-screen.share-using { outline: 2px solid var(--accent); outline-offset: 2px; cursor: crosshair; }
++.share-controls { display: flex; flex-wrap: wrap; gap: 10px 16px; align-items: center; }
++.share-use { display: inline-flex; gap: 8px; align-items: center; }
ddiff --git a/public/screen-share.js b/public/screen-share.js
nnew file mode 100644
iindex 0000000..8124864
---- /dev/null
++++ b/public/screen-share.js
@@@ -0,0 +1,113 @@
++/* A shared screen with "use" (server/screen-shares.js, /screen/:token): start or stop the screen, and with "Use the mouse
++ and keyboard" on, clicks, double clicks, right clicks, the wheel and keys on the picture go to the computer as input
++ commands. Positions are in the picture's own pixels (the computer scales them to its screen). Plain text is sent in
++ short bursts as typed text; other keys and chords by name (Return, BackSpace, ctrl+c). panel.js plays the picture. */
++(function () {
++ 'use strict';
++ const main = document.querySelector('main[data-share]');
++ if (!main || main.dataset.control !== '1') return;
++ const base = main.dataset.share;
++ const fig = main.querySelector('[data-screen-state]');
++ const use = main.querySelector('[data-share-use]');
++ const startBtn = main.querySelector('[data-share-start]');
++ const stopBtn = main.querySelector('[data-share-stop]');
++ const note = main.querySelector('[data-share-note]');
++ const say = (t) => { if (note) note.textContent = t || ''; };
++
++ async function post(path, body) {
++ const r = await fetch(base + path, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(body || {}), credentials: 'omit' });
++ const j = await r.json().catch(() => ({}));
++ if (!r.ok) throw new Error(j.detail || ('the computer answered ' + r.status));
++ return j;
++ }
++
++ // Start when the screen is off; the state comes from the same JSON panel.js reads.
++ async function refresh() {
++ try {
++ const r = await fetch(base + '/state.json', { headers: { Accept: 'application/json' }, cache: 'no-store', credentials: 'omit' });
++ const s = r.ok ? await r.json() : null;
++ if (startBtn) startBtn.hidden = !s || s.live || !s.online;
++ if (s && !s.online) say('The computer is offline.');
++ } catch (e) { /* the next look tries again */ }
++ }
++ refresh();
++ setInterval(refresh, 5000);
++ if (startBtn) startBtn.addEventListener('click', async () => {
++ startBtn.disabled = true; say('Starting the screen…');
++ try { await post('/start'); say('Starting: the picture appears in a few seconds.'); } catch (e) { say(e.message); } finally { startBtn.disabled = false; refresh(); }
++ });
++ if (stopBtn) stopBtn.addEventListener('click', async () => {
++ try { await post('/stop'); say('The screen is stopped.'); } catch (e) { say(e.message); } finally { refresh(); }
++ });
++
++ const on = () => !!(use && use.checked);
++ if (use) use.addEventListener('change', () => { fig.classList.toggle('share-using', on()); if (on()) { fig.focus(); say('Clicks and keys on the picture go to the computer.'); } else say(''); });
++
++ /** A pointer position → the picture's own pixels, allowing for the letterboxing of object-fit: contain. */
++ function at(ev) {
++ const v = fig.querySelector('video');
++ if (!v || !v.videoWidth) return null;
++ const r = v.getBoundingClientRect();
++ const scale = Math.min(r.width / v.videoWidth, r.height / v.videoHeight);
++ const ox = (r.width - v.videoWidth * scale) / 2;
++ const oy = (r.height - v.videoHeight * scale) / 2;
++ const x = Math.round((ev.clientX - r.left - ox) / scale);
++ const y = Math.round((ev.clientY - r.top - oy) / scale);
++ if (x < 0 || y < 0 || x > v.videoWidth || y > v.videoHeight) return null;
++ return { x, y, width: v.videoWidth, height: v.videoHeight };
++ }
++ let busy = Promise.resolve();
++ const send = (value) => { busy = busy.then(() => post('/input', value).then(() => say(''), (e) => say(e.message))); return busy; };
++
++ let clickTimer = null;
++ fig.addEventListener('click', (ev) => {
++ if (!on()) return;
++ const p = at(ev); if (!p) return;
++ ev.preventDefault();
++ // A double click arrives as two clicks first: wait a moment so it is sent once, as a double click.
++ clearTimeout(clickTimer);
++ clickTimer = setTimeout(() => send({ action: 'click', ...p }), 220);
++ });
++ fig.addEventListener('dblclick', (ev) => {
++ if (!on()) return;
++ const p = at(ev); if (!p) return;
++ ev.preventDefault(); clearTimeout(clickTimer);
++ send({ action: 'double_click', ...p });
++ });
++ fig.addEventListener('contextmenu', (ev) => {
++ if (!on()) return;
++ const p = at(ev); if (!p) return;
++ ev.preventDefault();
++ send({ action: 'right_click', ...p });
++ });
++ let wheel = 0, wheelTimer = null, wheelAt = null;
++ fig.addEventListener('wheel', (ev) => {
++ if (!on()) return;
++ const p = at(ev); if (!p) return;
++ ev.preventDefault();
++ wheel += ev.deltaY; wheelAt = p;
++ clearTimeout(wheelTimer);
++ wheelTimer = setTimeout(() => { const dy = Math.max(-20, Math.min(20, Math.round(wheel / 60))); wheel = 0; if (dy) send({ action: 'scroll', ...wheelAt, dy }); }, 150);
++ }, { passive: false });
++
++ const NAMES = { Enter: 'Return', Backspace: 'BackSpace', Tab: 'Tab', Escape: 'Escape', Delete: 'Delete', ArrowUp: 'Up', ArrowDown: 'Down', ArrowLeft: 'Left', ArrowRight: 'Right', Home: 'Home', End: 'End', PageUp: 'Prior', PageDown: 'Next', Insert: 'Insert', ' ': 'space' };
++ let text = '', textTimer = null;
++ const flush = () => { if (text) { const t = text; text = ''; send({ action: 'type', text: t }); } };
++ fig.addEventListener('keydown', (ev) => {
++ if (!on()) return;
++ const mods = [ev.ctrlKey && 'ctrl', ev.altKey && 'alt', ev.metaKey && 'super'].filter(Boolean);
++ if (['Control', 'Alt', 'Shift', 'Meta'].includes(ev.key)) return;
++ ev.preventDefault();
++ if (!mods.length && ev.key.length === 1) {
++ text += ev.key;
++ clearTimeout(textTimer);
++ textTimer = setTimeout(flush, 120);
++ return;
++ }
++ flush();
++ let name = NAMES[ev.key] || (/^F\d{1,2}$/.test(ev.key) ? ev.key : (ev.key.length === 1 ? ev.key.toLowerCase() : null));
++ if (!name || !/^[A-Za-z0-9_]+$/.test(name)) return;
++ if (ev.shiftKey && mods.length) mods.push('shift');
++ send({ action: 'key', keys: [...mods, name].join('+') });
++ });
++})();
ddiff --git a/server/screen-shares.js b/server/screen-shares.js
nnew file mode 100644
iindex 0000000..8bc5678
---- /dev/null
++++ b/server/screen-shares.js
@@@ -0,0 +1,131 @@
++'use strict';
++/**
++ * Screen share links (migrations/0022_screen_shares.sql; owner 2026-10-25: "a screen you can watch and use … with
++ * other users … all together"). A machine's owner makes a link to its live screen and puts it where a group works (a
++ * workspace room on OpenVibe.Chat shows it beside the conversation):
++ *
++ * watch holders see the screen live (the machine's own private OpenRestream stream, as /machines/:id/screen)
++ * use holders also start the screen when it is off, and use the mouse and keyboard: each action is an
++ * `input` command to the machine as its owner, judged by the level the owner set on the machine (at
++ * `ask` each waits for approval there; `observe` refuses), audited with the link's id
++ *
++ * The token is shown once (Bot keeps its sha256). A link stops at once when revoked, when its end date passes, or when
++ * the machine is removed. Nothing here lets a holder run programs, read files or point the screen elsewhere: only the
++ * screen's start and stop, and the mouse and keyboard.
++ *
++ * create(device, owner, { label, control, days }) → { share, token }
++ * list(device, owner) / revoke(device, owner, id)
++ * open(token) → { share, device } or null
++ * state(token) / start(token) / stop(token) the screen, through the link
++ * input(token, value) a `bot.command@1` input value (click, double_click, right_click,
++ * move, scroll, type, key) → the machine's answer
++ */
++const { BotError, prefixedId, sha256, token: newToken } = require('./util');
++
++const DAYS = { min: 1, max: 365, default: 30 };
++const ACTIONS = new Set(['click', 'double_click', 'right_click', 'move', 'scroll', 'type', 'key']);
++const PER_SECOND = 8; // a person clicks and types a few times a second, not more
++
++function createScreenShares({ db, domain, hub, machineScreen, now = () => Date.now() }) {
++ const rate = new Map(); // share id → recent input timestamps (this process)
++
++ const shape = (r) => ({
++ id: r.id, device_id: r.device_id, label: r.label || null, control: !!r.control,
++ created_at: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at,
++ expires_at: r.expires_at ? (r.expires_at instanceof Date ? r.expires_at.toISOString() : r.expires_at) : null,
++ last_used_at: r.last_used_at ? (r.last_used_at instanceof Date ? r.last_used_at.toISOString() : r.last_used_at) : null,
++ });
++
++ async function create(device, owner, { label = null, control = false, days = DAYS.default } = {}) {
++ if (!device || device.owner_subject !== owner) throw new BotError(404, 'bot.device_not_found', 'no such machine of yours');
++ const d = Number(days);
++ if (!Number.isInteger(d) || d < DAYS.min || d > DAYS.max) throw new BotError(422, 'bot.invalid_input', `days is a whole number from ${DAYS.min} to ${DAYS.max}`);
++ const id = prefixedId('shr', now());
++ const t = newToken(32);
++ const l = label == null ? null : String(label).replace(/\s+/g, ' ').trim().slice(0, 80) || null;
++ await db.query(`INSERT INTO machine_screen_shares (id, device_id, owner_subject, token_hash, label, control, created_at, expires_at)
++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, [id, device.id, owner, sha256(t), l, !!control, new Date(now()), new Date(now() + d * 86400000)]);
++ const row = await db.maybe('SELECT * FROM machine_screen_shares WHERE id = $1', [id]);
++ return { share: shape(row), token: t };
++ }
++
++ async function list(device, owner) {
++ if (!device || device.owner_subject !== owner) throw new BotError(404, 'bot.device_not_found', 'no such machine of yours');
++ const rows = await db.many('SELECT * FROM machine_screen_shares WHERE device_id = $1 AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > $2) ORDER BY created_at DESC', [device.id, new Date(now())]);
++ return rows.map(shape);
++ }
++
++ async function revoke(device, owner, id) {
++ if (!device || device.owner_subject !== owner) throw new BotError(404, 'bot.device_not_found', 'no such machine of yours');
++ const r = await db.query('UPDATE machine_screen_shares SET revoked_at = $3 WHERE id = $1 AND device_id = $2 AND revoked_at IS NULL', [String(id), device.id, new Date(now())]);
++ return { revoked: (r.rowCount || r.changes || 0) > 0 };
++ }
++
++ /** The link behind a token, while it may be used: { share, device } or null (one answer for every reason). */
++ async function open(t) {
++ if (typeof t !== 'string' || !/^[A-Za-z0-9_-]{40,60}$/.test(t)) return null;
++ const row = await db.maybe('SELECT * FROM machine_screen_shares WHERE token_hash = $1', [sha256(t)]);
++ if (!row || row.revoked_at || (row.expires_at && new Date(row.expires_at).getTime() <= now())) return null;
++ const device = await db.maybe('SELECT * FROM devices WHERE id = $1 AND revoked_at IS NULL', [row.device_id]);
++ if (!device || device.owner_subject !== row.owner_subject) return null;
++ const last = row.last_used_at ? new Date(row.last_used_at).getTime() : 0;
++ if (now() - last > 60000) await db.query('UPDATE machine_screen_shares SET last_used_at = $2 WHERE id = $1', [row.id, new Date(now())]);
++ return { share: row, device };
++ }
++
++ async function must(t, { control = false } = {}) {
++ const s = await open(t);
++ if (!s) throw new BotError(404, 'bot.share_not_found', 'this screen link does not work: it was stopped, it ended, or it never existed');
++ if (control && !s.share.control) throw new BotError(403, 'bot.share_watch_only', 'this link is for watching only');
++ return s;
++ }
++
++ const asShare = (s) => ({ kind: 'share', sub: s.share.owner_subject, share: s.share.id });
++
++ async function state(t) {
++ const s = await must(t);
++ const st = await machineScreen.state(s.device, s.share.owner_subject);
++ return { ...st, name: s.device.name || 'A computer', control: !!s.share.control, label: s.share.label || null };
++ }
++
++ async function start(t) {
++ const s = await must(t, { control: true });
++ return await machineScreen.start(s.device, s.share.owner_subject, asShare(s), { seconds: 1800, fps: 4 });
++ }
++
++ async function stop(t) {
++ const s = await must(t, { control: true });
++ return await machineScreen.stop(s.device, s.share.owner_subject, asShare(s));
++ }
++
++ /** One mouse or keyboard action through the link, as the owner, at the machine's own level. */
++ async function input(t, value = {}) {
++ const s = await must(t, { control: true });
++ if (!hub) throw new BotError(503, 'bot.unavailable', 'machines are not connected here');
++ const v = value && typeof value === 'object' ? value : {};
++ if (!ACTIONS.has(v.action)) throw new BotError(422, 'bot.invalid_input', `action is one of ${[...ACTIONS].join(', ')}`);
++ const out = { action: v.action };
++ if (['click', 'double_click', 'right_click', 'move', 'scroll'].includes(v.action) && v.x != null) {
++ for (const k of ['x', 'y', 'width', 'height']) {
++ const n = Math.round(Number(v[k]));
++ if (!Number.isFinite(n) || n < 0 || n > 10000) throw new BotError(422, 'bot.invalid_input', `${k} is a position in the picture`);
++ out[k] = n;
++ }
++ }
++ if (v.action === 'scroll') out.dy = Math.max(-50, Math.min(50, Math.round(Number(v.dy) || 0)));
++ if (v.action === 'type') out.text = String(v.text || '').slice(0, 500);
++ if (v.action === 'key') out.keys = String(v.keys || '').slice(0, 40);
++ const recent = (rate.get(s.share.id) || []).filter((x) => now() - x < 1000);
++ if (recent.length >= PER_SECOND) throw new BotError(429, 'bot.rate_limited', 'slow down a little');
++ recent.push(now());
++ rate.set(s.share.id, recent);
++ const r = await hub.machineCommand(s.device.id, asShare(s), s.share.owner_subject, { kind: 'input', value: out, ms: 15000 });
++ if (r && r.result === 'ack') return { done: true };
++ const code = (r && r.code) || 'bot.device_refused';
++ throw new BotError(r && r.result === 'expired' ? 504 : 409, code.startsWith('bot.') ? code : 'bot.device_refused', `the computer did not do it: ${(r && (r.reason || r.result)) || 'no answer'}`.slice(0, 300));
++ }
++
++ return { create, list, revoke, open, state, start, stop, input, DAYS };
++}
++
++module.exports = { createScreenShares, ACTIONS };
ddiff --git a/server/web/render.js b/server/web/render.js
iindex c05b1ac..5dbf37e 100644
---- a/server/web/render.js
++++ b/server/web/render.js
@@@ -724,7 +724,46 @@ ${add}
* /machines/:id/screen: the machine's screen, live, for its owner only (plan T14). The tile waits for the machine's
* session (public/panel.js polls `stateUrl`) and plays it like a robot's camera; Stop ends it at once.
*/
--function renderMachineScreenPage({ machine, until = null, error = null, idle = false }) {
++/** The owner's links to this screen (server/screen-shares.js): make one to watch or use it, see and stop the others. */
++function sharesBlock(machine, shares, madeLink, madeShare) {
++ const rows = (shares || []).map((x) => `<li><strong>${esc(x.label || 'A link')}</strong> · ${x.control ? 'watch and use' : 'watch only'} · until <time datetime="${esc(x.expires_at || '')}" data-local-time>${esc(String(x.expires_at || '').slice(0, 10))}</time>${x.last_used_at ? ` · last opened <time datetime="${esc(x.last_used_at)}" data-local-time>${esc(String(x.last_used_at).slice(0, 16).replace('T', ' '))}</time>` : ''}
++<form method="post" action="/machines/${esc(machine.id)}/shares/${esc(x.id)}/revoke" class="inline-form"><button type="submit" class="button quiet">Stop this link</button></form></li>`).join('');
++ return `<section class="machine-shares" aria-labelledby="shares-h"><h2 id="shares-h">Share this screen</h2>
++<p class="muted">Make a link for a group: put it in a workspace room on OpenVibe.Chat (the staff room's Screen) or anywhere people work together. <strong>Watch</strong> shows the screen live. <strong>Watch and use</strong> also lets them start it and use the mouse and keyboard, at the level you set on this computer: at <code>ask</code> each action waits for your approval there, and at <code>observe</code> nothing happens. Nobody can run programs or read files through a link.</p>
++${madeLink ? `<p class="notice" role="status">Your new link${madeShare && madeShare.control ? ' (watch and use)' : ''}. Copy it now: it is not shown again.<br><input class="share-link" type="text" readonly value="${esc(madeLink)}" aria-label="The new screen link"></p>` : ''}
++<form method="post" action="/machines/${esc(machine.id)}/shares" class="share-form">
++<label class="field"><span class="field-label">Where it goes</span><input name="label" maxlength="80" placeholder="Staff room"></label>
++<label class="field"><span class="field-label">Lasts (days)</span><input name="days" type="number" min="1" max="365" value="30"></label>
++<label class="check"><input type="checkbox" name="control" value="1"> They may use the mouse and keyboard too</label>
++<button type="submit" class="primary">Make a link</button></form>
++${rows ? `<ul class="share-list">${rows}</ul>` : ''}</section>`;
++}
++
++/** A screen link that does not work (stopped, ended, never existed): one answer for every reason. */
++function renderShareGone() {
++ return page('Screen link', `<main class="page share-page"><h1>This screen link does not work</h1><p class="muted">It was stopped, it ended, or it never existed. Ask whoever shared it for a new one.</p></main>`, { description: 'A shared computer screen on OpenVibe.Bot.' });
++}
++
++/** /screen/:token: a shared screen, live, for a frame or a tab; with "use" links, the mouse and keyboard too. */
++function renderSharePage({ token, name, control, label }) {
++ const base = `/screen/${encodeURIComponent(token)}`;
++ const body = `<main class="page share-page" data-share="${esc(base)}" data-control="${control ? '1' : '0'}">
++<header class="share-head"><h1>${esc(name)}${label ? ` <span class="muted">· ${esc(label)}</span>` : ''}</h1>
++<p class="muted share-mode">${control ? 'Shared to watch and use' : 'Shared to watch'}</p></header>
++<figure class="camera machine-screen share-screen" data-screen-state="${esc(base)}/state.json" tabindex="${control ? '0' : '-1'}" aria-label="The shared screen${control ? ': turn on Use to click and type on it' : ''}">
++<div class="camera-screen" data-camera-screen>${icon('plug', 'icon camera-icon')}<p class="camera-title" data-screen-status role="status">Looking for the screen…</p>
++<p class="camera-sub">The picture comes from the computer itself, a few frames a second.</p></div>
++</figure>
++${control ? `<div class="share-controls">
++<button type="button" class="primary" data-share-start hidden>Start the screen</button>
++<label class="check share-use"><input type="checkbox" data-share-use> Use the mouse and keyboard</label>
++<button type="button" class="button quiet" data-share-stop>Stop the screen</button>
++<span class="muted small" data-share-note role="status"></span></div>` : ''}
++</main>`;
++ return page(`${name}: shared screen`, body, { scripts: ['/panel/panel.js', ...(control ? ['/panel/screen-share.js'] : [])], bodyClass: 'share-body', description: 'A shared computer screen on OpenVibe.Bot.' });
++}
++
++function renderMachineScreenPage({ machine, until = null, error = null, idle = false, shares = [], madeLink = null, madeShare = null }) {
const name = machine.name || machine.id;
const stateUrl = `/machines/${encodeURIComponent(machine.id)}/screen.json`;
// Idle (nothing is shown, as from a link): a start button instead of waiting for a stream nobody started.
@@@ -734,6 +773,7 @@ function renderMachineScreenPage({ machine, until = null, error = null, idle = f
<h1>${esc(name)}: screen</h1>
<p class="muted">Show this computer's screen here, live, to you only, for 10 minutes. Nothing is recorded, and the computer says on its desktop that its screen is live.</p>
<form method="post" action="/machines/${esc(machine.id)}/screen"><button type="submit" class="primary">Show my screen</button></form>
++${sharesBlock(machine, shares, madeLink, madeShare)}
</main>`;
return page(`${name}: screen`, body, { description: 'Your machine\'s screen, live, for you only.', canonical: '/machines' });
}
@@@ -751,6 +791,7 @@ ${error ? `<p class="error" role="alert">${esc(error)}</p>` : ends}
<form method="post" action="/machines/${esc(machine.id)}/screen"><button type="submit" class="button quiet">${error ? 'Try again' : 'Start again (10 more minutes)'}</button></form>
</div>
<p class="muted small">The machine says on its desktop that its screen is live. <code>openvibe-node stop</code> on the machine ends it at once, and it ends on its own after its time.</p>
++${sharesBlock(machine, shares, madeLink, madeShare)}
</main>`;
return page(`${name}: screen`, body, { scripts: ['/panel/panel.js'], description: 'Your machine\'s screen, live, for you only.', canonical: '/machines' });
}
@@@ -789,4 +830,4 @@ function renderPhonePage() {
return page('This phone is a robot', body, { scripts: ['/panel/phone-device.js'], bodyClass: 'phone-page', description: 'Turn a phone into a robot on OpenVibe.Bot from its browser: its camera, voice and face, with nothing to install.', canonical: '/device/phone' });
}
--module.exports = { page, renderPanel, renderPhonePage, renderMachinesPage, renderMachineScreenPage, panelScripts, isSim, TRY_DESCRIPTION, renderEmbedRefused, renderDrivePage, renderWidget, renderRobotsPage, renderPairingPage, renderLinkPage, renderClaimPage, renderClaimApproved, camerasOf, esc };
++module.exports = { page, renderPanel, renderPhonePage, renderMachinesPage, renderMachineScreenPage, renderSharePage, renderShareGone, panelScripts, isSim, TRY_DESCRIPTION, renderEmbedRefused, renderDrivePage, renderWidget, renderRobotsPage, renderPairingPage, renderLinkPage, renderClaimPage, renderClaimApproved, camerasOf, esc };
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex 97360c1..05a6521 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -36,8 +36,9 @@ const { getProfile, listProfiles, loadProfiles } = require('../profiles');
const { frameAncestors } = require('../config');
const { BotError } = require('../util');
const kits = require('../kits');
--const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, renderPhonePage, renderMachinesPage, renderMachineScreenPage, renderLinkPage, renderClaimPage, renderClaimApproved } = require('./render');
++const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, renderPhonePage, renderMachinesPage, renderMachineScreenPage, renderSharePage, renderShareGone, renderLinkPage, renderClaimPage, renderClaimApproved } = require('./render');
const { createMachineScreen } = require('../machine-screen');
++const { createScreenShares } = require('../screen-shares');
const { renderDocsIndex, renderDriversPage, renderProfilesPage, renderAutomationPage, renderComputersPage } = require('./docs');
const { renderSdCard } = require('./sd-card');
const { checkProfile } = require('../profiles');
@@@ -195,6 +196,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
const asset = (file, type) => (req, res) => { res.setHeader('Cache-Control', 'no-cache'); res.type(type).sendFile(path.join(PUBLIC, file)); };
r.get('/panel/panel.js', asset('panel.js', 'application/javascript'));
r.get('/panel/panel.css', asset('panel.css', 'text/css'));
++ r.get('/panel/screen-share.js', asset('screen-share.js', 'application/javascript'));
r.get('/panel/drive.js', asset('drive.js', 'application/javascript'));
r.get('/panel/sim-world.js', asset('sim-world.js', 'application/javascript'));
r.get('/panel/sim-view.js', asset('sim-view.js', 'application/javascript'));
@@@ -604,7 +606,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
const idle = !until && (await machineScreen.state(mine, me.subject)).state !== 'live';
res.setHeader('Vary', 'Cookie');
res.setHeader('Content-Security-Policy', PANEL_CSP);
-- res.type('html').send(renderMachineScreenPage({ machine: mine, until, idle }));
++ res.type('html').send(renderMachineScreenPage({ machine: mine, until, idle, shares: await shares.list(mine, me.subject).catch(() => []) }));
}));
r.get('/machines/:id/screen.json', page(async (req, res) => {
const me = requireUser(req);
@@@ -619,6 +621,48 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
await machineScreen.stop(mine, me.subject, asUser(me));
res.redirect(303, '/machines');
}));
++ // ── Screen share links (server/screen-shares.js): the owner shares the live screen with a group, to watch or use ──
++ const shares = createScreenShares({ db: domain && domain.db, domain, hub, machineScreen });
++ // A share page may sit in OpenVibe's own workspaces (a room on OpenVibe.Chat, the admin panel) and the configured embeds.
++ const shareCsp = ["default-src 'self'", `script-src 'self' ${PANEL_SCRIPT_SRC}`, `connect-src 'self' ${PANEL_CONNECT_SRC}`,
++ `frame-ancestors ${frameAncestors(config)} https://openvibe.chat https://openvibe.network`, "object-src 'none'", "base-uri 'self'"].join('; ');
++ r.post('/machines/:id/shares', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
++ const me = requireUser(req);
++ if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
++ const mine = await myMachine(req, me);
++ const made = await shares.create(mine, me.subject, { label: req.body && req.body.label, control: req.body && req.body.control === '1', days: (req.body && req.body.days) || 30 });
++ res.setHeader('Content-Security-Policy', PANEL_CSP);
++ res.type('html').send(renderMachineScreenPage({ machine: mine, idle: true, shares: await shares.list(mine, me.subject), madeLink: `${config.baseUrl}/screen/${made.token}`, madeShare: made.share }));
++ }));
++ r.post('/machines/:id/shares/:share/revoke', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
++ const me = requireUser(req);
++ if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
++ const mine = await myMachine(req, me);
++ await shares.revoke(mine, me.subject, req.params.share);
++ res.redirect(303, `/machines/${encodeURIComponent(mine.id)}/screen`);
++ }));
++ const shareJson = (fn) => async (req, res) => {
++ res.setHeader('Cache-Control', 'no-store');
++ try { res.json(await fn(req)); } catch (e) {
++ if (!(e instanceof BotError)) { log.warn(`[Bot] screen share: ${e.message}`); return res.status(500).json({ code: 'bot.internal', detail: 'something went wrong' }); }
++ res.status(e.status).json({ code: e.code, detail: e.detail || e.message });
++ }
++ };
++ const shareLimit = limits ? [limits('bot.screen.share', { minute: 600, hour: 6000 })] : [];
++ r.get('/screen/:token', async (req, res) => {
++ res.setHeader('Cache-Control', 'no-store');
++ res.setHeader('Referrer-Policy', 'no-referrer');
++ res.setHeader('X-Robots-Tag', 'noindex, nofollow');
++ res.setHeader('Content-Security-Policy', shareCsp);
++ const s = await shares.open(req.params.token).catch(() => null);
++ if (!s) return res.status(404).type('html').send(renderShareGone());
++ res.type('html').send(renderSharePage({ token: req.params.token, name: s.device.name || 'A computer', control: !!s.share.control, label: s.share.label }));
++ });
++ r.get('/screen/:token/state.json', shareJson(async (req) => await shares.state(req.params.token)));
++ r.post('/screen/:token/start', ...shareLimit, shareJson(async (req) => await shares.start(req.params.token)));
++ r.post('/screen/:token/stop', ...shareLimit, shareJson(async (req) => await shares.stop(req.params.token)));
++ r.post('/screen/:token/input', ...shareLimit, express.json({ limit: '2kb' }), shareJson(async (req) => await shares.input(req.params.token, req.body || {})));
++
r.get('/machines', frameCsp, page(async (req, res) => {
const me = requireUser(req);
res.setHeader('Vary', 'Cookie');
ddiff --git a/test/screen-shares.test.js b/test/screen-shares.test.js
nnew file mode 100644
iindex 0000000..ac8298b
---- /dev/null
++++ b/test/screen-shares.test.js
@@@ -0,0 +1,121 @@
++'use strict';
++// Screen share links (server/screen-shares.js, migration 0022): the machine's owner makes a link to its live screen,
++// shown once; "watch" links read the screen's state, "use" links also start and stop it and send mouse and keyboard
++// input to the machine as its owner (audited, rate-limited, nothing else: no exec, no files); the share page works in a
++// frame on OpenVibe's workspaces; another person cannot make links for the machine; a revoked or ended link answers 404.
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/app');
++
++(async () => {
++ const t = await boot({ env: { BOT_ROUTINE_TICK_MS: '0', BOT_WEBHOOK_TICK_MS: '0', BOT_WHIP_BASE: 'https://ingest.openre.test/whip/' } });
++ const alex = t.network.newUser('alex');
++ const bob = t.network.newUser('bob');
++ let seq = 0;
++ const frame = (type, fields) => ({ v: 1, seq: ++seq, ts: Date.now(), type, ...fields });
++ const poll = async (fn, ms = 8000) => { const end = Date.now() + ms; while (Date.now() < end) { const v = await fn(); if (v) return v; await t.wait(30); } return null; };
++ const COMPUTER = { screen: { level: 'trusted', tool: 'grim', stream: true }, input: { level: 'trusted' }, files: { level: 'trusted', roots: ['/home/alex'] }, terminal: { level: 'trusted' } };
++ const seen = [];
++ const principal = t.network.addNode({ owner: alex.subject, ref: `worker:${alex.subject}`, name: 'staff-desk' });
++ const c = await t.ws('/device', { headers: { Authorization: `Bearer ${t.network.signNode(principal)}` } });
++ const deviceId = (await c.waitFor((m) => m.type === 'hello')).device_id;
++ const config = await c.waitFor((m) => m.type === 'config');
++ c.send(frame('status', { capabilities: { computer: COMPUTER }, drivers: [], faults: [] }));
++ c.send(frame('telemetry', { sensors: { computer_level: 'trusted', computer_screen_stream: 'off' } }));
++ c.ws.on('message', (raw) => {
++ let m; try { m = JSON.parse(raw.toString()); } catch { return; }
++ if (m.type !== 'command') return;
++ seen.push(m);
++ if (m.kind === 'screen_stream' && m.value.action === 'start') c.send(frame('ack', { id: m.id, data: { streaming: true, seconds: m.value.seconds, fps: m.value.fps, until_ms: Date.now() + m.value.seconds * 1000 } }));
++ else c.send(frame('ack', { id: m.id, data: {} }));
++ });
++ const beat = setInterval(() => { try { c.send(frame('heartbeat', { t: Date.now() })); } catch { /* closed */ } }, Math.max(100, Math.floor(config.heartbeat_ms / 2)));
++ c.ws.on('close', () => clearInterval(beat));
++ await poll(() => t.hub.computerOf(deviceId) !== null);
++ const cookie = (user) => ({ Cookie: `ov_token=${t.network.signUser(user)}` });
++ const form = (p, user, body = '', headers = {}) => fetch(t.base + p, { method: 'POST', redirect: 'manual', body, headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(user ? cookie(user) : {}), ...headers } });
++ const json = async (method, p, body) => { const r = await fetch(t.base + p, { method, headers: { 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined }); return { status: r.status, json: await r.json().catch(() => null), headers: r.headers, text: '' }; };
++ const linkOf = (html) => (/value="(https?:\/\/[^"]+\/screen\/([A-Za-z0-9_-]+))"/.exec(html) || [])[2];
++ let watch, use;
++
++ await check('the owner makes links, each shown once; the page lists them; nobody else makes one', async () => {
++ let r = await form(`/machines/${deviceId}/shares`, alex, 'label=Staff+room&days=30');
++ assert.strictEqual(r.status, 200);
++ watch = linkOf(await r.text());
++ assert.ok(watch && watch.length >= 40, 'a watch link');
++ r = await form(`/machines/${deviceId}/shares`, alex, 'label=Staff+room+(use)&days=7&control=1');
++ const html = await r.text();
++ use = linkOf(html);
++ assert.ok(use && use !== watch);
++ assert.match(html, /watch and use/);
++ const rows = await t.db.many('SELECT * FROM machine_screen_shares WHERE device_id = $1 ORDER BY created_at', [deviceId]);
++ assert.deepStrictEqual(rows.map((x) => [x.label, x.control]), [['Staff room', false], ['Staff room (use)', true]]);
++ assert.ok(rows.every((x) => !JSON.stringify(x).includes(watch) && !JSON.stringify(x).includes(use)), 'only hashes are kept');
++ r = await fetch(`${t.base}/machines/${deviceId}/screen`, { headers: cookie(alex) });
++ const page = await r.text();
++ assert.match(page, /Staff room \(use\)/);
++ assert.ok(!page.includes(use), 'a link is never shown again');
++ assert.ok((await form(`/machines/${deviceId}/shares`, bob, 'label=mine')).status >= 400, 'another person cannot share it');
++ assert.strictEqual((await form(`/machines/${deviceId}/shares`, alex, 'label=x', { Origin: 'https://evil.test' })).status, 403, 'no cross-site post');
++ });
++
++ await check('the share page: framed by OpenVibe\'s workspaces, controls only on a use link', async () => {
++ let r = await fetch(`${t.base}/screen/${watch}`);
++ assert.strictEqual(r.status, 200);
++ assert.match(r.headers.get('content-security-policy'), /frame-ancestors [^;]*https:\/\/openvibe\.chat/);
++ assert.strictEqual(r.headers.get('referrer-policy'), 'no-referrer');
++ let html = await r.text();
++ assert.match(html, /staff-desk/);
++ assert.ok(!html.includes('screen-share.js'), 'a watch link has no controls');
++ r = await fetch(`${t.base}/screen/${use}`);
++ html = await r.text();
++ assert.match(html, /screen-share\.js/);
++ assert.match(html, /Use the mouse and keyboard/);
++ assert.strictEqual((await fetch(`${t.base}/screen/notarealtokennotarealtokennotarealtoken12345`)).status, 404);
++ });
++
++ await check('watch reads the state; only use starts the screen or sends input', async () => {
++ let r = await json('GET', `/screen/${watch}/state.json`);
++ assert.deepStrictEqual([r.status, r.json.live, r.json.online, r.json.control], [200, false, true, false]);
++ assert.strictEqual((await json('POST', `/screen/${watch}/start`)).status, 403);
++ assert.strictEqual((await json('POST', `/screen/${watch}/input`, { action: 'click', x: 1, y: 1, width: 10, height: 10 })).json.code, 'bot.share_watch_only');
++ r = await json('POST', `/screen/${use}/start`);
++ assert.strictEqual(r.status, 200, JSON.stringify(r.json));
++ const start = seen.filter((m) => m.kind === 'screen_stream').at(-1);
++ assert.deepStrictEqual([start.value.action, start.value.seconds, start.value.fps], ['start', 1800, 4]);
++ });
++
++ await check('input reaches the machine as the owner, audited with the action only', async () => {
++ let r = await json('POST', `/screen/${use}/input`, { action: 'click', x: 640, y: 360, width: 1280, height: 720 });
++ assert.strictEqual(r.status, 200, JSON.stringify(r.json));
++ const click = seen.filter((m) => m.kind === 'input').at(-1);
++ assert.deepStrictEqual(click.value, { action: 'click', x: 640, y: 360, width: 1280, height: 720 });
++ assert.strictEqual(click.operator.subject, alex.subject);
++ r = await json('POST', `/screen/${use}/input`, { action: 'type', text: 'hello team' });
++ assert.strictEqual(r.status, 200);
++ assert.deepStrictEqual(seen.filter((m) => m.kind === 'input').at(-1).value, { action: 'type', text: 'hello team' });
++ r = await json('POST', `/screen/${use}/input`, { action: 'key', keys: 'ctrl+s' });
++ assert.strictEqual(r.status, 200);
++ assert.ok((await json('POST', `/screen/${use}/input`, { action: 'key', keys: 'ctrl+s; rm -rf ~' })).status >= 400, 'the contract refuses a shell line');
++ assert.strictEqual((await json('POST', `/screen/${use}/input`, { action: 'exec', argv: ['rm'] })).status, 422, 'only mouse and keyboard');
++ const audit = await t.db.many("SELECT value, result FROM command_audit WHERE robot_id = $1 AND kind = 'input' ORDER BY id", [deviceId]);
++ assert.ok(audit.length >= 3);
++ assert.ok(!JSON.stringify(audit).includes('hello team'), 'typed text is not kept');
++ });
++
++ await check('a burst is slowed down; a revoked link stops at once', async () => {
++ let limited = 0;
++ for (let i = 0; i < 12; i++) { if ((await json('POST', `/screen/${use}/input`, { action: 'move', x: i, y: i, width: 100, height: 100 })).status === 429) limited++; }
++ assert.ok(limited > 0, 'rate-limited');
++ const id = (await t.db.maybe("SELECT id FROM machine_screen_shares WHERE label = 'Staff room (use)'")).id;
++ const r = await form(`/machines/${deviceId}/shares/${id}/revoke`, alex);
++ assert.strictEqual(r.status, 303);
++ assert.strictEqual((await fetch(`${t.base}/screen/${use}`)).status, 404);
++ assert.strictEqual((await json('POST', `/screen/${use}/input`, { action: 'click', x: 1, y: 1, width: 2, height: 2 })).status, 404);
++ // An ended link is gone too.
++ await t.db.query("UPDATE machine_screen_shares SET expires_at = now() - interval '1 minute' WHERE label = 'Staff room'");
++ assert.strictEqual((await json('GET', `/screen/${watch}/state.json`)).status, 404);
++ });
++
++ c.close();
++ await done(t);
++})().catch((err) => { console.error(err); process.exit(1); });