openvibers/openvibe.bot Commits
A desk on your OpenVibe Live channel, when you choose (Desks plan D8): a public stream of the machine's own, linked to your channel through OpenRestream, for up to 30 minutes; never your own screen, never the private screen stream (#196)
Changes
@@@ -217,6 +217,12 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf/* A machine's computer plugin (Node docs/computer.md): its level, what waits on it, and the agent switch, full width. */.machine-computer { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem 0.6rem; padding-top: 0.6rem; border-top: 1px solid var(--line); }.machine-ai { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem 0.6rem; flex-basis: 100%; }++/* A desk on the owner's Live channel: the second step, under the desk's own buttons. */++.desk-live { flex-basis: 100%; }++.desk-live > summary { list-style: none; display: inline-flex; }++.desk-live > summary::-webkit-details-marker { display: none; }++.desk-live form { display: grid; gap: 0.5rem; margin-top: 0.5rem; max-width: 36rem; }++.desk-live form p { margin: 0; }@media (min-width: 36rem) { .robot-card { grid-template-columns: minmax(0, 1fr) auto; align-items: center; } .robot-meta { grid-column: 1; grid-row: 2; } .robot-actions { grid-column: 2; grid-row: 1 / span 2; } .machine-computer { grid-column: 1 / -1; grid-row: 3; } .machine-computer form { margin-left: auto; } }.empty { text-align: center; padding: 2rem 1rem; margin-bottom: 1.5rem; border: 1px dashed var(--line); border-radius: var(--radius); color: var(--muted); }.empty-icon { width: 3rem; height: 3rem; stroke-width: 1.5; color: var(--accent); }@@@ -489,7 +489,7 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm};r.post('/machines/:id/screen', manage, wrap(async (req, res) => {const { device, subject } = await screenOwner(req);-- const out = await machineScreen.start(device, subject, req.principal, { seconds: req.body?.seconds, fps: req.body?.fps, desk: req.body?.desk });++ const out = await machineScreen.start(device, subject, req.principal, { seconds: req.body?.seconds, fps: req.body?.fps, desk: req.body?.desk, live: req.body?.live === true });res.status(201).json({ machine_id: device.id, ...out });}));r.get('/machines/:id/screen', wrap(async (req, res) => {@@@ -720,6 +720,41 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =}return { streamId: out.streamId, key: out.key.key, whipUrl: whipUrl(out.key.key) };}++ /**++ * A desk shown on the owner's OpenVibe Live channel, at their request (the Desks plan's D8) → { streamId, key, whipUrl,++ * live: { linked, channel_url?, code?, reason? } }. A second stream of the machine's (external ref++ * bot:machine_live:<id>), public, never the private screen stream: switching its mirror_to_live on has OpenRestream++ * link it to a slot on their channel (once; Live then shows each session at openvibe.live/@them). Each start rotates++ * the key. Throws 503 without OpenRestream or a WHIP ingest.++ */++ async function liveScreenKey(device, owner) {++ if (!openre || !whipUrl('x')) fail(503, 'bot.openre_unavailable', 'OpenRestream is not configured here, so a desk cannot go live');++ const found = await openre.find(`bot:machine_live:${device.id}`, owner);++ let out = null;++ if (found && found.id) {++ const r = await openre.rotate(found.id, owner, { grace_seconds: 0, end_sessions: true });++ out = r && { streamId: found.id, key: r.key };++ }++ if (!out) {++ const label = String(device.name || device.id).slice(0, 100);++ const created = await openre.create({++ title: `${label}: my agent at work`, protocols: ['webrtc'], recording_mode: 'none', recording_visibility: 'public',++ playback_visibility: 'public', mirror_to_live: false, show_on_live: false,++ external_refs: [{ service: 'bot', type: 'machine_live', id: device.id, label }],++ }, owner);++ out = created && created.stream && { streamId: created.stream.id, key: created.key };++ }++ if (!out || typeof out.streamId !== 'string' || !out.key || typeof out.key.key !== 'string' || !out.key.key) {++ fail(503, 'bot.openre_unavailable', 'OpenRestream answered no ingest key for the desk');++ }++ const shown = await openre.showOnLive(out.streamId, owner);++ const slot = shown && shown.stream && (shown.stream.external_refs || []).find((r) => r && r.service === 'live' && r.type === 'managed_stream');++ const channel = (shown && shown.live && shown.live.channel_url) || (slot && slot.label) || null;++ const live = shown && shown.live && shown.live.linked === false++ ? { linked: false, code: shown.live.code || null, reason: shown.live.reason || null }++ : { linked: !!(shown && shown.stream && shown.stream.mirror_to_live && channel), channel_url: channel };++ return { streamId: out.streamId, key: out.key.key, whipUrl: whipUrl(out.key.key), live };++ }/** The viewer signaling URL of the machine's open screen session, as its owner, or null (none is live yet). */async function screenLive(device, owner) {if (!openre || !device.screen_stream_id) return null;@@@ -1253,7 +1288,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, driverForProfile, whipUrl, onPaired },devices: { byCredential, bindNode, issuePublishKey, revokeVideo, updateDeclared, revokeNode, ensureServerDevice, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, revokeByNode, touchSeen, setOnline, ownerOf, onChange },machines: { mint: mintMachine, list: listMachines, workerRef, setAgentComputer, setAiModels, setWatchProbe, setVoice,-- screen: { key: screenKey, live: screenLive, end: screenEnd, archive: screenArchive } },++ screen: { key: screenKey, liveKey: liveScreenKey, live: screenLive, end: screenEnd, archive: screenArchive } },streaming: { get: streaming, set: setStreaming },video: { live: liveVideo },estop: { set: setEstop, clear: clearEstop },@@@ -78,7 +78,14 @@ function createMachineScreen({ domain, hub, now = () => Date.now() }) {if (desk !== 'main' && desk !== 'private' && offered.desk.named !== true) {throw new BotError(409, 'bot.computer_off', 'this machine runs one desk only (private): several named desks need OpenVibe Node v0.19.0 or newer');}-- const key = await domain.machines.screen.key(device, subject);++ // On the owner's OpenVibe Live channel (the Desks plan's D8): a desk only, never their own screen, on a public++ // stream of its own that OpenRestream links to their channel; refused with Live's reason when it cannot be.++ const live = opts.live === true;++ if (live && desk === 'main') throw new BotError(422, 'bot.live_desk_only', 'only a desk goes on your Live channel, never your own screen');++ const key = live ? await domain.machines.screen.liveKey(device, subject) : await domain.machines.screen.key(device, subject);++ if (live && !key.live.linked) {++ throw new BotError(409, key.live.code || 'bot.live_refused', key.live.reason || 'OpenVibe Live did not take the desk: sign in to openvibe.live once, then try again');++ }const r = await hub.machineScreen(device.id, principal, subject, { action: 'start', whip_url: key.whipUrl, publish_key: key.key, seconds, fps, ...(desk !== 'main' ? { desk } : {}) });if (r.result !== 'ack') {const code = r.code || (r.result === 'expired' ? 'bot.device_timeout' : 'bot.device_refused');@@@ -88,7 +95,7 @@ function createMachineScreen({ domain, hub, now = () => Date.now() }) {const data = r.data && typeof r.data === 'object' ? r.data : {};const until = Number.isFinite(data.until_ms) ? new Date(data.until_ms).toISOString() : new Date(now() + seconds * 1000).toISOString();shown.set(device.id, desk);-- return { streaming: true, seconds, fps, until, desk };++ return { streaming: true, seconds, fps, until, desk, ...(live ? { live: { channel_url: key.live.channel_url } } : {}) };},async state(device, subject) {const sensors = hub ? ((hub.deviceState(device.id).telemetry || {}).sensors || {}) : {};@@@ -19,6 +19,8 @@* get(id, owner) GET /api/v1/streams/:id → the stream, or null on 404* create(body, owner) POST /api/v1/streams → { stream, key: { id, key, hint } }* update(id, fields, owner) PATCH /api/v1/streams/:id → the updated stream, or null on 404++ * showOnLive(id, owner) PATCH /api/v1/streams/:id { mirror_to_live: true } as the owner: OpenRestream++ * links it to a slot on their Live channel once → { stream, live? }, or null on 404* rotate(id, owner, { grace_seconds, end_sessions })* POST /api/v1/streams/:id/keys/rotate → { key: { id, key, hint }, … }* archive(id, owner) DELETE /api/v1/streams/:id (OpenRestream refuses a live stream: 409)@@@ -119,6 +121,10 @@ function createOpenRe(config, { fetchImpl = globalThis.fetch } = {}) {const out = await call('PATCH', stream(id), { owner, body, named: true });return (out && out.stream) || null;},++ async showOnLive(id, owner) {++ const out = await call('PATCH', stream(id), { owner, body: { mirror_to_live: true }, named: true });++ return out && out.stream ? { stream: out.stream, live: out.live || null } : null;++ },rotate: (id, owner, { grace_seconds = 0, end_sessions = false } = {}) =>call('POST', `${stream(id)}/keys/rotate`, { owner, body: { grace_seconds, end_sessions }, named: true }),archive: (id, owner) => call('DELETE', stream(id), { owner, named: true }),@@@ -871,7 +871,7 @@ ${control ? `<div class="share-controls">* /machines/:id/desks: the desks running on the owner's machine (Desks plan), each a computer of its own for an agent:* its kind, size and start, Watch and take over (the screen page on that desk), and Stop.*/--function renderMachineDesksPage({ machine, desks = [], maxDesks = null, error = null, stopped = null }) {++function renderMachineDesksPage({ machine, desks = [], maxDesks = null, error = null, stopped = null, live = null }) {const name = machine.name || machine.id;const id = esc(machine.id);const KIND = { browser: 'Browser desk', desktop: 'Whole desk' };@@@ -884,7 +884,12 @@ function renderMachineDesksPage({ machine, desks = [], maxDesks = null, error =const rows = desks.map((d) => `<li class="robot-card"><span class="robot-name">${icon('plug')}<span><code>${esc(d.desk)}</code></span></span>`+ `<span class="robot-meta"><span class="pill">${esc(KIND[d.kind] || 'Desk')}</span>${Number.isInteger(d.width) ? ` <span class="muted">${d.width}×${d.height}</span>` : ''}${since(d)}</span>`+ `<span class="robot-actions"><a class="button" href="/machines/${id}/screen?desk=${encodeURIComponent(d.desk)}">Watch and take over</a>`-- + `<form method="post" action="/machines/${id}/desks/stop"><input type="hidden" name="desk" value="${esc(d.desk)}"><button type="submit" class="button quiet">Stop</button></form></span></li>`).join('');++ + `<form method="post" action="/machines/${id}/desks/stop"><input type="hidden" name="desk" value="${esc(d.desk)}"><button type="submit" class="button quiet">Stop</button></form></span>`++ // On the owner's OpenVibe Live channel, when they choose (the Desks plan's D8): a second step says what goes public.++ + `<details class="desk-live"><summary class="button quiet">Show live on my channel</summary>`++ + `<form method="post" action="/machines/${id}/desks/live"><input type="hidden" name="desk" value="${esc(d.desk)}">`++ + `<p class="muted">Everyone can watch this desk on your OpenVibe Live channel for up to 30 minutes: what your agent does and every page it opens, signed-in pages included. Stop showing it on its screen page, or stop the desk.</p>`++ + `<button type="submit" class="button primary">Go live with this desk</button></form></details></li>`).join('');const list = error ? `<p class="error" role="alert">${esc(error)}</p>`: rows ? `<ul class="robots">${rows}</ul>`: `<div class="empty">${icon('plug', 'icon empty-icon')}<p class="empty-title">No desks are running.</p><p>An agent's task starts a desk of its own here when it needs one, and stops it when it is done.</p></div>`;@@@ -893,6 +898,7 @@ function renderMachineDesksPage({ machine, desks = [], maxDesks = null, error =<h1>${esc(name)}: desks</h1><p class="muted">Each desk is a computer of its own on this machine, where one of your agents works: its own screen or browser, files and sign-ins. Watch one live and take it over, or stop it; a task whose desk you stop goes on without it.</p>${stopped ? `<p class="setting-ok" role="status">${icon('check')}<span>Stopped <code>${esc(stopped)}</code>.</span></p>` : ''}++${live ? `<p class="setting-ok" role="status">${icon('check')}<span><code>${esc(live.desk)}</code> is live${live.channel ? ` on <a href="${esc(live.channel)}">${esc(live.channel.replace(/^https:\/\//, ''))}</a>` : ' on your OpenVibe Live channel'} for up to 30 minutes. <a href="/machines/${id}/screen?desk=${encodeURIComponent(live.desk)}">Its screen page</a> stops it.</span></p>` : ''}${list}${Number.isInteger(maxDesks) ? `<p class="muted small">At most ${maxDesks} whole ${maxDesks === 1 ? 'desk' : 'desks'} at once here; a browser desk counts as half.</p>` : ''}</main>`;@@@ -676,7 +676,21 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi} else error = `The machine did not list its desks: ${(r && (r.reason || r.result)) || 'no answer'}`.slice(0, 300);}const stopped = typeof req.query.stopped === 'string' && machineScreen.DESK.test(req.query.stopped) ? req.query.stopped : null;-- res.type('html').send(renderMachineDesksPage({ machine: mine, desks, maxDesks, error, stopped }));++ const liveDesk = typeof req.query.live === 'string' && machineScreen.DESK.test(req.query.live) && req.query.live !== 'main' ? req.query.live : null;++ const channel = typeof req.query.channel === 'string' && /^https:\/\/openvibe\.live\/@[A-Za-z0-9_.-]{1,40}$/.test(req.query.channel) ? req.query.channel : null;++ res.type('html').send(renderMachineDesksPage({ machine: mine, desks, maxDesks, error, stopped, live: liveDesk ? { desk: liveDesk, channel } : null }));++ }));++ // A desk on the owner's OpenVibe Live channel for up to 30 minutes (the Desks plan's D8), from the desks page's++ // "Show live on my channel" and its confirmation.++ r.post('/machines/:id/desks/live', ...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 desk = req.body && typeof req.body.desk === 'string' ? req.body.desk : '';++ if (!machineScreen.DESK.test(desk) || desk === 'main') throw new BotError(422, 'bot.invalid_input', 'desk is private or desk-<name>');++ const out = await machineScreen.start(mine, me.subject, asUser(me), { desk, live: true, seconds: machineScreen.limits.SECONDS.max });++ const channel = out.live && /^https:\/\/openvibe\.live\/@[A-Za-z0-9_.-]{1,40}$/.test(String(out.live.channel_url || '')) ? out.live.channel_url : '';++ res.redirect(303, `/machines/${encodeURIComponent(mine.id)}/desks?live=${encodeURIComponent(desk)}${channel ? `&channel=${encodeURIComponent(channel)}` : ''}`);}));r.post('/machines/:id/desks/stop', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {const me = requireUser(req);@@@ -194,6 +194,8 @@ async function startOpenRe({ token = `ovt_${crypto.randomBytes(16).toString('hexconst streams = new Map();const sessions = new Map();const calls = [];++ const noLive = new Set(); // owners with no OpenVibe Live account (a Live link is refused)++ let slotSeq = 40;let failure = null;let failuresLeft = 0; // how many further requests failNext answers before it clearslet n = 0;@@@ -311,6 +313,14 @@ async function startOpenRe({ token = `ovt_${crypto.randomBytes(16).toString('hexif (st && !m[2] && req.method === 'PATCH') {// Only the streaming toggles are writable here, as OpenRestream's PATCH accepts: the rest stays put.if (body && body.recording_mode !== undefined) st.recording_mode = body.recording_mode;++ // Switching mirror_to_live on links a slot on the owner's Live channel once, as OpenRestream does for the++ // person or a service acting for them (OpenRestream#117); an owner in noLive has no Live account.++ if (body && body.mirror_to_live === true && !st.refs.some((r) => r.service === 'live')) {++ if (noLive.has(st.owner)) return send(res, 200, { stream: view(st), live: { linked: false, code: 'live.no_account', reason: 'You have no OpenVibe Live channel yet. Sign in to openvibe.live once, then switch this on again.' } });++ st.refs.push({ service: 'live', type: 'managed_stream', id: String(++slotSeq), label: `https://openvibe.live/@${st.owner.slice(-6).toLowerCase()}` });++ st.mirror_to_live = true;++ return send(res, 200, { stream: view(st), live: { linked: true, channel_url: st.refs.at(-1).label } });++ }if (body && body.mirror_to_live !== undefined) st.mirror_to_live = !!body.mirror_to_live;return send(res, 200, { stream: view(st) });}@@@ -333,7 +343,7 @@ async function startOpenRe({ token = `ovt_${crypto.randomBytes(16).toString('hex});const url = await listen(server);return {-- url, token, streams, sessions, calls, admits, goLive, endLive,++ url, token, streams, sessions, calls, admits, goLive, endLive, noLive,failNext: (what, times = 1) => { failure = what; failuresLeft = what == null ? 0 : times; },setCaps: (list) => { granted = [...(list || caps)]; },close: () => { server.closeAllConnections(); return new Promise((r) => server.close(r)); },@@@ -386,6 +386,7 @@ const { boot, check, done } = require('./helpers/app');assert.ok(html.includes('<code>desk-task-a</code>') && html.includes('Browser desk') && html.includes('Whole desk'));assert.ok(html.includes(`href="/machines/${host.deviceId}/screen?desk=desk-task-a"`), 'Watch and take over that desk');assert.ok(html.includes('name="desk" value="desk-task-a"'), 'its Stop');++ assert.ok(html.includes(`action="/machines/${host.deviceId}/desks/live"`) && html.includes('Show live on my channel') && html.includes('signed-in pages included'), 'and, a second step away, live on the owner\'s channel (Desks plan D8)');assert.ok(html.includes('At most 4 whole desks at once here'));assert.strictEqual((await fetch(`${t.base}/machines/${host.deviceId}/desks`, { headers: cookie(bob) })).status, 404, 'nobody else');const stop = (body, headers = {}) => fetch(`${t.base}/machines/${host.deviceId}/desks/stop`, { method: 'POST', redirect: 'manual', body, headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...cookie(alex), ...headers } });@@@ -229,6 +229,45 @@ const { boot, check, done } = require('./helpers/app');m.c.close();});++ await check('a desk on the owner\'s OpenVibe Live channel (Desks plan D8): a public stream of its own, linked to their channel; never their own screen; Live\'s refusal said', async () => {++ const host = await machine(alex, { ...SCREEN, desk: { screen: true, named: true, kinds: ['desktop', 'browser'] } });++ const privateBefore = [...t.openre.streams.values()].filter((x) => x.refs.some((r) => r.type === 'machine' && r.id === host.deviceId)).map((x) => [x.id, x.mirror_to_live, x.playback_visibility]);++ assert.strictEqual((await screen('POST', host.deviceId, { user: alex, body: { live: true } })).json.code, 'bot.live_desk_only', 'never the person\'s own screen');++ const r = await screen('POST', host.deviceId, { user: alex, body: { desk: 'desk-task-a', live: true, seconds: 1800 } });++ assert.strictEqual(r.status, 201, r.text);++ assert.match(r.json.live.channel_url, /^https:\/\/openvibe\.live\/@/);++ const sent = seen.at(-1);++ assert.deepStrictEqual([sent.kind, sent.value.action, sent.value.desk, sent.value.seconds], ['screen_stream', 'start', 'desk-task-a', 1800]);++ const live = [...t.openre.streams.values()].find((x) => x.refs.some((ref) => ref.type === 'machine_live' && ref.id === host.deviceId));++ assert.ok(live, 'a stream of its own for Live');++ assert.deepStrictEqual([live.playback_visibility, live.mirror_to_live, live.refs.some((ref) => ref.service === 'live')], ['public', true, true], 'public and linked to the channel');++ assert.ok(t.openre.admits(sent.value.publish_key) && live.keys.some((k) => k.key === sent.value.publish_key && k.status === 'active'), 'the machine publishes to it');++ const privateAfter = [...t.openre.streams.values()].filter((x) => x.refs.some((r) => r.type === 'machine' && r.id === host.deviceId)).map((x) => [x.id, x.mirror_to_live, x.playback_visibility]);++ assert.deepStrictEqual(privateAfter, privateBefore, 'the private screen stream is never touched');++ // Again: the same stream, a new key, linked once.++ const again = await screen('POST', host.deviceId, { user: alex, body: { desk: 'desk-task-a', live: true } });++ assert.strictEqual(again.json.live.channel_url, r.json.live.channel_url);++ assert.strictEqual([...t.openre.streams.values()].filter((x) => x.refs.some((ref) => ref.type === 'machine_live' && ref.id === host.deviceId)).length, 1);++ // From the desks page: the second step posts the desk, and the page says where it is live.++ assert.strictEqual((await form(`/machines/${host.deviceId}/desks/live`, alex, { Origin: 'https://evil.test' })).status, 403, 'no cross-site post');++ const posted = await fetch(`${t.base}/machines/${host.deviceId}/desks/live`, { method: 'POST', redirect: 'manual', body: 'desk=desk-task-a', headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...cookie(alex) } });++ assert.strictEqual(posted.status, 303);++ const where = posted.headers.get('location');++ assert.match(where, /\/desks\?live=desk-task-a&channel=https%3A%2F%2Fopenvibe\.live%2F%40/);++ const page = await (await fetch(t.base + where, { headers: cookie(alex) })).text();++ assert.ok(page.includes('<code>desk-task-a</code> is live on <a href="https://openvibe.live/@'), 'the page says where');++ // Without a Live account: Live's reason, and nothing is sent to the machine.++ const bobs = await machine(bob, { ...SCREEN, desk: { screen: true, named: true } });++ t.openre.noLive.add(bob.subject);++ const before = seen.length;++ const refused = await screen('POST', bobs.deviceId, { user: bob, body: { desk: 'desk-task-b', live: true } });++ assert.deepStrictEqual([refused.status, refused.json.code], [409, 'live.no_account'], refused.text);++ assert.match(refused.json.detail || '', /Sign in to openvibe\.live once/);++ assert.strictEqual(seen.length, before, 'nothing went to the machine');++ host.c.close();++ bobs.c.close();++ });++await check('removing the machine archives its screen stream', async () => {const r = await form(`/machines/${mine.deviceId}/remove`, alex);assert.strictEqual(r.status, 303);
@@ -217,6 +217,12 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
@@ -217,6 +217,12 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
/* A machine's computer plugin (Node docs/computer.md): its level, what waits on it, and the agent switch, full width. */
/* A machine's computer plugin (Node docs/computer.md): its level, what waits on it, and the agent switch, full width. */
.machine-computer { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem 0.6rem; padding-top: 0.6rem; border-top: 1px solid var(--line); }
.machine-computer { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem 0.6rem; padding-top: 0.6rem; border-top: 1px solid var(--line); }
.machine-ai { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem 0.6rem; flex-basis: 100%; }
.machine-ai { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem 0.6rem; flex-basis: 100%; }
+/* A desk on the owner's Live channel: the second step, under the desk's own buttons. */
+.desk-live { flex-basis: 100%; }
+.desk-live > summary { list-style: none; display: inline-flex; }
+.desk-live > summary::-webkit-details-marker { display: none; }
+.desk-live form { display: grid; gap: 0.5rem; margin-top: 0.5rem; max-width: 36rem; }
+.desk-live form p { margin: 0; }
@media (min-width: 36rem) { .robot-card { grid-template-columns: minmax(0, 1fr) auto; align-items: center; } .robot-meta { grid-column: 1; grid-row: 2; } .robot-actions { grid-column: 2; grid-row: 1 / span 2; } .machine-computer { grid-column: 1 / -1; grid-row: 3; } .machine-computer form { margin-left: auto; } }
@media (min-width: 36rem) { .robot-card { grid-template-columns: minmax(0, 1fr) auto; align-items: center; } .robot-meta { grid-column: 1; grid-row: 2; } .robot-actions { grid-column: 2; grid-row: 1 / span 2; } .machine-computer { grid-column: 1 / -1; grid-row: 3; } .machine-computer form { margin-left: auto; } }
.empty { text-align: center; padding: 2rem 1rem; margin-bottom: 1.5rem; border: 1px dashed var(--line); border-radius: var(--radius); color: var(--muted); }
.empty { text-align: center; padding: 2rem 1rem; margin-bottom: 1.5rem; border: 1px dashed var(--line); border-radius: var(--radius); color: var(--muted); }
.empty-icon { width: 3rem; height: 3rem; stroke-width: 1.5; color: var(--accent); }
.empty-icon { width: 3rem; height: 3rem; stroke-width: 1.5; color: var(--accent); }
@@ -489,7 +489,7 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
@@ -489,7 +489,7 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
};
};
r.post('/machines/:id/screen', manage, wrap(async (req, res) => {
r.post('/machines/:id/screen', manage, wrap(async (req, res) => {
const { device, subject } = await screenOwner(req);
const { device, subject } = await screenOwner(req);
- const out = await machineScreen.start(device, subject, req.principal, { seconds: req.body?.seconds, fps: req.body?.fps, desk: req.body?.desk });
+ const out = await machineScreen.start(device, subject, req.principal, { seconds: req.body?.seconds, fps: req.body?.fps, desk: req.body?.desk, live: req.body?.live === true });
res.status(201).json({ machine_id: device.id, ...out });
res.status(201).json({ machine_id: device.id, ...out });
}));
}));
r.get('/machines/:id/screen', wrap(async (req, res) => {
r.get('/machines/:id/screen', wrap(async (req, res) => {
@@ -720,6 +720,41 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -720,6 +720,41 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
}
}
return { streamId: out.streamId, key: out.key.key, whipUrl: whipUrl(out.key.key) };
return { streamId: out.streamId, key: out.key.key, whipUrl: whipUrl(out.key.key) };
}
}
+ /**
+ * A desk shown on the owner's OpenVibe Live channel, at their request (the Desks plan's D8) → { streamId, key, whipUrl,
+ * live: { linked, channel_url?, code?, reason? } }. A second stream of the machine's (external ref
+ * bot:machine_live:<id>), public, never the private screen stream: switching its mirror_to_live on has OpenRestream
+ * link it to a slot on their channel (once; Live then shows each session at openvibe.live/@them). Each start rotates
+ * the key. Throws 503 without OpenRestream or a WHIP ingest.
+ */
+ async function liveScreenKey(device, owner) {
+ if (!openre || !whipUrl('x')) fail(503, 'bot.openre_unavailable', 'OpenRestream is not configured here, so a desk cannot go live');
+ const found = await openre.find(`bot:machine_live:${device.id}`, owner);
+ let out = null;
+ if (found && found.id) {
+ const r = await openre.rotate(found.id, owner, { grace_seconds: 0, end_sessions: true });
+ out = r && { streamId: found.id, key: r.key };
+ }
+ if (!out) {
+ const label = String(device.name || device.id).slice(0, 100);
+ const created = await openre.create({
+ title: `${label}: my agent at work`, protocols: ['webrtc'], recording_mode: 'none', recording_visibility: 'public',
+ playback_visibility: 'public', mirror_to_live: false, show_on_live: false,
+ external_refs: [{ service: 'bot', type: 'machine_live', id: device.id, label }],
+ }, owner);
+ out = created && created.stream && { streamId: created.stream.id, key: created.key };
+ }
+ if (!out || typeof out.streamId !== 'string' || !out.key || typeof out.key.key !== 'string' || !out.key.key) {
+ fail(503, 'bot.openre_unavailable', 'OpenRestream answered no ingest key for the desk');
+ }
+ const shown = await openre.showOnLive(out.streamId, owner);
+ const slot = shown && shown.stream && (shown.stream.external_refs || []).find((r) => r && r.service === 'live' && r.type === 'managed_stream');
+ const channel = (shown && shown.live && shown.live.channel_url) || (slot && slot.label) || null;
+ const live = shown && shown.live && shown.live.linked === false
+ ? { linked: false, code: shown.live.code || null, reason: shown.live.reason || null }
+ : { linked: !!(shown && shown.stream && shown.stream.mirror_to_live && channel), channel_url: channel };
+ return { streamId: out.streamId, key: out.key.key, whipUrl: whipUrl(out.key.key), live };
+ }
/** The viewer signaling URL of the machine's open screen session, as its owner, or null (none is live yet). */
/** The viewer signaling URL of the machine's open screen session, as its owner, or null (none is live yet). */
async function screenLive(device, owner) {
async function screenLive(device, owner) {
if (!openre || !device.screen_stream_id) return null;
if (!openre || !device.screen_stream_id) return null;
@@ -1253,7 +1288,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -1253,7 +1288,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, driverForProfile, whipUrl, onPaired },
pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, driverForProfile, whipUrl, onPaired },
devices: { byCredential, bindNode, issuePublishKey, revokeVideo, updateDeclared, revokeNode, ensureServerDevice, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, revokeByNode, touchSeen, setOnline, ownerOf, onChange },
devices: { byCredential, bindNode, issuePublishKey, revokeVideo, updateDeclared, revokeNode, ensureServerDevice, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, revokeByNode, touchSeen, setOnline, ownerOf, onChange },
machines: { mint: mintMachine, list: listMachines, workerRef, setAgentComputer, setAiModels, setWatchProbe, setVoice,
machines: { mint: mintMachine, list: listMachines, workerRef, setAgentComputer, setAiModels, setWatchProbe, setVoice,
- screen: { key: screenKey, live: screenLive, end: screenEnd, archive: screenArchive } },
+ screen: { key: screenKey, liveKey: liveScreenKey, live: screenLive, end: screenEnd, archive: screenArchive } },
streaming: { get: streaming, set: setStreaming },
streaming: { get: streaming, set: setStreaming },
video: { live: liveVideo },
video: { live: liveVideo },
estop: { set: setEstop, clear: clearEstop },
estop: { set: setEstop, clear: clearEstop },
@@ -78,7 +78,14 @@ function createMachineScreen({ domain, hub, now = () => Date.now() }) {
@@ -78,7 +78,14 @@ function createMachineScreen({ domain, hub, now = () => Date.now() }) {
if (desk !== 'main' && desk !== 'private' && offered.desk.named !== true) {
if (desk !== 'main' && desk !== 'private' && offered.desk.named !== true) {
throw new BotError(409, 'bot.computer_off', 'this machine runs one desk only (private): several named desks need OpenVibe Node v0.19.0 or newer');
throw new BotError(409, 'bot.computer_off', 'this machine runs one desk only (private): several named desks need OpenVibe Node v0.19.0 or newer');
}
}
- const key = await domain.machines.screen.key(device, subject);
+ // On the owner's OpenVibe Live channel (the Desks plan's D8): a desk only, never their own screen, on a public
+ // stream of its own that OpenRestream links to their channel; refused with Live's reason when it cannot be.
+ const live = opts.live === true;
+ if (live && desk === 'main') throw new BotError(422, 'bot.live_desk_only', 'only a desk goes on your Live channel, never your own screen');
+ const key = live ? await domain.machines.screen.liveKey(device, subject) : await domain.machines.screen.key(device, subject);
+ if (live && !key.live.linked) {
+ throw new BotError(409, key.live.code || 'bot.live_refused', key.live.reason || 'OpenVibe Live did not take the desk: sign in to openvibe.live once, then try again');
+ }
const r = await hub.machineScreen(device.id, principal, subject, { action: 'start', whip_url: key.whipUrl, publish_key: key.key, seconds, fps, ...(desk !== 'main' ? { desk } : {}) });
const r = await hub.machineScreen(device.id, principal, subject, { action: 'start', whip_url: key.whipUrl, publish_key: key.key, seconds, fps, ...(desk !== 'main' ? { desk } : {}) });
if (r.result !== 'ack') {
if (r.result !== 'ack') {
const code = r.code || (r.result === 'expired' ? 'bot.device_timeout' : 'bot.device_refused');
const code = r.code || (r.result === 'expired' ? 'bot.device_timeout' : 'bot.device_refused');
@@ -88,7 +95,7 @@ function createMachineScreen({ domain, hub, now = () => Date.now() }) {
@@ -88,7 +95,7 @@ function createMachineScreen({ domain, hub, now = () => Date.now() }) {
const data = r.data && typeof r.data === 'object' ? r.data : {};
const data = r.data && typeof r.data === 'object' ? r.data : {};
const until = Number.isFinite(data.until_ms) ? new Date(data.until_ms).toISOString() : new Date(now() + seconds * 1000).toISOString();
const until = Number.isFinite(data.until_ms) ? new Date(data.until_ms).toISOString() : new Date(now() + seconds * 1000).toISOString();
shown.set(device.id, desk);
shown.set(device.id, desk);
- return { streaming: true, seconds, fps, until, desk };
+ return { streaming: true, seconds, fps, until, desk, ...(live ? { live: { channel_url: key.live.channel_url } } : {}) };
},
},
async state(device, subject) {
async state(device, subject) {
const sensors = hub ? ((hub.deviceState(device.id).telemetry || {}).sensors || {}) : {};
const sensors = hub ? ((hub.deviceState(device.id).telemetry || {}).sensors || {}) : {};
@@ -19,6 +19,8 @@
@@ -19,6 +19,8 @@
* get(id, owner) GET /api/v1/streams/:id → the stream, or null on 404
* get(id, owner) GET /api/v1/streams/:id → the stream, or null on 404
* create(body, owner) POST /api/v1/streams → { stream, key: { id, key, hint } }
* create(body, owner) POST /api/v1/streams → { stream, key: { id, key, hint } }
* update(id, fields, owner) PATCH /api/v1/streams/:id → the updated stream, or null on 404
* update(id, fields, owner) PATCH /api/v1/streams/:id → the updated stream, or null on 404
+ * showOnLive(id, owner) PATCH /api/v1/streams/:id { mirror_to_live: true } as the owner: OpenRestream
+ * links it to a slot on their Live channel once → { stream, live? }, or null on 404
* rotate(id, owner, { grace_seconds, end_sessions })
* rotate(id, owner, { grace_seconds, end_sessions })
* POST /api/v1/streams/:id/keys/rotate → { key: { id, key, hint }, … }
* POST /api/v1/streams/:id/keys/rotate → { key: { id, key, hint }, … }
* archive(id, owner) DELETE /api/v1/streams/:id (OpenRestream refuses a live stream: 409)
* archive(id, owner) DELETE /api/v1/streams/:id (OpenRestream refuses a live stream: 409)
@@ -119,6 +121,10 @@ function createOpenRe(config, { fetchImpl = globalThis.fetch } = {}) {
@@ -119,6 +121,10 @@ function createOpenRe(config, { fetchImpl = globalThis.fetch } = {}) {
const out = await call('PATCH', stream(id), { owner, body, named: true });
const out = await call('PATCH', stream(id), { owner, body, named: true });
return (out && out.stream) || null;
return (out && out.stream) || null;
},
},
+ async showOnLive(id, owner) {
+ const out = await call('PATCH', stream(id), { owner, body: { mirror_to_live: true }, named: true });
+ return out && out.stream ? { stream: out.stream, live: out.live || null } : null;
+ },
rotate: (id, owner, { grace_seconds = 0, end_sessions = false } = {}) =>
rotate: (id, owner, { grace_seconds = 0, end_sessions = false } = {}) =>
call('POST', `${stream(id)}/keys/rotate`, { owner, body: { grace_seconds, end_sessions }, named: true }),
call('POST', `${stream(id)}/keys/rotate`, { owner, body: { grace_seconds, end_sessions }, named: true }),
archive: (id, owner) => call('DELETE', stream(id), { owner, named: true }),
archive: (id, owner) => call('DELETE', stream(id), { owner, named: true }),
@@ -871,7 +871,7 @@ ${control ? `<div class="share-controls">
@@ -871,7 +871,7 @@ ${control ? `<div class="share-controls">
* /machines/:id/desks: the desks running on the owner's machine (Desks plan), each a computer of its own for an agent:
* /machines/:id/desks: the desks running on the owner's machine (Desks plan), each a computer of its own for an agent:
* its kind, size and start, Watch and take over (the screen page on that desk), and Stop.
* its kind, size and start, Watch and take over (the screen page on that desk), and Stop.
*/
*/
-function renderMachineDesksPage({ machine, desks = [], maxDesks = null, error = null, stopped = null }) {
+function renderMachineDesksPage({ machine, desks = [], maxDesks = null, error = null, stopped = null, live = null }) {
const name = machine.name || machine.id;
const name = machine.name || machine.id;
const id = esc(machine.id);
const id = esc(machine.id);
const KIND = { browser: 'Browser desk', desktop: 'Whole desk' };
const KIND = { browser: 'Browser desk', desktop: 'Whole desk' };
@@ -884,7 +884,12 @@ function renderMachineDesksPage({ machine, desks = [], maxDesks = null, error =
@@ -884,7 +884,12 @@ function renderMachineDesksPage({ machine, desks = [], maxDesks = null, error =
const rows = desks.map((d) => `<li class="robot-card"><span class="robot-name">${icon('plug')}<span><code>${esc(d.desk)}</code></span></span>`
const rows = desks.map((d) => `<li class="robot-card"><span class="robot-name">${icon('plug')}<span><code>${esc(d.desk)}</code></span></span>`
+ `<span class="robot-meta"><span class="pill">${esc(KIND[d.kind] || 'Desk')}</span>${Number.isInteger(d.width) ? ` <span class="muted">${d.width}×${d.height}</span>` : ''}${since(d)}</span>`
+ `<span class="robot-meta"><span class="pill">${esc(KIND[d.kind] || 'Desk')}</span>${Number.isInteger(d.width) ? ` <span class="muted">${d.width}×${d.height}</span>` : ''}${since(d)}</span>`
+ `<span class="robot-actions"><a class="button" href="/machines/${id}/screen?desk=${encodeURIComponent(d.desk)}">Watch and take over</a>`
+ `<span class="robot-actions"><a class="button" href="/machines/${id}/screen?desk=${encodeURIComponent(d.desk)}">Watch and take over</a>`
- + `<form method="post" action="/machines/${id}/desks/stop"><input type="hidden" name="desk" value="${esc(d.desk)}"><button type="submit" class="button quiet">Stop</button></form></span></li>`).join('');
+ + `<form method="post" action="/machines/${id}/desks/stop"><input type="hidden" name="desk" value="${esc(d.desk)}"><button type="submit" class="button quiet">Stop</button></form></span>`
+ // On the owner's OpenVibe Live channel, when they choose (the Desks plan's D8): a second step says what goes public.
+ + `<details class="desk-live"><summary class="button quiet">Show live on my channel</summary>`
+ + `<form method="post" action="/machines/${id}/desks/live"><input type="hidden" name="desk" value="${esc(d.desk)}">`
+ + `<p class="muted">Everyone can watch this desk on your OpenVibe Live channel for up to 30 minutes: what your agent does and every page it opens, signed-in pages included. Stop showing it on its screen page, or stop the desk.</p>`
+ + `<button type="submit" class="button primary">Go live with this desk</button></form></details></li>`).join('');
const list = error ? `<p class="error" role="alert">${esc(error)}</p>`
const list = error ? `<p class="error" role="alert">${esc(error)}</p>`
: rows ? `<ul class="robots">${rows}</ul>`
: rows ? `<ul class="robots">${rows}</ul>`
: `<div class="empty">${icon('plug', 'icon empty-icon')}<p class="empty-title">No desks are running.</p><p>An agent's task starts a desk of its own here when it needs one, and stops it when it is done.</p></div>`;
: `<div class="empty">${icon('plug', 'icon empty-icon')}<p class="empty-title">No desks are running.</p><p>An agent's task starts a desk of its own here when it needs one, and stops it when it is done.</p></div>`;
@@ -893,6 +898,7 @@ function renderMachineDesksPage({ machine, desks = [], maxDesks = null, error =
@@ -893,6 +898,7 @@ function renderMachineDesksPage({ machine, desks = [], maxDesks = null, error =
<h1>${esc(name)}: desks</h1>
<h1>${esc(name)}: desks</h1>
<p class="muted">Each desk is a computer of its own on this machine, where one of your agents works: its own screen or browser, files and sign-ins. Watch one live and take it over, or stop it; a task whose desk you stop goes on without it.</p>
<p class="muted">Each desk is a computer of its own on this machine, where one of your agents works: its own screen or browser, files and sign-ins. Watch one live and take it over, or stop it; a task whose desk you stop goes on without it.</p>
${stopped ? `<p class="setting-ok" role="status">${icon('check')}<span>Stopped <code>${esc(stopped)}</code>.</span></p>` : ''}
${stopped ? `<p class="setting-ok" role="status">${icon('check')}<span>Stopped <code>${esc(stopped)}</code>.</span></p>` : ''}
+${live ? `<p class="setting-ok" role="status">${icon('check')}<span><code>${esc(live.desk)}</code> is live${live.channel ? ` on <a href="${esc(live.channel)}">${esc(live.channel.replace(/^https:\/\//, ''))}</a>` : ' on your OpenVibe Live channel'} for up to 30 minutes. <a href="/machines/${id}/screen?desk=${encodeURIComponent(live.desk)}">Its screen page</a> stops it.</span></p>` : ''}
${list}
${list}
${Number.isInteger(maxDesks) ? `<p class="muted small">At most ${maxDesks} whole ${maxDesks === 1 ? 'desk' : 'desks'} at once here; a browser desk counts as half.</p>` : ''}
${Number.isInteger(maxDesks) ? `<p class="muted small">At most ${maxDesks} whole ${maxDesks === 1 ? 'desk' : 'desks'} at once here; a browser desk counts as half.</p>` : ''}
</main>`;
</main>`;
@@ -676,7 +676,21 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -676,7 +676,21 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
} else error = `The machine did not list its desks: ${(r && (r.reason || r.result)) || 'no answer'}`.slice(0, 300);
} else error = `The machine did not list its desks: ${(r && (r.reason || r.result)) || 'no answer'}`.slice(0, 300);
}
}
const stopped = typeof req.query.stopped === 'string' && machineScreen.DESK.test(req.query.stopped) ? req.query.stopped : null;
const stopped = typeof req.query.stopped === 'string' && machineScreen.DESK.test(req.query.stopped) ? req.query.stopped : null;
- res.type('html').send(renderMachineDesksPage({ machine: mine, desks, maxDesks, error, stopped }));
+ const liveDesk = typeof req.query.live === 'string' && machineScreen.DESK.test(req.query.live) && req.query.live !== 'main' ? req.query.live : null;
+ const channel = typeof req.query.channel === 'string' && /^https:\/\/openvibe\.live\/@[A-Za-z0-9_.-]{1,40}$/.test(req.query.channel) ? req.query.channel : null;
+ res.type('html').send(renderMachineDesksPage({ machine: mine, desks, maxDesks, error, stopped, live: liveDesk ? { desk: liveDesk, channel } : null }));
+ }));
+ // A desk on the owner's OpenVibe Live channel for up to 30 minutes (the Desks plan's D8), from the desks page's
+ // "Show live on my channel" and its confirmation.
+ r.post('/machines/:id/desks/live', ...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 desk = req.body && typeof req.body.desk === 'string' ? req.body.desk : '';
+ if (!machineScreen.DESK.test(desk) || desk === 'main') throw new BotError(422, 'bot.invalid_input', 'desk is private or desk-<name>');
+ const out = await machineScreen.start(mine, me.subject, asUser(me), { desk, live: true, seconds: machineScreen.limits.SECONDS.max });
+ const channel = out.live && /^https:\/\/openvibe\.live\/@[A-Za-z0-9_.-]{1,40}$/.test(String(out.live.channel_url || '')) ? out.live.channel_url : '';
+ res.redirect(303, `/machines/${encodeURIComponent(mine.id)}/desks?live=${encodeURIComponent(desk)}${channel ? `&channel=${encodeURIComponent(channel)}` : ''}`);
}));
}));
r.post('/machines/:id/desks/stop', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
r.post('/machines/:id/desks/stop', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
const me = requireUser(req);
const me = requireUser(req);
@@ -194,6 +194,8 @@ async function startOpenRe({ token = `ovt_${crypto.randomBytes(16).toString('hex
@@ -194,6 +194,8 @@ async function startOpenRe({ token = `ovt_${crypto.randomBytes(16).toString('hex
const streams = new Map();
const streams = new Map();
const sessions = new Map();
const sessions = new Map();
const calls = [];
const calls = [];
+ const noLive = new Set(); // owners with no OpenVibe Live account (a Live link is refused)
+ let slotSeq = 40;
let failure = null;
let failure = null;
let failuresLeft = 0; // how many further requests failNext answers before it clears
let failuresLeft = 0; // how many further requests failNext answers before it clears
let n = 0;
let n = 0;
@@ -311,6 +313,14 @@ async function startOpenRe({ token = `ovt_${crypto.randomBytes(16).toString('hex
@@ -311,6 +313,14 @@ async function startOpenRe({ token = `ovt_${crypto.randomBytes(16).toString('hex
if (st && !m[2] && req.method === 'PATCH') {
if (st && !m[2] && req.method === 'PATCH') {
// Only the streaming toggles are writable here, as OpenRestream's PATCH accepts: the rest stays put.
// Only the streaming toggles are writable here, as OpenRestream's PATCH accepts: the rest stays put.
if (body && body.recording_mode !== undefined) st.recording_mode = body.recording_mode;
if (body && body.recording_mode !== undefined) st.recording_mode = body.recording_mode;
+ // Switching mirror_to_live on links a slot on the owner's Live channel once, as OpenRestream does for the
+ // person or a service acting for them (OpenRestream#117); an owner in noLive has no Live account.
+ if (body && body.mirror_to_live === true && !st.refs.some((r) => r.service === 'live')) {
+ if (noLive.has(st.owner)) return send(res, 200, { stream: view(st), live: { linked: false, code: 'live.no_account', reason: 'You have no OpenVibe Live channel yet. Sign in to openvibe.live once, then switch this on again.' } });
+ st.refs.push({ service: 'live', type: 'managed_stream', id: String(++slotSeq), label: `https://openvibe.live/@${st.owner.slice(-6).toLowerCase()}` });
+ st.mirror_to_live = true;
+ return send(res, 200, { stream: view(st), live: { linked: true, channel_url: st.refs.at(-1).label } });
+ }
if (body && body.mirror_to_live !== undefined) st.mirror_to_live = !!body.mirror_to_live;
if (body && body.mirror_to_live !== undefined) st.mirror_to_live = !!body.mirror_to_live;
return send(res, 200, { stream: view(st) });
return send(res, 200, { stream: view(st) });
}
}
@@ -333,7 +343,7 @@ async function startOpenRe({ token = `ovt_${crypto.randomBytes(16).toString('hex
@@ -333,7 +343,7 @@ async function startOpenRe({ token = `ovt_${crypto.randomBytes(16).toString('hex
});
});
const url = await listen(server);
const url = await listen(server);
return {
return {
- url, token, streams, sessions, calls, admits, goLive, endLive,
+ url, token, streams, sessions, calls, admits, goLive, endLive, noLive,
failNext: (what, times = 1) => { failure = what; failuresLeft = what == null ? 0 : times; },
failNext: (what, times = 1) => { failure = what; failuresLeft = what == null ? 0 : times; },
setCaps: (list) => { granted = [...(list || caps)]; },
setCaps: (list) => { granted = [...(list || caps)]; },
close: () => { server.closeAllConnections(); return new Promise((r) => server.close(r)); },
close: () => { server.closeAllConnections(); return new Promise((r) => server.close(r)); },
@@ -386,6 +386,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -386,6 +386,7 @@ const { boot, check, done } = require('./helpers/app');
assert.ok(html.includes('<code>desk-task-a</code>') && html.includes('Browser desk') && html.includes('Whole desk'));
assert.ok(html.includes('<code>desk-task-a</code>') && html.includes('Browser desk') && html.includes('Whole desk'));
assert.ok(html.includes(`href="/machines/${host.deviceId}/screen?desk=desk-task-a"`), 'Watch and take over that desk');
assert.ok(html.includes(`href="/machines/${host.deviceId}/screen?desk=desk-task-a"`), 'Watch and take over that desk');
assert.ok(html.includes('name="desk" value="desk-task-a"'), 'its Stop');
assert.ok(html.includes('name="desk" value="desk-task-a"'), 'its Stop');
+ assert.ok(html.includes(`action="/machines/${host.deviceId}/desks/live"`) && html.includes('Show live on my channel') && html.includes('signed-in pages included'), 'and, a second step away, live on the owner\'s channel (Desks plan D8)');
assert.ok(html.includes('At most 4 whole desks at once here'));
assert.ok(html.includes('At most 4 whole desks at once here'));
assert.strictEqual((await fetch(`${t.base}/machines/${host.deviceId}/desks`, { headers: cookie(bob) })).status, 404, 'nobody else');
assert.strictEqual((await fetch(`${t.base}/machines/${host.deviceId}/desks`, { headers: cookie(bob) })).status, 404, 'nobody else');
const stop = (body, headers = {}) => fetch(`${t.base}/machines/${host.deviceId}/desks/stop`, { method: 'POST', redirect: 'manual', body, headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...cookie(alex), ...headers } });
const stop = (body, headers = {}) => fetch(`${t.base}/machines/${host.deviceId}/desks/stop`, { method: 'POST', redirect: 'manual', body, headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...cookie(alex), ...headers } });
@@ -229,6 +229,45 @@ const { boot, check, done } = require('./helpers/app');
@@ -229,6 +229,45 @@ const { boot, check, done } = require('./helpers/app');
m.c.close();
m.c.close();
});
});
+ await check('a desk on the owner\'s OpenVibe Live channel (Desks plan D8): a public stream of its own, linked to their channel; never their own screen; Live\'s refusal said', async () => {
+ const host = await machine(alex, { ...SCREEN, desk: { screen: true, named: true, kinds: ['desktop', 'browser'] } });
+ const privateBefore = [...t.openre.streams.values()].filter((x) => x.refs.some((r) => r.type === 'machine' && r.id === host.deviceId)).map((x) => [x.id, x.mirror_to_live, x.playback_visibility]);
+ assert.strictEqual((await screen('POST', host.deviceId, { user: alex, body: { live: true } })).json.code, 'bot.live_desk_only', 'never the person\'s own screen');
+ const r = await screen('POST', host.deviceId, { user: alex, body: { desk: 'desk-task-a', live: true, seconds: 1800 } });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.match(r.json.live.channel_url, /^https:\/\/openvibe\.live\/@/);
+ const sent = seen.at(-1);
+ assert.deepStrictEqual([sent.kind, sent.value.action, sent.value.desk, sent.value.seconds], ['screen_stream', 'start', 'desk-task-a', 1800]);
+ const live = [...t.openre.streams.values()].find((x) => x.refs.some((ref) => ref.type === 'machine_live' && ref.id === host.deviceId));
+ assert.ok(live, 'a stream of its own for Live');
+ assert.deepStrictEqual([live.playback_visibility, live.mirror_to_live, live.refs.some((ref) => ref.service === 'live')], ['public', true, true], 'public and linked to the channel');
+ assert.ok(t.openre.admits(sent.value.publish_key) && live.keys.some((k) => k.key === sent.value.publish_key && k.status === 'active'), 'the machine publishes to it');
+ const privateAfter = [...t.openre.streams.values()].filter((x) => x.refs.some((r) => r.type === 'machine' && r.id === host.deviceId)).map((x) => [x.id, x.mirror_to_live, x.playback_visibility]);
+ assert.deepStrictEqual(privateAfter, privateBefore, 'the private screen stream is never touched');
+ // Again: the same stream, a new key, linked once.
+ const again = await screen('POST', host.deviceId, { user: alex, body: { desk: 'desk-task-a', live: true } });
+ assert.strictEqual(again.json.live.channel_url, r.json.live.channel_url);
+ assert.strictEqual([...t.openre.streams.values()].filter((x) => x.refs.some((ref) => ref.type === 'machine_live' && ref.id === host.deviceId)).length, 1);
+ // From the desks page: the second step posts the desk, and the page says where it is live.
+ assert.strictEqual((await form(`/machines/${host.deviceId}/desks/live`, alex, { Origin: 'https://evil.test' })).status, 403, 'no cross-site post');
+ const posted = await fetch(`${t.base}/machines/${host.deviceId}/desks/live`, { method: 'POST', redirect: 'manual', body: 'desk=desk-task-a', headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...cookie(alex) } });
+ assert.strictEqual(posted.status, 303);
+ const where = posted.headers.get('location');
+ assert.match(where, /\/desks\?live=desk-task-a&channel=https%3A%2F%2Fopenvibe\.live%2F%40/);
+ const page = await (await fetch(t.base + where, { headers: cookie(alex) })).text();
+ assert.ok(page.includes('<code>desk-task-a</code> is live on <a href="https://openvibe.live/@'), 'the page says where');
+ // Without a Live account: Live's reason, and nothing is sent to the machine.
+ const bobs = await machine(bob, { ...SCREEN, desk: { screen: true, named: true } });
+ t.openre.noLive.add(bob.subject);
+ const before = seen.length;
+ const refused = await screen('POST', bobs.deviceId, { user: bob, body: { desk: 'desk-task-b', live: true } });
+ assert.deepStrictEqual([refused.status, refused.json.code], [409, 'live.no_account'], refused.text);
+ assert.match(refused.json.detail || '', /Sign in to openvibe\.live once/);
+ assert.strictEqual(seen.length, before, 'nothing went to the machine');
+ host.c.close();
+ bobs.c.close();
+ });
+
await check('removing the machine archives its screen stream', async () => {
await check('removing the machine archives its screen stream', async () => {
const r = await form(`/machines/${mine.deviceId}/remove`, alex);
const r = await form(`/machines/${mine.deviceId}/remove`, alex);
assert.strictEqual(r.status, 303);
assert.strictEqual(r.status, 303);