Files
rmancinasandClaude Opus 5 999717f77b feat: self-hosted remote support over VNC
Browser-based remote control (noVNC) with invite links, per-user access
control, garagedoor SSO and a persisted client list.

The hub proxies RFB rather than pointing the browser at a VNC server. That
is what lets it authenticate upstream with a stored password the browser
never sees, and enforce view-only by dropping input messages on the
client->server stream instead of hiding buttons.

Machines are reachable two ways: direct TCP for LAN hosts, or an outbound
agent tunnel for anything behind NAT. Node 22's global WebSocket keeps the
agent dependency-free, and node:sqlite keeps the image free of native
builds.

Ships with an end-to-end suite that boots the real server against a fake
VNC server and a fake auth service (72 assertions), plus Gitea Actions
CI/CD to Portainer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 23:37:35 -07:00

274 lines
8.1 KiB
JavaScript

/* ============================================================================
particles.js — ambient "network of machines" field.
Self-contained, no dependencies. Mounts a <canvas> into a host element and
draws slow-drifting nodes with proximity links.
import { mountParticles } from '/particles.js';
const field = mountParticles(document.getElementById('bg-field'));
field.setMode('static'); // freeze: draws one frame, then zero cost
field.destroy();
Budget rules baked in, because this runs on whatever desktop is on the desk:
- node count is derived from viewport area and hard-capped
- device pixel ratio is capped at 2
- the loop is throttled to ~30fps and uses no shadows or gradients
- rAF is cancelled outright when the tab is hidden or the mode is static
- prefers-reduced-motion renders a single static frame and never loops
========================================================================= */
'use strict';
const DEFAULTS = {
/* one node per this many CSS pixels of area */
areaPerNode: 26000,
minNodes: 14,
maxNodes: 78,
/* proximity links */
linkDistance: 138,
linkAlpha: 0.20,
/* nodes */
nodeAlpha: 0.62,
nodeSize: 2,
hubEvery: 7, /* every Nth node is drawn as a larger "hub" */
/* drift, CSS px per second */
speed: 7,
fps: 30,
colors: ['#3ddc97', '#3ddc97', '#3ddc97', '#9aa2ff', '#e9a05c'],
linkColor: '61, 220, 151',
mode: 'animate',
};
const NOOP_HANDLE = {
setMode() {},
destroy() {},
canvas: null,
};
export function mountParticles(host, options = {}) {
if (!host || typeof document === 'undefined') return NOOP_HANDLE;
const cfg = { ...DEFAULTS, ...options };
const canvas = document.createElement('canvas');
canvas.setAttribute('aria-hidden', 'true');
const ctx = canvas.getContext('2d', { alpha: true, desynchronized: true });
if (!ctx) return NOOP_HANDLE;
host.append(canvas);
const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
let nodes = [];
let width = 0;
let height = 0;
let raf = 0;
let lastFrame = 0;
let mode = cfg.mode;
let destroyed = false;
/* ------------------------------------------------------------ geometry */
function targetCount() {
const raw = Math.round((width * height) / cfg.areaPerNode);
return Math.max(cfg.minNodes, Math.min(cfg.maxNodes, raw));
}
function makeNode(index) {
const angle = Math.random() * Math.PI * 2;
return {
x: Math.random() * width,
y: Math.random() * height,
vx: Math.cos(angle) * cfg.speed * (0.35 + Math.random() * 0.85),
vy: Math.sin(angle) * cfg.speed * (0.35 + Math.random() * 0.85),
color: cfg.colors[index % cfg.colors.length],
hub: index % cfg.hubEvery === 0,
/* per-node brightness keeps the field from looking like a lattice */
alpha: cfg.nodeAlpha * (0.45 + Math.random() * 0.55),
};
}
function reconcileNodes() {
const want = targetCount();
while (nodes.length > want) nodes.pop();
while (nodes.length < want) nodes.push(makeNode(nodes.length));
for (const n of nodes) {
if (n.x > width) n.x = Math.random() * width;
if (n.y > height) n.y = Math.random() * height;
}
}
function resize() {
if (destroyed) return;
const w = host.clientWidth || window.innerWidth;
const h = host.clientHeight || window.innerHeight;
if (!w || !h) return;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
width = w;
height = h;
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
reconcileNodes();
if (!running()) draw();
}
/* --------------------------------------------------------------- paint */
function draw() {
ctx.clearRect(0, 0, width, height);
/* links first, so nodes sit on top of them */
const max = cfg.linkDistance;
const maxSq = max * max;
ctx.lineWidth = 1;
for (let i = 0; i < nodes.length; i++) {
const a = nodes[i];
for (let j = i + 1; j < nodes.length; j++) {
const b = nodes[j];
const dx = a.x - b.x;
const dy = a.y - b.y;
const distSq = dx * dx + dy * dy;
if (distSq > maxSq) continue;
const strength = 1 - Math.sqrt(distSq) / max;
ctx.strokeStyle = `rgba(${cfg.linkColor}, ${(strength * strength * cfg.linkAlpha).toFixed(3)})`;
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
}
}
/* nodes: small squares read as devices, not as bokeh */
ctx.globalAlpha = 1;
for (const n of nodes) {
const s = n.hub ? cfg.nodeSize + 1.5 : cfg.nodeSize;
ctx.fillStyle = withAlpha(n.color, n.alpha);
ctx.fillRect(n.x - s / 2, n.y - s / 2, s, s);
if (n.hub) {
ctx.strokeStyle = withAlpha(n.color, n.alpha * 0.30);
ctx.strokeRect(n.x - s * 1.6, n.y - s * 1.6, s * 3.2, s * 3.2);
}
}
}
function step(now) {
raf = window.requestAnimationFrame(step);
const interval = 1000 / cfg.fps;
const elapsed = now - lastFrame;
if (elapsed < interval) return;
/* keep the phase, but never integrate a huge dt after a stall */
lastFrame = now - (elapsed % interval);
const dt = Math.min(elapsed, 100) / 1000;
for (const n of nodes) {
n.x += n.vx * dt;
n.y += n.vy * dt;
if (n.x < 0) { n.x = 0; n.vx = -n.vx; }
else if (n.x > width) { n.x = width; n.vx = -n.vx; }
if (n.y < 0) { n.y = 0; n.vy = -n.vy; }
else if (n.y > height) { n.y = height; n.vy = -n.vy; }
}
draw();
}
/* ------------------------------------------------------------ lifecycle */
function running() { return raf !== 0; }
function shouldAnimate() {
return !destroyed
&& mode === 'animate'
&& !document.hidden
&& !motionQuery.matches;
}
function stop() {
if (raf) window.cancelAnimationFrame(raf);
raf = 0;
}
function sync() {
if (shouldAnimate()) {
if (!running()) {
lastFrame = performance.now();
raf = window.requestAnimationFrame(step);
}
return;
}
stop();
if (!destroyed && mode !== 'off') draw();
if (mode === 'off') ctx.clearRect(0, 0, width, height);
}
/* ------------------------------------------------------------- plumbing */
let resizeTimer = 0;
const onResize = () => {
window.clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(resize, 140);
};
const onVisibility = () => sync();
window.addEventListener('resize', onResize, { passive: true });
document.addEventListener('visibilitychange', onVisibility);
addMediaListener(motionQuery, sync);
let observer = null;
if (typeof ResizeObserver !== 'undefined') {
observer = new ResizeObserver(onResize);
observer.observe(host);
}
resize();
sync();
return {
canvas,
/** 'animate' | 'static' (one frozen frame) | 'off' (blank) */
setMode(next) {
if (next === mode) return;
mode = next;
sync();
},
destroy() {
destroyed = true;
stop();
window.clearTimeout(resizeTimer);
window.removeEventListener('resize', onResize);
document.removeEventListener('visibilitychange', onVisibility);
removeMediaListener(motionQuery, sync);
observer?.disconnect();
canvas.remove();
},
};
}
/* ------------------------------------------------------------------ utils */
function withAlpha(hex, alpha) {
const h = hex.replace('#', '');
const n = parseInt(h.length === 3 ? h.replace(/./g, (c) => c + c) : h, 16);
const r = (n >> 16) & 255;
const g = (n >> 8) & 255;
const b = n & 255;
return `rgba(${r}, ${g}, ${b}, ${alpha.toFixed(3)})`;
}
/* Safari < 14 only has the deprecated listener API. */
function addMediaListener(query, fn) {
if (query.addEventListener) query.addEventListener('change', fn);
else if (query.addListener) query.addListener(fn);
}
function removeMediaListener(query, fn) {
if (query.removeEventListener) query.removeEventListener('change', fn);
else if (query.removeListener) query.removeListener(fn);
}
export default mountParticles;