openvibers/openvibe.bot Commits
A machine's desks: the card says how many run, and /machines/:id/desks lists them (kind, size, since) with Watch and take over, and Stop (#184)
Changes
@@@ -749,9 +749,13 @@ function renderMachinesPage({ machines = [], pairing = null }) {if (!c) return '';const can = [c.terminal && 'terminal', c.files && 'files', c.screen && 'screen', c.input && 'mouse and keyboard'].filter(Boolean).join(', ');const desks = c.desks ? `<span class="muted">${esc(deskWords(c.desks))}</span>` : '';++ // The desks running now (the Node's telemetry), and the page that lists them, to watch or stop one.++ const running = Number.isInteger(m.computer_desks) && m.computer_desks > 0++ ? ` <span class="pill">${m.computer_desks === 1 ? '1 desk running' : `${m.computer_desks} desks running`}</span>` : '';++ const deskLink = c.desks && m.online ? `<a class="button quiet" href="/machines/${esc(m.id)}/desks">See its desks</a>` : '';const waiting = Number.isInteger(m.computer_waiting) && m.computer_waiting > 0? ` <span class="pill warn">${m.computer_waiting} waiting for you on the machine</span>` : '';-- return `<div class="machine-computer"><span class="pill">Computer: ${esc(c.level ? LEVELS[c.level] : 'on')}</span><span class="muted">${esc(can)}</span>${desks}${waiting}`++ return `<div class="machine-computer"><span class="pill">Computer: ${esc(c.level ? LEVELS[c.level] : 'on')}</span><span class="muted">${esc(can)}</span>${desks}${running}${waiting}${deskLink}`+ `<form method="post" action="/machines/${esc(m.id)}/agent-computer"><input type="hidden" name="on" value="${m.agent_computer ? '0' : '1'}">`+ `<button type="submit" class="button quiet" aria-pressed="${m.agent_computer ? 'true' : 'false'}">${m.agent_computer ? 'Stop my agent using this computer' : 'Let my agent use this computer'}</button></form>`+ (c.screenStream && m.online ? `<form method="post" action="/machines/${esc(m.id)}/screen"><button type="submit" class="button quiet">${m.computer_screen_stream === 'live' ? 'Watch my screen' : 'Show my screen'}</button></form>` : '')@@@ -836,6 +840,38 @@ ${control ? `<div class="share-controls">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.' });}++/**++ * /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 }) {++ const name = machine.name || machine.id;++ const id = esc(machine.id);++ const KIND = { browser: 'Browser desk', desktop: 'Whole desk' };++ const since = (d) => {++ const t = Number(d.since);++ if (!Number.isFinite(t) || t <= 0) return '';++ const iso = new Date(t * 1000).toISOString();++ return ` <span class="muted">since <time datetime="${esc(iso)}" data-local-time>${esc(iso.slice(11, 16))} UTC</time></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-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('');++ 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>`;++ const body = `${topbar(`<a class="crumb" href="/machines">Machines</a><span class="crumb">${esc(name)}</span>`)}++<main class="page">++<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>` : ''}++${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>`;++ return page(`${name}: desks`, body, { description: 'The desks your agents work on, on your machine.', canonical: '/machines' });++}++function renderMachineScreenPage({ machine, until = null, error = null, idle = false, shares = [], madeLink = null, madeShare = null, hasDesk = false, hasScreen = true, desk = 'main' }) {const name = machine.name || machine.id;const stateUrl = `/machines/${encodeURIComponent(machine.id)}/screen.json`;@@@ -919,4 +955,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, renderSharePage, renderShareGone, panelScripts, isSim, TRY_DESCRIPTION, renderEmbedRefused, renderDrivePage, renderWidget, renderRobotsPage, renderPairingPage, renderLinkPage, renderClaimPage, renderClaimApproved, camerasOf, esc };++module.exports = { page, renderPanel, renderPhonePage, renderMachinesPage, renderMachineDesksPage, renderMachineScreenPage, renderSharePage, renderShareGone, panelScripts, isSim, TRY_DESCRIPTION, renderEmbedRefused, renderDrivePage, renderWidget, renderRobotsPage, renderPairingPage, renderLinkPage, renderClaimPage, renderClaimApproved, camerasOf, esc };@@@ -39,7 +39,7 @@ 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, renderSharePage, renderShareGone, renderLinkPage, renderClaimPage, renderClaimApproved } = require('./render');++const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, renderPhonePage, renderMachinesPage, renderMachineDesksPage, 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');@@@ -579,6 +579,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limicomputer_level: typeof sensors.computer_level === 'string' ? sensors.computer_level : null,computer_waiting: Number.isInteger(sensors.computer_waiting) ? sensors.computer_waiting : null,computer_screen_stream: sensors.computer_screen_stream === 'live' ? 'live' : 'off',++ computer_desks: Number.isInteger(sensors.computer_desks) ? sensors.computer_desks : null,};}));// ── The live screen (plan T14): the owner watches their machine's screen on their own private stream ──@@@ -649,6 +650,40 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limiconst mine = await myMachine(req, me);res.json(await machineScreen.input(mine, me.subject, req.body || {}));}));++ // The desks running on the machine (Desks plan): asked of the machine when the owner opens the page, never on a++ // schedule; each can be watched, taken over or stopped.++ r.get('/machines/:id/desks', page(async (req, res) => {++ const me = requireUser(req);++ const mine = await myMachine(req, me);++ res.setHeader('Vary', 'Cookie');++ res.setHeader('Cache-Control', 'private, no-store');++ let desks = [];++ let maxDesks = null;++ let error = null;++ if (!hub || !hub.isOnline(mine.id)) error = 'The machine is offline: its desks stopped with it, or show here when it is back.';++ else if (!(hub.computerOf(mine.id) || {}).desk) error = 'This machine runs no desks: it needs OpenVibe Node with the computer plugin (Linux with Xvfb, or Chromium for browser desks).';++ else {++ const r = await hub.machineCommand(mine.id, asUser(me), me.subject, { kind: 'desk', value: { action: 'list' }, ms: 8000 });++ if (r && r.result === 'ack') {++ const d = r.data || {};++ desks = (Array.isArray(d.desks) ? d.desks : []).filter((x) => x && machineScreen.DESK.test(String(x.desk || '')) && x.desk !== 'main').slice(0, 50);++ maxDesks = Number.isInteger(d.max_desks) ? d.max_desks : null;++ } 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 }));++ }));++ r.post('/machines/:id/desks/stop', ...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>');++ if (!hub || !hub.isOnline(mine.id)) throw new BotError(409, 'bot.device_offline', 'the machine is offline');++ const r = await hub.machineCommand(mine.id, asUser(me), me.subject, { kind: 'desk', value: { action: 'stop', desk }, ms: 15000 });++ if (!r || r.result !== 'ack') throw new BotError(409, 'bot.device_refused', `the machine did not stop it: ${(r && (r.reason || r.result)) || 'no answer'}`.slice(0, 300));++ res.redirect(303, `/machines/${encodeURIComponent(mine.id)}/desks?stopped=${encodeURIComponent(desk)}`);++ }));r.get('/machines/:id/screen.json', page(async (req, res) => {const me = requireUser(req);const mine = await myMachine(req, me);@@@ -34,6 +34,7 @@ const { boot, check, done } = require('./helpers/app');else if (m.kind === 'probe') c.send(frame('ack', { id: m.id, data: { status: 200, body: '{"up":true}', ms: 3 } }));else if (m.kind === 'input') c.send(frame('ack', { id: m.id, data: { done: true, action: m.value.action } }));else if (m.kind === 'browser') c.send(frame('ack', { id: m.id, data: { url: m.value.url || 'https://example.org/', title: 'Example', text: 'Example page', truncated: false, links: [] } }));++ else if (m.kind === 'desk' && m.value.action === 'list') c.send(frame('ack', { id: m.id, data: { desks: [{ desk: 'desk-task-a', kind: 'browser', running: true, width: 1280, height: 800, since: 1793102400 }, { desk: 'private', kind: 'desktop', running: true, display: ':91', width: 1280, height: 800, since: 1793102000 }], max_desks: 4, available: true } }));else if (m.kind === 'desk') c.send(frame('ack', { id: m.id, data: { running: m.value.action !== 'stop', display: ':91', width: 1280, height: 800 } }));else if (m.kind === 'screen' && m.value.desk && m.value.desk !== 'main') c.send(frame('ack', { id: m.id, data: { format: 'png', width: 1280, height: 800, content_b64: 'iVBORw0KGgo=' } }));else c.send(frame('nack', { id: m.id, fault_code: 'not_allowed', message: 'this machine allows observing only' }));@@@ -339,6 +340,34 @@ const { boot, check, done } = require('./helpers/app');mac.c.close();});++ await check('a machine\'s desks page: its running desks to watch, take over or stop, for its owner only; stop is same-site only', async () => {++ const cookie = (user) => ({ Cookie: `ov_token=${t.network.signUser(user)}` });++ const host = await machine(alex, { ...COMPUTER, desk: { level: 'trusted', window_manager: 'openbox', input: true, screen: true, named: true, max_desks: 4, kinds: ['desktop', 'browser'] } });++ host.c.send(frame('telemetry', { sensors: { computer_level: 'trusted', computer_waiting: 0, computer_running: 0, computer_desks: 2 } }));++ await poll(async () => (await (await fetch(`${t.base}/machines`, { headers: cookie(alex) })).text()).includes('2 desks running'));++ const list = await (await fetch(`${t.base}/machines`, { headers: cookie(alex) })).text();++ assert.ok(list.includes(`href="/machines/${host.deviceId}/desks"`), 'the card links its desks');++ const page = await fetch(`${t.base}/machines/${host.deviceId}/desks`, { headers: cookie(alex) });++ assert.strictEqual(page.status, 200);++ const html = await page.text();++ assert.deepStrictEqual(seen.at(-1).value, { action: 'list' }, 'asked of the machine when the page opens');++ 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('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 } });++ assert.strictEqual((await stop('desk=desk-task-a', { Origin: 'https://evil.test' })).status, 403);++ assert.strictEqual((await stop('desk=main')).status, 422, 'never the person\'s own screen');++ assert.strictEqual((await stop('desk=desk-../x')).status, 422);++ const done = await stop('desk=desk-task-a');++ assert.strictEqual(done.status, 303);++ assert.match(done.headers.get('location'), /\/desks\?stopped=desk-task-a$/);++ assert.deepStrictEqual(seen.at(-1).value, { action: 'stop', desk: 'desk-task-a' });++ assert.ok((await (await fetch(`${t.base}${done.headers.get('location')}`, { headers: cookie(alex) })).text()).includes('Stopped <code>desk-task-a</code>'));++ host.c.close();++ });++await check('Watch sends only probes, only where the owner let their watches use the computer', async () => {const watch = t.network.signService({ sub: 'svc:watch', cap: ['bot.computer.control'] });const asWatch = { token: watch, headers: { 'X-OV-Subject': alex.subject } };
@@ -749,9 +749,13 @@ function renderMachinesPage({ machines = [], pairing = null }) {
@@ -749,9 +749,13 @@ function renderMachinesPage({ machines = [], pairing = null }) {
if (!c) return '';
if (!c) return '';
const can = [c.terminal && 'terminal', c.files && 'files', c.screen && 'screen', c.input && 'mouse and keyboard'].filter(Boolean).join(', ');
const can = [c.terminal && 'terminal', c.files && 'files', c.screen && 'screen', c.input && 'mouse and keyboard'].filter(Boolean).join(', ');
const desks = c.desks ? `<span class="muted">${esc(deskWords(c.desks))}</span>` : '';
const desks = c.desks ? `<span class="muted">${esc(deskWords(c.desks))}</span>` : '';
+ // The desks running now (the Node's telemetry), and the page that lists them, to watch or stop one.
+ const running = Number.isInteger(m.computer_desks) && m.computer_desks > 0
+ ? ` <span class="pill">${m.computer_desks === 1 ? '1 desk running' : `${m.computer_desks} desks running`}</span>` : '';
+ const deskLink = c.desks && m.online ? `<a class="button quiet" href="/machines/${esc(m.id)}/desks">See its desks</a>` : '';
const waiting = Number.isInteger(m.computer_waiting) && m.computer_waiting > 0
const waiting = Number.isInteger(m.computer_waiting) && m.computer_waiting > 0
? ` <span class="pill warn">${m.computer_waiting} waiting for you on the machine</span>` : '';
? ` <span class="pill warn">${m.computer_waiting} waiting for you on the machine</span>` : '';
- return `<div class="machine-computer"><span class="pill">Computer: ${esc(c.level ? LEVELS[c.level] : 'on')}</span><span class="muted">${esc(can)}</span>${desks}${waiting}`
+ return `<div class="machine-computer"><span class="pill">Computer: ${esc(c.level ? LEVELS[c.level] : 'on')}</span><span class="muted">${esc(can)}</span>${desks}${running}${waiting}${deskLink}`
+ `<form method="post" action="/machines/${esc(m.id)}/agent-computer"><input type="hidden" name="on" value="${m.agent_computer ? '0' : '1'}">`
+ `<form method="post" action="/machines/${esc(m.id)}/agent-computer"><input type="hidden" name="on" value="${m.agent_computer ? '0' : '1'}">`
+ `<button type="submit" class="button quiet" aria-pressed="${m.agent_computer ? 'true' : 'false'}">${m.agent_computer ? 'Stop my agent using this computer' : 'Let my agent use this computer'}</button></form>`
+ `<button type="submit" class="button quiet" aria-pressed="${m.agent_computer ? 'true' : 'false'}">${m.agent_computer ? 'Stop my agent using this computer' : 'Let my agent use this computer'}</button></form>`
+ (c.screenStream && m.online ? `<form method="post" action="/machines/${esc(m.id)}/screen"><button type="submit" class="button quiet">${m.computer_screen_stream === 'live' ? 'Watch my screen' : 'Show my screen'}</button></form>` : '')
+ (c.screenStream && m.online ? `<form method="post" action="/machines/${esc(m.id)}/screen"><button type="submit" class="button quiet">${m.computer_screen_stream === 'live' ? 'Watch my screen' : 'Show my screen'}</button></form>` : '')
@@ -836,6 +840,38 @@ ${control ? `<div class="share-controls">
@@ -836,6 +840,38 @@ ${control ? `<div class="share-controls">
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.' });
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.' });
}
}
+/**
+ * /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 }) {
+ const name = machine.name || machine.id;
+ const id = esc(machine.id);
+ const KIND = { browser: 'Browser desk', desktop: 'Whole desk' };
+ const since = (d) => {
+ const t = Number(d.since);
+ if (!Number.isFinite(t) || t <= 0) return '';
+ const iso = new Date(t * 1000).toISOString();
+ return ` <span class="muted">since <time datetime="${esc(iso)}" data-local-time>${esc(iso.slice(11, 16))} UTC</time></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-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('');
+ 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>`;
+ const body = `${topbar(`<a class="crumb" href="/machines">Machines</a><span class="crumb">${esc(name)}</span>`)}
+<main class="page">
+<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>` : ''}
+${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>`;
+ return page(`${name}: desks`, body, { description: 'The desks your agents work on, on your machine.', canonical: '/machines' });
+}
+
function renderMachineScreenPage({ machine, until = null, error = null, idle = false, shares = [], madeLink = null, madeShare = null, hasDesk = false, hasScreen = true, desk = 'main' }) {
function renderMachineScreenPage({ machine, until = null, error = null, idle = false, shares = [], madeLink = null, madeShare = null, hasDesk = false, hasScreen = true, desk = 'main' }) {
const name = machine.name || machine.id;
const name = machine.name || machine.id;
const stateUrl = `/machines/${encodeURIComponent(machine.id)}/screen.json`;
const stateUrl = `/machines/${encodeURIComponent(machine.id)}/screen.json`;
@@ -919,4 +955,4 @@ function renderPhonePage() {
@@ -919,4 +955,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' });
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, renderSharePage, renderShareGone, panelScripts, isSim, TRY_DESCRIPTION, renderEmbedRefused, renderDrivePage, renderWidget, renderRobotsPage, renderPairingPage, renderLinkPage, renderClaimPage, renderClaimApproved, camerasOf, esc };
+module.exports = { page, renderPanel, renderPhonePage, renderMachinesPage, renderMachineDesksPage, renderMachineScreenPage, renderSharePage, renderShareGone, panelScripts, isSim, TRY_DESCRIPTION, renderEmbedRefused, renderDrivePage, renderWidget, renderRobotsPage, renderPairingPage, renderLinkPage, renderClaimPage, renderClaimApproved, camerasOf, esc };
@@ -39,7 +39,7 @@ const { getProfile, listProfiles, loadProfiles } = require('../profiles');
@@ -39,7 +39,7 @@ const { getProfile, listProfiles, loadProfiles } = require('../profiles');
const { frameAncestors } = require('../config');
const { frameAncestors } = require('../config');
const { BotError } = require('../util');
const { BotError } = require('../util');
const kits = require('../kits');
const kits = require('../kits');
-const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, renderPhonePage, renderMachinesPage, renderMachineScreenPage, renderSharePage, renderShareGone, renderLinkPage, renderClaimPage, renderClaimApproved } = require('./render');
+const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, renderPhonePage, renderMachinesPage, renderMachineDesksPage, renderMachineScreenPage, renderSharePage, renderShareGone, renderLinkPage, renderClaimPage, renderClaimApproved } = require('./render');
const { createMachineScreen } = require('../machine-screen');
const { createMachineScreen } = require('../machine-screen');
const { createScreenShares } = require('../screen-shares');
const { createScreenShares } = require('../screen-shares');
const { renderDocsIndex, renderDriversPage, renderProfilesPage, renderAutomationPage, renderComputersPage } = require('./docs');
const { renderDocsIndex, renderDriversPage, renderProfilesPage, renderAutomationPage, renderComputersPage } = require('./docs');
@@ -579,6 +579,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -579,6 +579,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
computer_level: typeof sensors.computer_level === 'string' ? sensors.computer_level : null,
computer_level: typeof sensors.computer_level === 'string' ? sensors.computer_level : null,
computer_waiting: Number.isInteger(sensors.computer_waiting) ? sensors.computer_waiting : null,
computer_waiting: Number.isInteger(sensors.computer_waiting) ? sensors.computer_waiting : null,
computer_screen_stream: sensors.computer_screen_stream === 'live' ? 'live' : 'off',
computer_screen_stream: sensors.computer_screen_stream === 'live' ? 'live' : 'off',
+ computer_desks: Number.isInteger(sensors.computer_desks) ? sensors.computer_desks : null,
};
};
}));
}));
// ── The live screen (plan T14): the owner watches their machine's screen on their own private stream ──
// ── The live screen (plan T14): the owner watches their machine's screen on their own private stream ──
@@ -649,6 +650,40 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -649,6 +650,40 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
const mine = await myMachine(req, me);
const mine = await myMachine(req, me);
res.json(await machineScreen.input(mine, me.subject, req.body || {}));
res.json(await machineScreen.input(mine, me.subject, req.body || {}));
}));
}));
+ // The desks running on the machine (Desks plan): asked of the machine when the owner opens the page, never on a
+ // schedule; each can be watched, taken over or stopped.
+ r.get('/machines/:id/desks', page(async (req, res) => {
+ const me = requireUser(req);
+ const mine = await myMachine(req, me);
+ res.setHeader('Vary', 'Cookie');
+ res.setHeader('Cache-Control', 'private, no-store');
+ let desks = [];
+ let maxDesks = null;
+ let error = null;
+ if (!hub || !hub.isOnline(mine.id)) error = 'The machine is offline: its desks stopped with it, or show here when it is back.';
+ else if (!(hub.computerOf(mine.id) || {}).desk) error = 'This machine runs no desks: it needs OpenVibe Node with the computer plugin (Linux with Xvfb, or Chromium for browser desks).';
+ else {
+ const r = await hub.machineCommand(mine.id, asUser(me), me.subject, { kind: 'desk', value: { action: 'list' }, ms: 8000 });
+ if (r && r.result === 'ack') {
+ const d = r.data || {};
+ desks = (Array.isArray(d.desks) ? d.desks : []).filter((x) => x && machineScreen.DESK.test(String(x.desk || '')) && x.desk !== 'main').slice(0, 50);
+ maxDesks = Number.isInteger(d.max_desks) ? d.max_desks : null;
+ } 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 }));
+ }));
+ r.post('/machines/:id/desks/stop', ...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>');
+ if (!hub || !hub.isOnline(mine.id)) throw new BotError(409, 'bot.device_offline', 'the machine is offline');
+ const r = await hub.machineCommand(mine.id, asUser(me), me.subject, { kind: 'desk', value: { action: 'stop', desk }, ms: 15000 });
+ if (!r || r.result !== 'ack') throw new BotError(409, 'bot.device_refused', `the machine did not stop it: ${(r && (r.reason || r.result)) || 'no answer'}`.slice(0, 300));
+ res.redirect(303, `/machines/${encodeURIComponent(mine.id)}/desks?stopped=${encodeURIComponent(desk)}`);
+ }));
r.get('/machines/:id/screen.json', page(async (req, res) => {
r.get('/machines/:id/screen.json', page(async (req, res) => {
const me = requireUser(req);
const me = requireUser(req);
const mine = await myMachine(req, me);
const mine = await myMachine(req, me);
@@ -34,6 +34,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -34,6 +34,7 @@ const { boot, check, done } = require('./helpers/app');
else if (m.kind === 'probe') c.send(frame('ack', { id: m.id, data: { status: 200, body: '{"up":true}', ms: 3 } }));
else if (m.kind === 'probe') c.send(frame('ack', { id: m.id, data: { status: 200, body: '{"up":true}', ms: 3 } }));
else if (m.kind === 'input') c.send(frame('ack', { id: m.id, data: { done: true, action: m.value.action } }));
else if (m.kind === 'input') c.send(frame('ack', { id: m.id, data: { done: true, action: m.value.action } }));
else if (m.kind === 'browser') c.send(frame('ack', { id: m.id, data: { url: m.value.url || 'https://example.org/', title: 'Example', text: 'Example page', truncated: false, links: [] } }));
else if (m.kind === 'browser') c.send(frame('ack', { id: m.id, data: { url: m.value.url || 'https://example.org/', title: 'Example', text: 'Example page', truncated: false, links: [] } }));
+ else if (m.kind === 'desk' && m.value.action === 'list') c.send(frame('ack', { id: m.id, data: { desks: [{ desk: 'desk-task-a', kind: 'browser', running: true, width: 1280, height: 800, since: 1793102400 }, { desk: 'private', kind: 'desktop', running: true, display: ':91', width: 1280, height: 800, since: 1793102000 }], max_desks: 4, available: true } }));
else if (m.kind === 'desk') c.send(frame('ack', { id: m.id, data: { running: m.value.action !== 'stop', display: ':91', width: 1280, height: 800 } }));
else if (m.kind === 'desk') c.send(frame('ack', { id: m.id, data: { running: m.value.action !== 'stop', display: ':91', width: 1280, height: 800 } }));
else if (m.kind === 'screen' && m.value.desk && m.value.desk !== 'main') c.send(frame('ack', { id: m.id, data: { format: 'png', width: 1280, height: 800, content_b64: 'iVBORw0KGgo=' } }));
else if (m.kind === 'screen' && m.value.desk && m.value.desk !== 'main') c.send(frame('ack', { id: m.id, data: { format: 'png', width: 1280, height: 800, content_b64: 'iVBORw0KGgo=' } }));
else c.send(frame('nack', { id: m.id, fault_code: 'not_allowed', message: 'this machine allows observing only' }));
else c.send(frame('nack', { id: m.id, fault_code: 'not_allowed', message: 'this machine allows observing only' }));
@@ -339,6 +340,34 @@ const { boot, check, done } = require('./helpers/app');
@@ -339,6 +340,34 @@ const { boot, check, done } = require('./helpers/app');
mac.c.close();
mac.c.close();
});
});
+ await check('a machine\'s desks page: its running desks to watch, take over or stop, for its owner only; stop is same-site only', async () => {
+ const cookie = (user) => ({ Cookie: `ov_token=${t.network.signUser(user)}` });
+ const host = await machine(alex, { ...COMPUTER, desk: { level: 'trusted', window_manager: 'openbox', input: true, screen: true, named: true, max_desks: 4, kinds: ['desktop', 'browser'] } });
+ host.c.send(frame('telemetry', { sensors: { computer_level: 'trusted', computer_waiting: 0, computer_running: 0, computer_desks: 2 } }));
+ await poll(async () => (await (await fetch(`${t.base}/machines`, { headers: cookie(alex) })).text()).includes('2 desks running'));
+ const list = await (await fetch(`${t.base}/machines`, { headers: cookie(alex) })).text();
+ assert.ok(list.includes(`href="/machines/${host.deviceId}/desks"`), 'the card links its desks');
+ const page = await fetch(`${t.base}/machines/${host.deviceId}/desks`, { headers: cookie(alex) });
+ assert.strictEqual(page.status, 200);
+ const html = await page.text();
+ assert.deepStrictEqual(seen.at(-1).value, { action: 'list' }, 'asked of the machine when the page opens');
+ 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('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 } });
+ assert.strictEqual((await stop('desk=desk-task-a', { Origin: 'https://evil.test' })).status, 403);
+ assert.strictEqual((await stop('desk=main')).status, 422, 'never the person\'s own screen');
+ assert.strictEqual((await stop('desk=desk-../x')).status, 422);
+ const done = await stop('desk=desk-task-a');
+ assert.strictEqual(done.status, 303);
+ assert.match(done.headers.get('location'), /\/desks\?stopped=desk-task-a$/);
+ assert.deepStrictEqual(seen.at(-1).value, { action: 'stop', desk: 'desk-task-a' });
+ assert.ok((await (await fetch(`${t.base}${done.headers.get('location')}`, { headers: cookie(alex) })).text()).includes('Stopped <code>desk-task-a</code>'));
+ host.c.close();
+ });
+
await check('Watch sends only probes, only where the owner let their watches use the computer', async () => {
await check('Watch sends only probes, only where the owner let their watches use the computer', async () => {
const watch = t.network.signService({ sub: 'svc:watch', cap: ['bot.computer.control'] });
const watch = t.network.signService({ sub: 'svc:watch', cap: ['bot.computer.control'] });
const asWatch = { token: watch, headers: { 'X-OV-Subject': alex.subject } };
const asWatch = { token: watch, headers: { 'X-OV-Subject': alex.subject } };