import RFB from '/novnc/core/rfb.js'; // The viewer takes either a client id (mint a ticket via the API) or a ticket // that has already been minted for it — the support-link page does the latter, // since those visitors have no login at all. const params = new URLSearchParams(location.search); const token = localStorage.getItem('rcs.token'); const el = (id) => document.getElementById(id); const overlay = el('status-overlay'); let rfb = null; let scaled = true; function status(title, text, actions = []) { el('status-title').textContent = title; el('status-text').textContent = text || ''; const box = el('status-actions'); box.innerHTML = ''; for (const a of actions) { const b = document.createElement('button'); b.textContent = a.label; b.className = a.primary ? 'primary' : ''; b.addEventListener('click', a.onClick); box.append(b); } overlay.classList.remove('hidden'); } function hideStatus() { overlay.classList.add('hidden'); } function setControls(enabled, { control = true } = {}) { for (const id of ['btn-fit', 'btn-fullscreen', 'btn-disconnect']) el(id).disabled = !enabled; for (const id of ['btn-cad', 'btn-clipboard']) el(id).disabled = !enabled || !control; } async function mintTicket() { const clientId = params.get('client'); const res = await fetch('/api/sessions', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ clientId, viewOnly: params.get('viewOnly') === '1' }), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || `could not start a session (${res.status})`); return data; } function connect(session) { el('client-name').textContent = session.clientName || 'Remote machine'; document.title = `${session.clientName || 'Remote'} — session`; const viewOnly = session.role === 'viewer'; const roleTag = el('role-tag'); roleTag.textContent = viewOnly ? 'view only' : 'full control'; roleTag.className = viewOnly ? 'tag' : 'tag role'; roleTag.hidden = false; if (session.requireConsent) { status('Waiting for permission', 'Someone at that machine has to allow the connection.'); } else { status('Connecting…', 'Negotiating with the remote desktop.'); } const url = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/vnc?ticket=${encodeURIComponent(session.ticket)}`; rfb = new RFB(el('screen'), url); rfb.viewOnly = viewOnly; // the hub enforces this too; this just avoids noise rfb.scaleViewport = true; rfb.resizeSession = false; rfb.background = '#05070a'; rfb.clipViewport = false; rfb.focusOnClick = true; rfb.addEventListener('connect', () => { hideStatus(); el('conn-dot').className = 'dot online'; el('conn-status').textContent = 'connected'; setControls(true, { control: !viewOnly }); }); rfb.addEventListener('disconnect', (e) => { el('conn-dot').className = 'dot offline'; el('conn-status').textContent = 'disconnected'; setControls(false); const reason = e.detail?.reason || (e.detail?.clean ? 'The session ended.' : 'The connection dropped.'); status(e.detail?.clean ? 'Session ended' : 'Disconnected', reason, [ { label: 'Reconnect', primary: true, onClick: () => location.reload() }, { label: 'Close', onClick: () => window.close() }, ]); }); rfb.addEventListener('credentialsrequired', () => { // The hub authenticates upstream, so the browser should never be asked. status('Unexpected password prompt', 'The hub could not complete VNC authentication for this machine.'); rfb.disconnect(); }); rfb.addEventListener('securityfailure', (e) => { status('Rejected', e.detail?.reason || 'The remote machine refused the connection.'); }); } /* ------------------------------------------------------------- toolbar */ el('btn-cad').addEventListener('click', () => rfb?.sendCtrlAltDel()); el('btn-clipboard').addEventListener('click', async () => { try { const text = await navigator.clipboard.readText(); if (text) rfb?.clipboardPasteFrom(text); } catch { const text = prompt('Text to send to the remote machine:'); if (text) rfb?.clipboardPasteFrom(text); } }); el('btn-fit').addEventListener('click', (e) => { scaled = !scaled; if (rfb) rfb.scaleViewport = scaled; e.currentTarget.textContent = scaled ? 'Fit' : '1:1'; }); el('btn-fullscreen').addEventListener('click', () => { if (document.fullscreenElement) document.exitFullscreen(); else document.documentElement.requestFullscreen(); }); el('btn-disconnect').addEventListener('click', () => rfb?.disconnect()); /* ---------------------------------------------------------------- boot */ (async function start() { try { if (params.get('ticket')) { connect({ ticket: params.get('ticket'), clientName: params.get('name') || 'Remote machine', role: params.get('role') || 'viewer', requireConsent: params.get('consent') === '1', }); // The ticket is single-use; drop it from the address bar and history. history.replaceState(null, '', location.pathname); return; } if (!params.get('client')) throw new Error('nothing to connect to'); if (!token) throw new Error('you are not signed in'); connect(await mintTicket()); } catch (err) { status('Cannot start', err.message, [ { label: 'Back to machines', primary: true, onClick: () => { location.href = '/'; } }, ]); } })();