mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
feat(vcam): use a phone on the network as a virtual camera
SCENES > VCAM pairs a phone through a QR code and turns it into a handheld camera, after Unreal's Live Link VCAM and Blender's VirtuCamera: - Android Chrome tracks 6DoF through WebXR immersive-ar; iPhone Safari, which has no WebXR AR, tracks rotation through DeviceOrientation, and thumb sticks dolly, truck, crane and pan. - A motion scale (1:1 walk to 1:10000 orbit), smoothing, height and horizon locks, hold/clutch and recenter, none of which make the camera jump. Focal length and scale-aware depth of field with tap-to-focus. - The phone shows the app's render as a monitor (newest-frame JPEG pull). - REC records a take as a version-7 track shot in the selected scene. - The rig claims the camera through the navigation seam (Cockpit refuses it) and holds on any other navigation, scene run or globe input. Pairing never exposes the app: a loopback-only session request starts a separate HTTPS listener (VCAM_PORT, default 4174) that serves only the static phone page and a token-paired relay, and stops after the last session. Its certificate comes from a local CA built with node:crypto, which an iPhone can trust once; without it the phone falls back from WebSocket to HTTPS requests, which iOS allows past a certificate warning. scripts/qa-vcam.mjs drives it end to end: the real phone page over the HTTPS fallback, a scripted 6DoF phone, a recorded and replayed take, depth of field and camera handoff. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_015H1iLusTzWpVKitGic2AW8
This commit is contained in:
33 files changed
+5472
-6
No files matched your search
@@ -7,6 +7,7 @@ import { localMcpPlugin } from '../mcp/plugin.js';
|
||||
import { apiNotFoundPlugin } from './api-not-found.js';
|
||||
import { standaloneVoiceTools } from './voiceTools.js';
|
||||
import { unwatchedRuntimeDirsPlugin } from './unwatched-cache.js';
|
||||
import { vcamPlugin } from '../vcam/plugin.js';
|
||||
|
||||
const root = fileURLToPath(new URL('../../', import.meta.url));
|
||||
|
||||
@@ -20,6 +21,7 @@ export default defineConfig(({ command, mode }) => {
|
||||
plugins: [
|
||||
...localProviderPlugins({ realtime: { tools: standaloneVoiceTools() } }),
|
||||
localMcpPlugin(),
|
||||
vcamPlugin(),
|
||||
apiNotFoundPlugin(),
|
||||
unwatchedRuntimeDirsPlugin(),
|
||||
],
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#0a0a0f"/><circle cx="32" cy="32" r="17" fill="none" stroke="#00d4ff" stroke-width="3"/><circle cx="32" cy="32" r="7" fill="#00d4ff"/><path d="M32 6v8M32 50v8M6 32h8M50 32h8" stroke="#00d4ff" stroke-width="3" stroke-linecap="round"/></svg>
|
||||
|
After Width: | Height: | Size: 343 B |
@@ -0,0 +1,154 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no"
|
||||
/>
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="theme-color" content="#0a0a0f" />
|
||||
<title>GEV Virtual Camera</title>
|
||||
<link rel="icon" href="icon.svg" />
|
||||
<link rel="stylesheet" href="phone.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main id="app">
|
||||
<section id="pair" class="screen">
|
||||
<img class="logo" src="icon.svg" alt="" />
|
||||
<h1>VIRTUAL CAMERA</h1>
|
||||
<p class="lede">
|
||||
Enter the code shown in God's Eye View under SCENES › VCAM.
|
||||
</p>
|
||||
<form id="pair-form" autocomplete="off">
|
||||
<input
|
||||
id="code"
|
||||
inputmode="text"
|
||||
autocapitalize="characters"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
maxlength="6"
|
||||
placeholder="CODE"
|
||||
aria-label="Pairing code"
|
||||
/>
|
||||
<button id="join" type="submit" class="primary">PAIR</button>
|
||||
</form>
|
||||
<p id="pair-status" class="status" role="status"></p>
|
||||
<details class="help">
|
||||
<summary>Connection drops on iPhone?</summary>
|
||||
<p>
|
||||
Safari can load this page past a certificate warning but may still
|
||||
refuse its live connection, so the camera falls back to slower
|
||||
requests. To fix that once:
|
||||
</p>
|
||||
<ol>
|
||||
<li>
|
||||
<a id="ca-link" href="/ca.crt">Download the local certificate</a>
|
||||
and allow the profile.
|
||||
</li>
|
||||
<li>Settings › General › VPN & Device Management: install it.</li>
|
||||
<li>
|
||||
Settings › General › About › Certificate Trust Settings: turn on
|
||||
full trust for "God's Eye View Local VCam CA".
|
||||
</li>
|
||||
</ol>
|
||||
<p>The certificate only lets your phone trust this computer.</p>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section id="start" class="screen" hidden>
|
||||
<img class="logo" src="icon.svg" alt="" />
|
||||
<h1>PAIRED</h1>
|
||||
<p id="support" class="lede"></p>
|
||||
<button id="start-xr" class="primary" hidden>START AR CAMERA</button>
|
||||
<button id="start-gyro" class="primary">START GYRO CAMERA</button>
|
||||
<p class="hint">
|
||||
AR tracks position and rotation (6DoF). Gyro tracks rotation only;
|
||||
move with the on-screen sticks.
|
||||
</p>
|
||||
<p id="start-status" class="status" role="status"></p>
|
||||
</section>
|
||||
|
||||
<section id="camera" class="screen camera" hidden>
|
||||
<img id="monitor" alt="" draggable="false" />
|
||||
<div id="focus-ring" hidden></div>
|
||||
<div class="topbar">
|
||||
<span id="link-dot" class="dot" title="Connection"></span>
|
||||
<span id="mode-readout" class="chip">—</span>
|
||||
<span id="rec-readout" class="chip rec" hidden>● 00:00</span>
|
||||
<span class="spacer"></span>
|
||||
<span id="scale-readout" class="chip">1:1</span>
|
||||
<span id="focal-readout" class="chip">31mm</span>
|
||||
</div>
|
||||
<p id="camera-status" class="toast" role="status" hidden></p>
|
||||
|
||||
<div class="stick left" id="stick-left" aria-label="Move">
|
||||
<div class="knob"></div>
|
||||
<span>MOVE</span>
|
||||
</div>
|
||||
<div class="stick right" id="stick-right" aria-label="Crane and pan">
|
||||
<div class="knob"></div>
|
||||
<span>CRANE · PAN</span>
|
||||
</div>
|
||||
|
||||
<div class="bottombar">
|
||||
<button id="hold-btn" class="tool" aria-pressed="false">HOLD</button>
|
||||
<button id="recenter-btn" class="tool">RECENTER</button>
|
||||
<button id="record-btn" class="record" aria-pressed="false">
|
||||
<span class="record-dot"></span><span class="sr">RECORD</span>
|
||||
</button>
|
||||
<button id="scale-btn" class="tool" aria-expanded="false">
|
||||
SCALE
|
||||
</button>
|
||||
<button id="lens-btn" class="tool" aria-expanded="false">LENS</button>
|
||||
</div>
|
||||
|
||||
<div id="scale-drawer" class="drawer" hidden>
|
||||
<div class="presets" id="scale-presets">
|
||||
<button data-scale="1">1:1<small>WALK</small></button>
|
||||
<button data-scale="10">1:10<small>CRANE</small></button>
|
||||
<button data-scale="100">1:100<small>DRONE</small></button>
|
||||
<button data-scale="1000">1:1K<small>FLYOVER</small></button>
|
||||
<button data-scale="10000">1:10K<small>ORBIT</small></button>
|
||||
</div>
|
||||
<label
|
||||
>MOTION SCALE <output id="scale-value">1:1</output>
|
||||
<input id="scale-range" type="range" min="0" max="5" step="0.01" value="0"
|
||||
/></label>
|
||||
<label
|
||||
>SMOOTHING <output id="smooth-value">30%</output>
|
||||
<input id="smooth-range" type="range" min="0" max="100" step="1" value="30"
|
||||
/></label>
|
||||
<div class="toggles">
|
||||
<label><input id="height-lock" type="checkbox" /> HEIGHT LOCK</label>
|
||||
<label><input id="horizon-lock" type="checkbox" /> HORIZON LOCK</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="lens-drawer" class="drawer" hidden>
|
||||
<label
|
||||
>FOCAL LENGTH <output id="focal-value">31mm</output>
|
||||
<input id="focal-range" type="range" min="0" max="1" step="0.001" value="0.3"
|
||||
/></label>
|
||||
<div class="presets" id="focal-presets">
|
||||
<button data-focal="14">14</button>
|
||||
<button data-focal="24">24</button>
|
||||
<button data-focal="35">35</button>
|
||||
<button data-focal="50">50</button>
|
||||
<button data-focal="85">85</button>
|
||||
<button data-focal="200">200</button>
|
||||
</div>
|
||||
<div class="toggles">
|
||||
<label><input id="dof-toggle" type="checkbox" /> DEPTH OF FIELD</label>
|
||||
</div>
|
||||
<label
|
||||
>APERTURE <output id="fstop-value">f/2.8</output>
|
||||
<input id="fstop-range" type="range" min="0" max="1" step="0.001" value="0.35"
|
||||
/></label>
|
||||
<p class="hint">Tap the picture to focus there.</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<script type="module" src="phone.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Device orientation to the virtual camera's Y-up pose frame (+x right,
|
||||
* +y up, -z forward), shared by the phone page and its tests.
|
||||
*/
|
||||
export const quat = {
|
||||
multiply(a, b) {
|
||||
const [ax, ay, az, aw] = a;
|
||||
const [bx, by, bz, bw] = b;
|
||||
return [
|
||||
aw * bx + ax * bw + ay * bz - az * by,
|
||||
aw * by - ax * bz + ay * bw + az * bx,
|
||||
aw * bz + ax * by - ay * bx + az * bw,
|
||||
aw * bw - ax * bx - ay * by - az * bz,
|
||||
];
|
||||
},
|
||||
axisAngle(axis, angle) {
|
||||
const s = Math.sin(angle / 2);
|
||||
return [axis[0] * s, axis[1] * s, axis[2] * s, Math.cos(angle / 2)];
|
||||
},
|
||||
};
|
||||
const DEG = Math.PI / 180;
|
||||
// Device orientation is device→earth (x east, y north, z up). Rotating -90°
|
||||
// about x maps that earth frame to the Y-up frame (north becomes -z).
|
||||
const EARTH_TO_Y_UP = quat.axisAngle([1, 0, 0], -Math.PI / 2);
|
||||
|
||||
/** W3C DeviceOrientation (Z-X'-Y'') to a Y-up quaternion for the current screen orientation. */
|
||||
export function orientationQuaternion(alpha, beta, gamma, screenAngle) {
|
||||
const x = (beta || 0) * DEG;
|
||||
const y = (gamma || 0) * DEG;
|
||||
const z = (alpha || 0) * DEG;
|
||||
const cX = Math.cos(x / 2);
|
||||
const cY = Math.cos(y / 2);
|
||||
const cZ = Math.cos(z / 2);
|
||||
const sX = Math.sin(x / 2);
|
||||
const sY = Math.sin(y / 2);
|
||||
const sZ = Math.sin(z / 2);
|
||||
const device = [
|
||||
sX * cY * cZ - cX * sY * sZ,
|
||||
cX * sY * cZ + sX * cY * sZ,
|
||||
cX * cY * sZ + sX * sY * cZ,
|
||||
cX * cY * cZ - sX * sY * sZ,
|
||||
];
|
||||
const screen = quat.axisAngle([0, 0, 1], -(screenAngle || 0) * DEG);
|
||||
return quat.multiply(EARTH_TO_Y_UP, quat.multiply(device, screen));
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { orientationQuaternion } from './orientation.js';
|
||||
import { rotateByQuaternion } from '../../../src/vcam/rig.js';
|
||||
|
||||
const close = (actual, expected, message) =>
|
||||
actual.forEach((value, i) =>
|
||||
assert.ok(Math.abs(value - expected[i]) < 1e-9, `${message}: ${actual} vs ${expected}`),
|
||||
);
|
||||
const axes = (q) => ({
|
||||
forward: rotateByQuaternion(q, [0, 0, -1]),
|
||||
up: rotateByQuaternion(q, [0, 1, 0]),
|
||||
});
|
||||
|
||||
test('an upright portrait phone looks out of its back along the Y-up forward axis', () => {
|
||||
// beta 90: stood up from flat, screen toward the user, back toward north.
|
||||
const { forward, up } = axes(orientationQuaternion(0, 90, 0, 0));
|
||||
close(forward, [0, 0, -1], 'faces north (-z)');
|
||||
close(up, [0, 1, 0], 'top edge up');
|
||||
});
|
||||
|
||||
test('alpha turns the phone counterclockwise seen from above', () => {
|
||||
const { forward } = axes(orientationQuaternion(90, 90, 0, 0));
|
||||
close(forward, [-1, 0, 0], 'faces west');
|
||||
});
|
||||
|
||||
test('a landscape screen reports the picture\'s up, not the device\'s top edge', () => {
|
||||
// Rotated a quarter turn counterclockwise: the top edge points west and the
|
||||
// right edge up, the back still faces north, and the screen angle is 90.
|
||||
const { forward, up } = axes(orientationQuaternion(90, 0, -90, 90));
|
||||
close(forward, [0, 0, -1], 'still faces north');
|
||||
close(up, [0, 1, 0], 'picture up is world up');
|
||||
});
|
||||
|
||||
test('a phone lying flat, screen up, looks straight down', () => {
|
||||
const { forward, up } = axes(orientationQuaternion(0, 0, 0, 0));
|
||||
close(forward, [0, -1, 0], 'down');
|
||||
close(up, [0, 0, -1], 'top edge toward north');
|
||||
});
|
||||
@@ -0,0 +1,371 @@
|
||||
:root {
|
||||
--bg: #0a0a0f;
|
||||
--glass: rgba(12, 12, 20, 0.72);
|
||||
--border: rgba(255, 255, 255, 0.12);
|
||||
--accent: #00d4ff;
|
||||
--accent-dim: rgba(0, 212, 255, 0.18);
|
||||
--text: #e8eaed;
|
||||
--muted: rgba(232, 234, 237, 0.6);
|
||||
--rec: #ff3b4e;
|
||||
--mono: 'SF Mono', ui-monospace, 'JetBrains Mono', Menlo, monospace;
|
||||
--sans: -apple-system, BlinkMacSystemFont, 'Inter', system-ui, sans-serif;
|
||||
color-scheme: dark;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--sans);
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
button {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.screen {
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
padding: max(24px, env(safe-area-inset-top)) 24px
|
||||
max(24px, env(safe-area-inset-bottom));
|
||||
text-align: center;
|
||||
}
|
||||
.logo {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-family: var(--mono);
|
||||
font-size: 20px;
|
||||
letter-spacing: 0.18em;
|
||||
}
|
||||
.lede,
|
||||
.hint,
|
||||
.status {
|
||||
margin: 0;
|
||||
max-width: 34ch;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.status:empty {
|
||||
display: none;
|
||||
}
|
||||
.status.error {
|
||||
color: #ff8a95;
|
||||
}
|
||||
#pair-form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
#code {
|
||||
width: 9.5ch;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--glass);
|
||||
color: var(--text);
|
||||
font: 600 22px var(--mono);
|
||||
letter-spacing: 0.2em;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.primary {
|
||||
min-width: 220px;
|
||||
padding: 14px 20px;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 10px;
|
||||
background: var(--accent-dim);
|
||||
font: 600 15px var(--mono);
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
#pair-form .primary {
|
||||
min-width: 0;
|
||||
}
|
||||
.help {
|
||||
max-width: 36ch;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
}
|
||||
.help summary {
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
.help a {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Camera monitor: the desktop's render fills the screen. */
|
||||
.camera {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
background: #000;
|
||||
}
|
||||
#monitor {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background: #000;
|
||||
}
|
||||
.topbar,
|
||||
.bottombar {
|
||||
position: absolute;
|
||||
left: max(10px, env(safe-area-inset-left));
|
||||
right: max(10px, env(safe-area-inset-right));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.topbar {
|
||||
top: max(10px, env(safe-area-inset-top));
|
||||
}
|
||||
.bottombar {
|
||||
bottom: max(12px, env(safe-area-inset-bottom));
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.chip {
|
||||
padding: 5px 9px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--glass);
|
||||
font: 600 12px var(--mono);
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.chip.rec {
|
||||
border-color: var(--rec);
|
||||
color: var(--rec);
|
||||
}
|
||||
.dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #777;
|
||||
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.dot.live {
|
||||
background: #3ddc84;
|
||||
}
|
||||
.dot.slow {
|
||||
background: #ffb020;
|
||||
}
|
||||
.toast {
|
||||
position: absolute;
|
||||
top: calc(max(10px, env(safe-area-inset-top)) + 40px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin: 0;
|
||||
padding: 7px 12px;
|
||||
border-radius: 10px;
|
||||
background: var(--glass);
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tool {
|
||||
min-width: 64px;
|
||||
padding: 10px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--glass);
|
||||
font: 600 11px var(--mono);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.tool[aria-pressed='true'],
|
||||
.tool[aria-expanded='true'] {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
.record {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border: 3px solid #fff;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.record-dot {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
background: var(--rec);
|
||||
transition:
|
||||
border-radius 150ms,
|
||||
width 150ms,
|
||||
height 150ms;
|
||||
}
|
||||
.record[aria-pressed='true'] .record-dot {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.sr {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
}
|
||||
|
||||
/* Virtual sticks sit low on each side, under the thumbs. */
|
||||
.stick {
|
||||
position: absolute;
|
||||
bottom: calc(max(12px, env(safe-area-inset-bottom)) + 84px);
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 50%;
|
||||
background: rgba(12, 12, 20, 0.38);
|
||||
touch-action: none;
|
||||
}
|
||||
.stick.left {
|
||||
left: max(18px, env(safe-area-inset-left));
|
||||
}
|
||||
.stick.right {
|
||||
right: max(18px, env(safe-area-inset-right));
|
||||
}
|
||||
.stick span {
|
||||
position: absolute;
|
||||
bottom: -20px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
color: var(--muted);
|
||||
font: 600 10px var(--mono);
|
||||
letter-spacing: 0.1em;
|
||||
text-align: center;
|
||||
}
|
||||
.knob {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
margin: -26px 0 0 -26px;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 50%;
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
|
||||
.drawer {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: calc(max(12px, env(safe-area-inset-bottom)) + 84px);
|
||||
transform: translateX(-50%);
|
||||
width: min(360px, calc(100% - 24px));
|
||||
max-height: 60%;
|
||||
overflow: auto;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
background: rgba(12, 12, 20, 0.9);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
font: 600 11px var(--mono);
|
||||
letter-spacing: 0.08em;
|
||||
z-index: 2;
|
||||
}
|
||||
.drawer label {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
}
|
||||
.drawer input[type='range'] {
|
||||
width: 100%;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
.drawer output {
|
||||
color: var(--accent);
|
||||
}
|
||||
.presets {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.presets button {
|
||||
flex: 1;
|
||||
padding: 8px 2px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
font: 600 12px var(--mono);
|
||||
}
|
||||
.presets button.active {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
.presets small {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
color: var(--muted);
|
||||
font-size: 8px;
|
||||
}
|
||||
.toggles {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
}
|
||||
.toggles label {
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
}
|
||||
.drawer .hint {
|
||||
font: 12px var(--sans);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
#focus-ring {
|
||||
position: absolute;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin: -32px 0 0 -32px;
|
||||
border: 2px solid #ffd60a;
|
||||
border-radius: 6px;
|
||||
pointer-events: none;
|
||||
animation: focus 700ms ease-out forwards;
|
||||
}
|
||||
@keyframes focus {
|
||||
from {
|
||||
transform: scale(1.5);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@media (orientation: landscape) and (max-height: 500px) {
|
||||
.stick {
|
||||
width: 112px;
|
||||
height: 112px;
|
||||
bottom: calc(max(12px, env(safe-area-inset-bottom)) + 24px);
|
||||
}
|
||||
.bottombar {
|
||||
left: 160px;
|
||||
right: 160px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,770 @@
|
||||
/**
|
||||
* God's Eye View virtual camera — phone side.
|
||||
*
|
||||
* Streams this phone's pose to the desktop app and shows the app's render as
|
||||
* a monitor. Tracking uses WebXR `immersive-ar` (6DoF, Android Chrome with
|
||||
* ARCore) when available, otherwise device orientation (3DoF, iPhone Safari)
|
||||
* with on-screen sticks for movement. Poses are sent in one Y-up frame for
|
||||
* both: +x right, +y up, -z forward (WebXR's convention); yaw is arbitrary
|
||||
* and the desktop aligns it on recenter.
|
||||
*
|
||||
* Transport: a WebSocket when the connection allows it, otherwise HTTPS
|
||||
* requests (iOS refuses WebSockets under a tapped-through certificate). The
|
||||
* monitor always pulls the newest frame, so a slow network drops frames
|
||||
* instead of building a backlog.
|
||||
*/
|
||||
|
||||
import { orientationQuaternion } from './orientation.js';
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const KEY_STORAGE = 'gevVcam.phoneKey';
|
||||
const CODE_STORAGE = 'gevVcam.code';
|
||||
const SCALE_PRESETS = [1, 10, 100, 1000, 10000];
|
||||
const FOCAL_RANGE = [12, 400];
|
||||
const FSTOP_RANGE = [1.2, 22];
|
||||
const STICK_DEADZONE = 0.12;
|
||||
|
||||
const store = {
|
||||
get(key) {
|
||||
try {
|
||||
return sessionStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
set(key, value) {
|
||||
try {
|
||||
if (value === null) sessionStorage.removeItem(key);
|
||||
else sessionStorage.setItem(key, value);
|
||||
} catch {
|
||||
// Private browsing: pairing still works for this page load.
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const logMap = ([low, high], v) => low * (high / low) ** v;
|
||||
const logUnmap = ([low, high], value) =>
|
||||
Math.log(value / low) / Math.log(high / low);
|
||||
const round = (value, places = 5) => {
|
||||
const k = 10 ** places;
|
||||
return Math.round(value * k) / k;
|
||||
};
|
||||
const formatScale = (scale) =>
|
||||
scale >= 1000
|
||||
? `1:${+(scale / 1000).toFixed(scale >= 10000 ? 0 : 1)}K`
|
||||
: `1:${+scale.toFixed(scale < 10 ? 1 : 0)}`;
|
||||
const formatTime = (sec) => {
|
||||
const s = Math.max(0, Math.floor(sec));
|
||||
return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const screenAngle = () =>
|
||||
screen.orientation?.angle ?? (Number(window.orientation) || 0);
|
||||
|
||||
// ------------------------------------------------------------------ transport
|
||||
class Link {
|
||||
constructor(key, { onMessage, onStatus, onUnpaired }) {
|
||||
this.key = key;
|
||||
this.onMessage = onMessage;
|
||||
this.onStatus = onStatus;
|
||||
this.onUnpaired = onUnpaired;
|
||||
this.ws = null;
|
||||
this.events = null;
|
||||
this.mode = 'connecting';
|
||||
this.queue = [];
|
||||
this.pendingPose = null;
|
||||
this.inFlight = false;
|
||||
this.closed = false;
|
||||
this.webSocketFailed = false;
|
||||
this.retryTimer = null;
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this.closed) return;
|
||||
this.onStatus('connecting');
|
||||
if (!this.webSocketFailed && (await this.openWebSocket())) return;
|
||||
this.webSocketFailed = true;
|
||||
this.openHttp();
|
||||
}
|
||||
|
||||
openWebSocket() {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
let ws;
|
||||
try {
|
||||
ws = new WebSocket(
|
||||
`wss://${location.host}/ws?key=${encodeURIComponent(this.key)}`,
|
||||
);
|
||||
} catch {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
const done = (ok) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve(ok);
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
ws.close();
|
||||
done(false);
|
||||
}, 3500);
|
||||
ws.onopen = () => {
|
||||
this.ws = ws;
|
||||
this.mode = 'ws';
|
||||
this.onStatus('live');
|
||||
done(true);
|
||||
};
|
||||
ws.onerror = () => done(false);
|
||||
ws.onmessage = (event) => this.receive(event.data);
|
||||
ws.onclose = (event) => {
|
||||
done(false);
|
||||
if (this.ws !== ws) return;
|
||||
this.ws = null;
|
||||
this.mode = 'connecting';
|
||||
this.handleClose(event.code);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
openHttp() {
|
||||
if (this.closed) return;
|
||||
this.mode = 'http';
|
||||
const events = new EventSource(
|
||||
`/api/events?key=${encodeURIComponent(this.key)}`,
|
||||
);
|
||||
this.events = events;
|
||||
events.onopen = () => this.onStatus('slow');
|
||||
events.onmessage = (event) => this.receive(event.data);
|
||||
events.onerror = async () => {
|
||||
if (events.readyState !== EventSource.CLOSED || this.closed) return;
|
||||
this.events = null;
|
||||
// A closed stream is either a lost network or an ended session.
|
||||
try {
|
||||
const probe = await fetch('/api/send', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Vcam-Key': this.key,
|
||||
},
|
||||
body: '[]',
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (probe.status === 403) return this.handleClose(4003);
|
||||
} catch {
|
||||
// Offline: retry below.
|
||||
}
|
||||
this.handleClose(1006);
|
||||
};
|
||||
this.flush();
|
||||
}
|
||||
|
||||
handleClose(code) {
|
||||
if (this.closed) return;
|
||||
if (code === 4003 || code === 4000) {
|
||||
this.onUnpaired(code === 4000 ? 'ended' : 'unpaired');
|
||||
return;
|
||||
}
|
||||
if (code === 4001) {
|
||||
this.onStatus('replaced');
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
this.onStatus('connecting');
|
||||
clearTimeout(this.retryTimer);
|
||||
this.retryTimer = setTimeout(() => this.start(), 1000);
|
||||
}
|
||||
|
||||
receive(text) {
|
||||
try {
|
||||
this.onMessage(JSON.parse(text));
|
||||
} catch {
|
||||
// Ignore malformed relay input.
|
||||
}
|
||||
}
|
||||
|
||||
/** Poses are latest-wins; every other message is delivered in order. */
|
||||
send(message) {
|
||||
if (this.closed) return;
|
||||
// 1 is WebSocket.OPEN.
|
||||
if (this.ws && this.ws.readyState === 1) {
|
||||
this.ws.send(JSON.stringify(message));
|
||||
return;
|
||||
}
|
||||
if (message.type === 'pose') this.pendingPose = message;
|
||||
else this.queue.push(message);
|
||||
if (this.mode === 'http') this.flush();
|
||||
}
|
||||
|
||||
async flush() {
|
||||
if (this.inFlight || this.mode !== 'http' || this.closed) return;
|
||||
const batch = this.queue.splice(0, 32);
|
||||
if (this.pendingPose) batch.push(this.pendingPose);
|
||||
this.pendingPose = null;
|
||||
if (!batch.length) return;
|
||||
this.inFlight = true;
|
||||
try {
|
||||
const response = await fetch('/api/send', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Vcam-Key': this.key,
|
||||
},
|
||||
body: JSON.stringify(batch),
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (response.status === 403) this.handleClose(4003);
|
||||
} catch {
|
||||
// The event stream's error handling owns reconnection.
|
||||
} finally {
|
||||
this.inFlight = false;
|
||||
}
|
||||
if (this.queue.length || this.pendingPose) this.flush();
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closed = true;
|
||||
clearTimeout(this.retryTimer);
|
||||
this.ws?.close();
|
||||
this.events?.close();
|
||||
this.ws = null;
|
||||
this.events = null;
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------- state
|
||||
const app = {
|
||||
key: store.get(KEY_STORAGE),
|
||||
link: null,
|
||||
tracking: null, // { mode: '6dof'|'3dof', stop() }
|
||||
orientation: null,
|
||||
sticks: { left: [0, 0], right: [0, 0] },
|
||||
host: {
|
||||
connected: false,
|
||||
recording: false,
|
||||
elapsedSec: 0,
|
||||
scale: 1,
|
||||
focalMm: 31.18,
|
||||
fStop: 2.8,
|
||||
dof: false,
|
||||
held: false,
|
||||
smoothing: 0.3,
|
||||
heightLock: false,
|
||||
horizonLock: false,
|
||||
},
|
||||
recordingSince: 0,
|
||||
monitorRunning: false,
|
||||
wakeLock: null,
|
||||
};
|
||||
|
||||
function show(screenId) {
|
||||
for (const id of ['pair', 'start', 'camera']) $(id).hidden = id !== screenId;
|
||||
}
|
||||
|
||||
function setStatus(element, text, error = false) {
|
||||
element.textContent = text || '';
|
||||
element.classList.toggle('error', !!error);
|
||||
}
|
||||
|
||||
let toastTimer = null;
|
||||
function toast(text) {
|
||||
const element = $('camera-status');
|
||||
element.textContent = text;
|
||||
element.hidden = false;
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => (element.hidden = true), 2600);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- pairing
|
||||
async function join(code) {
|
||||
setStatus($('pair-status'), 'Pairing…');
|
||||
try {
|
||||
const response = await fetch('/api/join', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code }),
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (response.status === 403) {
|
||||
setStatus(
|
||||
$('pair-status'),
|
||||
'That code is not active. Check the code on the desktop.',
|
||||
true,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (!response.ok) throw new Error(String(response.status));
|
||||
const { phoneKey } = await response.json();
|
||||
app.key = phoneKey;
|
||||
store.set(KEY_STORAGE, phoneKey);
|
||||
store.set(CODE_STORAGE, code);
|
||||
setStatus($('pair-status'), '');
|
||||
connect();
|
||||
showStart();
|
||||
return true;
|
||||
} catch {
|
||||
setStatus(
|
||||
$('pair-status'),
|
||||
'Could not reach the desktop. Is it on the same network?',
|
||||
true,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function connect() {
|
||||
app.link?.close();
|
||||
app.link = new Link(app.key, {
|
||||
onMessage: handleMessage,
|
||||
onStatus: presentLink,
|
||||
onUnpaired: async (reason) => {
|
||||
app.link?.close();
|
||||
app.link = null;
|
||||
store.set(KEY_STORAGE, null);
|
||||
stopTracking();
|
||||
const code = store.get(CODE_STORAGE);
|
||||
if (reason === 'unpaired' && code && (await join(code))) return;
|
||||
show('pair');
|
||||
setStatus(
|
||||
$('pair-status'),
|
||||
reason === 'ended'
|
||||
? 'The desktop ended this session. Enter a new code.'
|
||||
: 'Pairing expired. Enter the code shown on the desktop.',
|
||||
true,
|
||||
);
|
||||
},
|
||||
});
|
||||
app.link.start();
|
||||
}
|
||||
|
||||
function presentLink(status) {
|
||||
const dot = $('link-dot');
|
||||
dot.classList.toggle('live', status === 'live');
|
||||
dot.classList.toggle('slow', status === 'slow');
|
||||
dot.title =
|
||||
status === 'live'
|
||||
? 'Live connection'
|
||||
: status === 'slow'
|
||||
? 'Fallback connection (install the certificate for lower latency)'
|
||||
: 'Connecting…';
|
||||
if (status === 'replaced') toast('Another phone took over this session');
|
||||
}
|
||||
|
||||
async function showStart() {
|
||||
show('start');
|
||||
const xr = await xrSupported();
|
||||
$('start-xr').hidden = !xr;
|
||||
$('support').textContent = xr
|
||||
? 'This phone supports AR tracking. Point it like a camera.'
|
||||
: 'AR tracking is not available in this browser, so the gyro camera tracks rotation and the sticks move you.';
|
||||
}
|
||||
|
||||
async function xrSupported() {
|
||||
try {
|
||||
return !!(await navigator.xr?.isSessionSupported('immersive-ar'));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ tracking
|
||||
function sendPose(q, p) {
|
||||
const { left, right } = app.sticks;
|
||||
app.link?.send({
|
||||
type: 'pose',
|
||||
t: Math.round(performance.now()),
|
||||
mode: app.tracking?.mode,
|
||||
q: q.map((v) => round(v)),
|
||||
p: p ? p.map((v) => round(v, 4)) : null,
|
||||
j: [left[0], left[1], right[0], right[1]].map((v) => round(v, 3)),
|
||||
});
|
||||
}
|
||||
|
||||
async function startXr() {
|
||||
setStatus($('start-status'), 'Starting AR…');
|
||||
try {
|
||||
const root = $('camera');
|
||||
const session = await navigator.xr.requestSession('immersive-ar', {
|
||||
requiredFeatures: ['local'],
|
||||
optionalFeatures: ['dom-overlay'],
|
||||
domOverlay: { root },
|
||||
});
|
||||
// Screen taps on controls must not become XR select input.
|
||||
root.addEventListener('beforexrselect', (event) => event.preventDefault());
|
||||
const canvas = document.createElement('canvas');
|
||||
const gl = canvas.getContext('webgl', { xrCompatible: true, alpha: true });
|
||||
await gl.makeXRCompatible?.();
|
||||
session.updateRenderState({ baseLayer: new XRWebGLLayer(session, gl) });
|
||||
const space = await session.requestReferenceSpace('local');
|
||||
let stopped = false;
|
||||
const onFrame = (_time, frame) => {
|
||||
if (stopped) return;
|
||||
session.requestAnimationFrame(onFrame);
|
||||
const pose = frame.getViewerPose(space);
|
||||
if (pose) {
|
||||
const { position, orientation } = pose.transform;
|
||||
sendPose(
|
||||
[orientation.x, orientation.y, orientation.z, orientation.w],
|
||||
pose.emulatedPosition ? null : [position.x, position.y, position.z],
|
||||
);
|
||||
}
|
||||
const layer = session.renderState.baseLayer;
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, layer.framebuffer);
|
||||
gl.clearColor(0, 0, 0, 0);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
};
|
||||
session.requestAnimationFrame(onFrame);
|
||||
session.addEventListener('end', () => {
|
||||
stopped = true;
|
||||
if (app.tracking?.session === session) {
|
||||
app.tracking = null;
|
||||
showStart();
|
||||
}
|
||||
});
|
||||
app.tracking = {
|
||||
mode: '6dof',
|
||||
session,
|
||||
stop: () => session.end().catch(() => {}),
|
||||
};
|
||||
enterCamera();
|
||||
} catch (error) {
|
||||
setStatus(
|
||||
$('start-status'),
|
||||
`AR could not start (${error?.message || error}). Try the gyro camera.`,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function startGyro() {
|
||||
// iOS asks for motion access only from a tap, before anything else awaits.
|
||||
try {
|
||||
if (typeof DeviceOrientationEvent?.requestPermission === 'function') {
|
||||
const answer = await DeviceOrientationEvent.requestPermission();
|
||||
if (answer !== 'granted') {
|
||||
setStatus(
|
||||
$('start-status'),
|
||||
'Motion access was denied. Allow it in Safari settings for this site, then reload.',
|
||||
true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setStatus($('start-status'), 'Motion access is unavailable.', true);
|
||||
return;
|
||||
}
|
||||
let stopped = false;
|
||||
const onOrientation = (event) => {
|
||||
if (event.alpha === null && event.beta === null) return;
|
||||
app.orientation = orientationQuaternion(
|
||||
event.alpha,
|
||||
event.beta,
|
||||
event.gamma,
|
||||
screenAngle(),
|
||||
);
|
||||
};
|
||||
window.addEventListener('deviceorientation', onOrientation);
|
||||
const tick = () => {
|
||||
if (stopped) return;
|
||||
requestAnimationFrame(tick);
|
||||
if (app.orientation) sendPose(app.orientation, null);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
app.tracking = {
|
||||
mode: '3dof',
|
||||
stop: () => {
|
||||
stopped = true;
|
||||
window.removeEventListener('deviceorientation', onOrientation);
|
||||
},
|
||||
};
|
||||
document.documentElement.requestFullscreen?.().catch(() => {});
|
||||
enterCamera();
|
||||
setTimeout(() => {
|
||||
if (!app.orientation && !stopped)
|
||||
toast('No motion data yet — this browser may block the gyroscope');
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
function stopTracking() {
|
||||
app.tracking?.stop();
|
||||
app.tracking = null;
|
||||
app.monitorRunning = false;
|
||||
}
|
||||
|
||||
async function enterCamera() {
|
||||
show('camera');
|
||||
$('mode-readout').textContent =
|
||||
app.tracking?.mode === '6dof' ? 'AR 6DOF' : 'GYRO 3DOF';
|
||||
app.link?.send({
|
||||
type: 'hello',
|
||||
mode: app.tracking?.mode,
|
||||
screen: [window.innerWidth, window.innerHeight],
|
||||
});
|
||||
try {
|
||||
app.wakeLock = await navigator.wakeLock?.request('screen');
|
||||
} catch {
|
||||
// Optional: the screen may dim during long takes.
|
||||
}
|
||||
runMonitor();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- monitor
|
||||
async function runMonitor() {
|
||||
if (app.monitorRunning) return;
|
||||
app.monitorRunning = true;
|
||||
const monitor = $('monitor');
|
||||
let seq = 0;
|
||||
let previousUrl = null;
|
||||
while (app.monitorRunning && app.key) {
|
||||
if (document.hidden) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/frame?key=${encodeURIComponent(app.key)}&after=${seq}`,
|
||||
{ cache: 'no-store' },
|
||||
);
|
||||
if (response.status === 403) break;
|
||||
if (response.status !== 200) continue;
|
||||
seq = Number(response.headers.get('X-Frame-Seq')) || seq + 1;
|
||||
const url = URL.createObjectURL(await response.blob());
|
||||
await new Promise((resolve) => {
|
||||
monitor.onload = resolve;
|
||||
monitor.onerror = resolve;
|
||||
monitor.src = url;
|
||||
});
|
||||
if (previousUrl) URL.revokeObjectURL(previousUrl);
|
||||
previousUrl = url;
|
||||
} catch {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
app.monitorRunning = false;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ controls
|
||||
function stick(element, target) {
|
||||
const knob = element.querySelector('.knob');
|
||||
let pointer = null;
|
||||
const set = (x, y) => {
|
||||
target[0] = x;
|
||||
target[1] = y;
|
||||
const radius = element.clientWidth / 2 - 26;
|
||||
knob.style.transform = `translate(${x * radius}px, ${-y * radius}px)`;
|
||||
};
|
||||
const move = (event) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const radius = rect.width / 2;
|
||||
let x = (event.clientX - rect.left - radius) / radius;
|
||||
let y = -(event.clientY - rect.top - radius) / radius;
|
||||
const length = Math.hypot(x, y);
|
||||
if (length > 1) {
|
||||
x /= length;
|
||||
y /= length;
|
||||
}
|
||||
// Rescale past the dead zone so small nudges stay precise.
|
||||
const live = Math.min(1, Math.hypot(x, y));
|
||||
const scale =
|
||||
live < STICK_DEADZONE
|
||||
? 0
|
||||
: (live - STICK_DEADZONE) / (1 - STICK_DEADZONE) / live;
|
||||
set(x * scale, y * scale);
|
||||
};
|
||||
element.addEventListener('pointerdown', (event) => {
|
||||
if (pointer !== null) return;
|
||||
pointer = event.pointerId;
|
||||
element.setPointerCapture(pointer);
|
||||
move(event);
|
||||
});
|
||||
element.addEventListener('pointermove', (event) => {
|
||||
if (event.pointerId === pointer) move(event);
|
||||
});
|
||||
const release = (event) => {
|
||||
if (event.pointerId !== pointer) return;
|
||||
pointer = null;
|
||||
set(0, 0);
|
||||
};
|
||||
element.addEventListener('pointerup', release);
|
||||
element.addEventListener('pointercancel', release);
|
||||
}
|
||||
|
||||
const sendSet = (key, value) => app.link?.send({ type: 'set', key, value });
|
||||
const sendCommand = (command) => app.link?.send({ type: 'cmd', cmd: command });
|
||||
|
||||
function presentHost() {
|
||||
const h = app.host;
|
||||
$('record-btn').setAttribute('aria-pressed', String(h.recording));
|
||||
$('rec-readout').hidden = !h.recording;
|
||||
$('hold-btn').setAttribute('aria-pressed', String(h.held));
|
||||
$('scale-readout').textContent = formatScale(h.scale);
|
||||
$('scale-value').textContent = formatScale(h.scale);
|
||||
$('focal-readout').textContent = `${Math.round(h.focalMm)}mm`;
|
||||
$('focal-value').textContent = `${Math.round(h.focalMm)}mm`;
|
||||
$('fstop-value').textContent = `f/${h.fStop.toFixed(1)}`;
|
||||
$('smooth-value').textContent = `${Math.round(h.smoothing * 100)}%`;
|
||||
const active = (element) =>
|
||||
element === document.activeElement && element.matches(':active');
|
||||
if (!active($('scale-range')))
|
||||
$('scale-range').value = String(Math.log10(h.scale));
|
||||
if (!active($('focal-range')))
|
||||
$('focal-range').value = String(logUnmap(FOCAL_RANGE, h.focalMm));
|
||||
if (!active($('fstop-range')))
|
||||
$('fstop-range').value = String(logUnmap(FSTOP_RANGE, h.fStop));
|
||||
if (!active($('smooth-range')))
|
||||
$('smooth-range').value = String(Math.round(h.smoothing * 100));
|
||||
$('dof-toggle').checked = h.dof;
|
||||
$('height-lock').checked = h.heightLock;
|
||||
$('horizon-lock').checked = h.horizonLock;
|
||||
for (const button of $('scale-presets').querySelectorAll('button'))
|
||||
button.classList.toggle(
|
||||
'active',
|
||||
Math.abs(Number(button.dataset.scale) - h.scale) < 1e-6,
|
||||
);
|
||||
for (const button of $('focal-presets').querySelectorAll('button'))
|
||||
button.classList.toggle(
|
||||
'active',
|
||||
Math.abs(Number(button.dataset.focal) - h.focalMm) < 0.5,
|
||||
);
|
||||
}
|
||||
|
||||
function handleMessage(message) {
|
||||
if (message.type === 'state') {
|
||||
const wasRecording = app.host.recording;
|
||||
Object.assign(app.host, message.state);
|
||||
if (app.host.recording && !wasRecording)
|
||||
app.recordingSince = performance.now() - app.host.elapsedSec * 1000;
|
||||
presentHost();
|
||||
} else if (message.type === 'peer' && message.role === 'host') {
|
||||
app.host.connected = message.connected;
|
||||
if (!message.connected) toast('Desktop disconnected — waiting…');
|
||||
} else if (message.type === 'toast' && typeof message.text === 'string') {
|
||||
toast(message.text.slice(0, 140));
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDrawer(name) {
|
||||
for (const other of ['scale', 'lens']) {
|
||||
const open = other === name && $(`${other}-drawer`).hidden;
|
||||
$(`${other}-drawer`).hidden = !open;
|
||||
$(`${other}-btn`).setAttribute('aria-expanded', String(open));
|
||||
}
|
||||
}
|
||||
|
||||
function bindControls() {
|
||||
$('pair-form').addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
const code = $('code').value.trim().toUpperCase();
|
||||
if (code) join(code);
|
||||
});
|
||||
$('start-xr').addEventListener('click', startXr);
|
||||
$('start-gyro').addEventListener('click', startGyro);
|
||||
stick($('stick-left'), app.sticks.left);
|
||||
stick($('stick-right'), app.sticks.right);
|
||||
$('record-btn').addEventListener('click', () =>
|
||||
sendCommand(app.host.recording ? 'stop' : 'record'),
|
||||
);
|
||||
$('hold-btn').addEventListener('click', () =>
|
||||
sendCommand(app.host.held ? 'release' : 'hold'),
|
||||
);
|
||||
$('recenter-btn').addEventListener('click', () => sendCommand('recenter'));
|
||||
$('scale-btn').addEventListener('click', () => toggleDrawer('scale'));
|
||||
$('lens-btn').addEventListener('click', () => toggleDrawer('lens'));
|
||||
$('scale-presets').addEventListener('click', (event) => {
|
||||
const value = Number(event.target.closest('button')?.dataset.scale);
|
||||
if (SCALE_PRESETS.includes(value)) sendSet('scale', value);
|
||||
});
|
||||
$('scale-range').addEventListener('input', (event) => {
|
||||
const scale = 10 ** Number(event.target.value);
|
||||
$('scale-value').textContent = formatScale(scale);
|
||||
sendSet('scale', round(scale, 3));
|
||||
});
|
||||
$('smooth-range').addEventListener('input', (event) =>
|
||||
sendSet('smoothing', Number(event.target.value) / 100),
|
||||
);
|
||||
$('height-lock').addEventListener('change', (event) =>
|
||||
sendSet('heightLock', event.target.checked),
|
||||
);
|
||||
$('horizon-lock').addEventListener('change', (event) =>
|
||||
sendSet('horizonLock', event.target.checked),
|
||||
);
|
||||
$('focal-range').addEventListener('input', (event) => {
|
||||
const focal = logMap(FOCAL_RANGE, Number(event.target.value));
|
||||
$('focal-value').textContent = `${Math.round(focal)}mm`;
|
||||
sendSet('focalMm', round(focal, 2));
|
||||
});
|
||||
$('focal-presets').addEventListener('click', (event) => {
|
||||
const value = Number(event.target.closest('button')?.dataset.focal);
|
||||
if (value) sendSet('focalMm', value);
|
||||
});
|
||||
$('dof-toggle').addEventListener('change', (event) =>
|
||||
sendSet('dof', event.target.checked),
|
||||
);
|
||||
$('fstop-range').addEventListener('input', (event) => {
|
||||
const fStop = logMap(FSTOP_RANGE, Number(event.target.value));
|
||||
$('fstop-value').textContent = `f/${fStop.toFixed(1)}`;
|
||||
sendSet('fStop', round(fStop, 2));
|
||||
});
|
||||
// Tap the monitor to focus: coordinates are within the shown picture.
|
||||
$('monitor').addEventListener('click', (event) => {
|
||||
const monitor = $('monitor');
|
||||
if (!monitor.naturalWidth) return;
|
||||
const rect = monitor.getBoundingClientRect();
|
||||
const fit = Math.min(
|
||||
rect.width / monitor.naturalWidth,
|
||||
rect.height / monitor.naturalHeight,
|
||||
);
|
||||
const width = monitor.naturalWidth * fit;
|
||||
const height = monitor.naturalHeight * fit;
|
||||
const x = (event.clientX - rect.left - (rect.width - width) / 2) / width;
|
||||
const y = (event.clientY - rect.top - (rect.height - height) / 2) / height;
|
||||
if (x < 0 || x > 1 || y < 0 || y > 1) return;
|
||||
app.link?.send({ type: 'focus', x: round(x, 4), y: round(y, 4) });
|
||||
const ring = $('focus-ring');
|
||||
ring.hidden = true;
|
||||
ring.style.left = `${event.clientX}px`;
|
||||
ring.style.top = `${event.clientY}px`;
|
||||
void ring.offsetWidth;
|
||||
ring.hidden = false;
|
||||
});
|
||||
setInterval(() => {
|
||||
if (app.host.recording)
|
||||
$('rec-readout').textContent =
|
||||
`● ${formatTime((performance.now() - app.recordingSince) / 1000)}`;
|
||||
}, 250);
|
||||
// The system releases a wake lock whenever the page is hidden.
|
||||
document.addEventListener('visibilitychange', async () => {
|
||||
if (document.hidden || !app.tracking) return;
|
||||
if (app.wakeLock && !app.wakeLock.released) return;
|
||||
try {
|
||||
app.wakeLock = await navigator.wakeLock?.request('screen');
|
||||
} catch {
|
||||
// Optional.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function boot() {
|
||||
bindControls();
|
||||
presentHost();
|
||||
const fromLink = decodeURIComponent(location.hash.slice(1))
|
||||
.toUpperCase()
|
||||
.replace(/[^0-9A-Z]/g, '');
|
||||
if (fromLink) {
|
||||
$('code').value = fromLink;
|
||||
store.set(CODE_STORAGE, fromLink);
|
||||
// Keep the code out of the address bar once read.
|
||||
history.replaceState(null, '', location.pathname);
|
||||
}
|
||||
if (app.key) {
|
||||
connect();
|
||||
showStart();
|
||||
} else if (fromLink) {
|
||||
join(fromLink);
|
||||
} else {
|
||||
show('pair');
|
||||
}
|
||||
}
|
||||
|
||||
boot();
|
||||
@@ -0,0 +1,522 @@
|
||||
/**
|
||||
* Virtual camera: lets a phone on the same network act as the app's camera.
|
||||
*
|
||||
* Two surfaces, deliberately separate:
|
||||
*
|
||||
* - On the app's own server (loopback only, behind the same gate as /mcp):
|
||||
* `POST /api/vcam/session` creates a pairing session, `DELETE` ends it,
|
||||
* `GET /api/vcam/ca.crt` downloads the local CA, and the desktop page joins
|
||||
* its session over the `/api/vcam/host` WebSocket.
|
||||
* - A separate HTTPS listener on the LAN (VCAM_PORT, default 4174) that starts
|
||||
* only when the desktop creates a session and stops after the last one
|
||||
* ends. It serves the static phone page and the phone side of the relay
|
||||
* and nothing else, so pairing a phone never exposes the app's provider
|
||||
* proxies or API keys to the network.
|
||||
*
|
||||
* The phone transport prefers a WebSocket and falls back to plain HTTPS
|
||||
* requests (POST for poses, server-sent events for state), which work under
|
||||
* a tapped-through certificate warning where iOS refuses WebSockets.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import https from 'node:https';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { isAllowedHost } from '../../build/allowedHosts.js';
|
||||
import { isSharingEnabled } from '../../src/keySetupCore.mjs';
|
||||
import { isLocalMcpRequest } from '../mcp/plugin.js';
|
||||
import { MAX_FRAME_BYTES, MAX_TEXT_BYTES, createVcamRelay } from './relay.js';
|
||||
import { loadLocalCertificates } from './tls.js';
|
||||
|
||||
const PHONE_DIR = fileURLToPath(new URL('./phone/', import.meta.url));
|
||||
const PHONE_FILES = Object.freeze({
|
||||
'/': ['index.html', 'text/html; charset=utf-8'],
|
||||
'/index.html': ['index.html', 'text/html; charset=utf-8'],
|
||||
'/phone.js': ['phone.js', 'text/javascript; charset=utf-8'],
|
||||
'/orientation.js': ['orientation.js', 'text/javascript; charset=utf-8'],
|
||||
'/phone.css': ['phone.css', 'text/css; charset=utf-8'],
|
||||
'/icon.svg': ['icon.svg', 'image/svg+xml'],
|
||||
});
|
||||
const DEFAULT_PORT = 4174;
|
||||
const MAX_BATCH_BYTES = 64 * 1024;
|
||||
const BODY_TIMEOUT_MS = 10000;
|
||||
const SSE_KEEPALIVE_MS = 15000;
|
||||
// The listener lingers after the last session in case the desktop reloads.
|
||||
const IDLE_SHUTDOWN_MS = 120000;
|
||||
const CA_CONTENT_TYPE = 'application/x-x509-ca-cert';
|
||||
|
||||
/** Headers for every phone-listener response; the page is static. */
|
||||
export const PHONE_HEADERS = Object.freeze({
|
||||
'Content-Security-Policy': [
|
||||
"default-src 'self'",
|
||||
"script-src 'self'",
|
||||
"style-src 'self'",
|
||||
"img-src 'self' blob: data:",
|
||||
"connect-src 'self' wss:",
|
||||
"base-uri 'none'",
|
||||
"form-action 'none'",
|
||||
"frame-ancestors 'none'",
|
||||
"object-src 'none'",
|
||||
].join('; '),
|
||||
'X-Frame-Options': 'DENY',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'Referrer-Policy': 'no-referrer',
|
||||
'Cache-Control': 'no-store',
|
||||
'Permissions-Policy': 'xr-spatial-tracking=(self), camera=(self)',
|
||||
});
|
||||
|
||||
/**
|
||||
* IPv4 addresses a phone on the same network could reach, private ranges
|
||||
* first (home and office Wi-Fi), then others. Loopback and link-local are
|
||||
* left out.
|
||||
*/
|
||||
export function lanAddresses(interfaces = os.networkInterfaces()) {
|
||||
const rank = (address) =>
|
||||
address.startsWith('192.168.')
|
||||
? 0
|
||||
: address.startsWith('10.')
|
||||
? 1
|
||||
: /^172\.(1[6-9]|2\d|3[01])\./.test(address)
|
||||
? 2
|
||||
: 3;
|
||||
const found = [];
|
||||
for (const entries of Object.values(interfaces || {}))
|
||||
for (const entry of entries || []) {
|
||||
const family = entry.family === 4 ? 'IPv4' : entry.family;
|
||||
if (family !== 'IPv4' || entry.internal) continue;
|
||||
if (entry.address.startsWith('169.254.')) continue;
|
||||
if (!found.includes(entry.address)) found.push(entry.address);
|
||||
}
|
||||
return found.sort((a, b) => rank(a) - rank(b));
|
||||
}
|
||||
|
||||
const json = (res, status, body) => {
|
||||
res.statusCode = status;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.end(JSON.stringify(body));
|
||||
};
|
||||
|
||||
function readBody(req, limit) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
const timer = setTimeout(() => {
|
||||
reject(Object.assign(new Error('timeout'), { status: 408 }));
|
||||
req.destroy();
|
||||
}, BODY_TIMEOUT_MS);
|
||||
req.on('data', (chunk) => {
|
||||
size += chunk.length;
|
||||
if (size > limit) {
|
||||
clearTimeout(timer);
|
||||
reject(Object.assign(new Error('too large'), { status: 413 }));
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on('end', () => {
|
||||
clearTimeout(timer);
|
||||
resolve(Buffer.concat(chunks).toString('utf8'));
|
||||
});
|
||||
req.on('error', (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Whether a desktop request may control pairing (loopback, same-origin). */
|
||||
function isDesktopRequest(req, env, { requireOrigin = true } = {}) {
|
||||
if (requireOrigin && !req.headers.origin) return false;
|
||||
return isLocalMcpRequest({
|
||||
remoteAddress: req.socket?.remoteAddress,
|
||||
localPort: req.socket?.localPort,
|
||||
host: req.headers.host || '',
|
||||
origin: req.headers.origin,
|
||||
headers: req.headers,
|
||||
env,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the virtual-camera service: the relay, the lazily started phone
|
||||
* listener and the handlers both servers mount.
|
||||
*/
|
||||
export function createVcamService({
|
||||
env = process.env,
|
||||
cacheDir = path.join(process.cwd(), '.gev-cache', 'vcam'),
|
||||
port = Number.parseInt(env.VCAM_PORT, 10) || DEFAULT_PORT,
|
||||
host = env.VCAM_HOST || '0.0.0.0',
|
||||
addresses = () => lanAddresses(),
|
||||
allowedHosts = [],
|
||||
logger = console,
|
||||
} = {}) {
|
||||
let listener = null;
|
||||
let listenerNames = '';
|
||||
let starting = null;
|
||||
let idleTimer = null;
|
||||
let certificates = null;
|
||||
const relay = createVcamRelay({
|
||||
onSessionClosed: () => {
|
||||
if (relay.size === 0) scheduleIdleShutdown();
|
||||
},
|
||||
});
|
||||
const sockets = new WebSocketServer({
|
||||
noServer: true,
|
||||
maxPayload: MAX_FRAME_BYTES,
|
||||
});
|
||||
|
||||
function scheduleIdleShutdown() {
|
||||
clearTimeout(idleTimer);
|
||||
idleTimer = setTimeout(() => {
|
||||
if (relay.size === 0) stopListener();
|
||||
}, IDLE_SHUTDOWN_MS);
|
||||
idleTimer.unref?.();
|
||||
}
|
||||
|
||||
function stopListener() {
|
||||
const current = listener;
|
||||
listener = null;
|
||||
listenerNames = '';
|
||||
current?.closeAllConnections?.();
|
||||
current?.close();
|
||||
}
|
||||
|
||||
/** Start (or restart for a changed address) the HTTPS phone listener. */
|
||||
async function ensureListener(requested) {
|
||||
clearTimeout(idleTimer);
|
||||
const names = [...new Set(requested)];
|
||||
const key = names.join(',');
|
||||
if (listener && listenerNames === key) return;
|
||||
if (starting) await starting.catch(() => {});
|
||||
if (listener && listenerNames === key) return;
|
||||
stopListener();
|
||||
certificates = loadLocalCertificates(cacheDir, names);
|
||||
const server = https.createServer(
|
||||
{ cert: certificates.cert, key: certificates.key },
|
||||
(req, res) => handlePhoneRequest(req, res),
|
||||
);
|
||||
server.on('upgrade', (req, socket, head) =>
|
||||
handlePhoneUpgrade(req, socket, head),
|
||||
);
|
||||
server.on('clientError', (_error, socket) => socket.destroy());
|
||||
starting = new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(port, host, () => {
|
||||
server.off('error', reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
try {
|
||||
await starting;
|
||||
} finally {
|
||||
starting = null;
|
||||
}
|
||||
listener = server;
|
||||
listenerNames = key;
|
||||
logger.info?.(
|
||||
`[vcam] phone listener on https://${names[0]}:${port}/ (pairing session only)`,
|
||||
);
|
||||
}
|
||||
|
||||
const phoneHostAllowed = (req) =>
|
||||
isAllowedHost(req.headers.host, allowedHosts);
|
||||
|
||||
function servePhoneFile(req, res, pathname) {
|
||||
const entry = PHONE_FILES[pathname];
|
||||
if (!entry) return false;
|
||||
const [file, type] = entry;
|
||||
let body;
|
||||
try {
|
||||
body = fs.readFileSync(path.join(PHONE_DIR, file));
|
||||
} catch {
|
||||
json(res, 500, { error: 'phone page unavailable' });
|
||||
return true;
|
||||
}
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', type);
|
||||
res.end(req.method === 'HEAD' ? undefined : body);
|
||||
return true;
|
||||
}
|
||||
|
||||
function sendCa(res) {
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', CA_CONTENT_TYPE);
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
'attachment; filename="gods-eye-view-vcam-ca.crt"',
|
||||
);
|
||||
res.end(certificates?.ca || '');
|
||||
}
|
||||
|
||||
function phoneSession(req, url) {
|
||||
const key = req.headers['x-vcam-key'] || url.searchParams.get('key');
|
||||
return relay.sessionForPhone(key);
|
||||
}
|
||||
|
||||
async function handlePhoneRequest(req, res) {
|
||||
for (const [name, value] of Object.entries(PHONE_HEADERS))
|
||||
res.setHeader(name, value);
|
||||
if (!phoneHostAllowed(req)) return json(res, 421, { error: 'bad host' });
|
||||
let url;
|
||||
try {
|
||||
url = new URL(req.url, 'https://phone.invalid');
|
||||
} catch {
|
||||
return json(res, 400, { error: 'bad request' });
|
||||
}
|
||||
const { pathname } = url;
|
||||
try {
|
||||
if (req.method === 'GET' || req.method === 'HEAD') {
|
||||
if (servePhoneFile(req, res, pathname)) return;
|
||||
if (pathname === '/ca.crt') return sendCa(res);
|
||||
}
|
||||
if (pathname === '/api/join' && req.method === 'POST') {
|
||||
let code = '';
|
||||
try {
|
||||
code = JSON.parse(await readBody(req, 1024))?.code;
|
||||
} catch (error) {
|
||||
if (error?.status) throw error;
|
||||
}
|
||||
const joined = relay.joinPhone(code);
|
||||
if (!joined.ok) return json(res, 403, { error: joined.reason });
|
||||
return json(res, 200, {
|
||||
phoneKey: joined.phoneKey,
|
||||
sessionId: joined.sessionId,
|
||||
limits: { text: MAX_TEXT_BYTES },
|
||||
});
|
||||
}
|
||||
if (pathname === '/api/send' && req.method === 'POST') {
|
||||
const session = phoneSession(req, url);
|
||||
if (!session) return json(res, 403, { error: 'unpaired' });
|
||||
let messages;
|
||||
try {
|
||||
messages = JSON.parse(await readBody(req, MAX_BATCH_BYTES));
|
||||
} catch (error) {
|
||||
if (error?.status) throw error;
|
||||
return json(res, 400, { error: 'expected a JSON array' });
|
||||
}
|
||||
if (!Array.isArray(messages) || messages.length > 64)
|
||||
return json(res, 400, { error: 'expected a JSON array' });
|
||||
let accepted = 0;
|
||||
for (const message of messages)
|
||||
if (relay.relay(session, 'phone', JSON.stringify(message)))
|
||||
accepted += 1;
|
||||
return json(res, 200, { accepted });
|
||||
}
|
||||
if (pathname === '/api/events' && req.method === 'GET')
|
||||
return openEventStream(req, res, phoneSession(req, url));
|
||||
if (pathname === '/api/frame' && req.method === 'GET') {
|
||||
const session = phoneSession(req, url);
|
||||
if (!session) return json(res, 403, { error: 'unpaired' });
|
||||
const after = Number.parseInt(url.searchParams.get('after'), 10) || 0;
|
||||
const frame = await relay.nextFrame(session, after, 2000);
|
||||
if (!frame) {
|
||||
res.statusCode = 204;
|
||||
return res.end();
|
||||
}
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'image/jpeg');
|
||||
res.setHeader('X-Frame-Seq', String(frame.seq));
|
||||
return res.end(frame.bytes);
|
||||
}
|
||||
json(res, 404, { error: 'not found' });
|
||||
} catch (error) {
|
||||
if (!res.headersSent)
|
||||
json(res, error?.status || 500, { error: 'request failed' });
|
||||
else res.end();
|
||||
}
|
||||
}
|
||||
|
||||
function openEventStream(req, res, session) {
|
||||
if (!session) return json(res, 403, { error: 'unpaired' });
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.flushHeaders?.();
|
||||
const keepAlive = setInterval(
|
||||
() => res.write(': keep-alive\n\n'),
|
||||
SSE_KEEPALIVE_MS,
|
||||
);
|
||||
const peer = {
|
||||
send: (text) => res.write(`data: ${text}\n\n`),
|
||||
close: () => res.end(),
|
||||
};
|
||||
const attached = relay.attachPhone(
|
||||
req.headers['x-vcam-key'] ||
|
||||
new URL(req.url, 'https://phone.invalid').searchParams.get('key'),
|
||||
peer,
|
||||
);
|
||||
if (!attached) {
|
||||
clearInterval(keepAlive);
|
||||
return res.end();
|
||||
}
|
||||
req.on('close', () => {
|
||||
clearInterval(keepAlive);
|
||||
relay.detach(attached, 'phone', peer);
|
||||
});
|
||||
}
|
||||
|
||||
function refuseUpgrade(socket, status) {
|
||||
socket.end(`HTTP/1.1 ${status}\r\nConnection: close\r\n\r\n`);
|
||||
}
|
||||
|
||||
function handlePhoneUpgrade(req, socket, head) {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(req.url, 'https://phone.invalid');
|
||||
} catch {
|
||||
return refuseUpgrade(socket, '400 Bad Request');
|
||||
}
|
||||
if (url.pathname !== '/ws' || !phoneHostAllowed(req))
|
||||
return refuseUpgrade(socket, '404 Not Found');
|
||||
// A WebSocket is not subject to CORS: only this page's own origin may open one.
|
||||
if (req.headers.origin !== `https://${req.headers.host}`)
|
||||
return refuseUpgrade(socket, '403 Forbidden');
|
||||
const key = url.searchParams.get('key');
|
||||
if (!relay.sessionForPhone(key))
|
||||
return refuseUpgrade(socket, '403 Forbidden');
|
||||
sockets.handleUpgrade(req, socket, head, (ws) => {
|
||||
const peer = {
|
||||
send: (text) => ws.readyState === ws.OPEN && ws.send(text),
|
||||
close: (code, reason) => ws.close(code, reason),
|
||||
};
|
||||
const session = relay.attachPhone(key, peer);
|
||||
if (!session) return ws.close(4003, 'unpaired');
|
||||
ws.on('message', (data, isBinary) => {
|
||||
if (isBinary) return;
|
||||
relay.relay(session, 'phone', data.toString('utf8'));
|
||||
});
|
||||
ws.on('close', () => relay.detach(session, 'phone', peer));
|
||||
ws.on('error', () => ws.terminate());
|
||||
});
|
||||
}
|
||||
|
||||
/** Mount the desktop routes on the app's own (dev or preview) server. */
|
||||
function install(server) {
|
||||
server.middlewares.use('/api/vcam', async (req, res, next) => {
|
||||
const pathname = (req.url || '/').split('?')[0];
|
||||
try {
|
||||
if (pathname === '/ca.crt' && req.method === 'GET') {
|
||||
if (!isDesktopRequest(req, env, { requireOrigin: false }))
|
||||
return json(res, 403, { error: 'local only' });
|
||||
if (!certificates)
|
||||
certificates = loadLocalCertificates(cacheDir, [
|
||||
...addresses(),
|
||||
'localhost',
|
||||
'127.0.0.1',
|
||||
]);
|
||||
return sendCa(res);
|
||||
}
|
||||
if (pathname !== '/session') return next();
|
||||
if (!isDesktopRequest(req, env))
|
||||
return json(res, 403, { error: 'local only' });
|
||||
if (isSharingEnabled(env))
|
||||
return json(res, 403, { error: 'sharing is enabled' });
|
||||
if (req.method === 'POST') {
|
||||
const lan = addresses();
|
||||
if (!lan.length)
|
||||
return json(res, 503, {
|
||||
error: 'no network address',
|
||||
code: 'no-network',
|
||||
});
|
||||
try {
|
||||
await ensureListener([...lan, 'localhost', '127.0.0.1']);
|
||||
} catch (error) {
|
||||
return json(res, 503, {
|
||||
error: 'phone listener unavailable',
|
||||
code: error?.code === 'EADDRINUSE' ? 'port-in-use' : 'listen',
|
||||
port,
|
||||
});
|
||||
}
|
||||
const session = relay.createSession();
|
||||
return json(res, 200, {
|
||||
sessionId: session.id,
|
||||
hostKey: session.hostKey,
|
||||
code: session.code,
|
||||
port: listener.address()?.port ?? port,
|
||||
addresses: lan,
|
||||
caFingerprint: certificates.caFingerprint,
|
||||
});
|
||||
}
|
||||
if (req.method === 'DELETE') {
|
||||
relay.endSession(req.headers['x-vcam-host-key']);
|
||||
return json(res, 200, { ended: true });
|
||||
}
|
||||
return json(res, 405, { error: 'method not allowed' });
|
||||
} catch {
|
||||
if (!res.headersSent) json(res, 500, { error: 'request failed' });
|
||||
}
|
||||
});
|
||||
|
||||
server.httpServer?.on('upgrade', (req, socket, head) => {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(req.url, 'http://local.invalid');
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (url.pathname !== '/api/vcam/host') return;
|
||||
// Browsers always send Origin on a WebSocket; it must be this app.
|
||||
if (!isDesktopRequest(req, env))
|
||||
return refuseUpgrade(socket, '403 Forbidden');
|
||||
const key = url.searchParams.get('key');
|
||||
if (!relay.sessionForHost(key))
|
||||
return refuseUpgrade(socket, '403 Forbidden');
|
||||
sockets.handleUpgrade(req, socket, head, (ws) => {
|
||||
const peer = {
|
||||
send: (text) => ws.readyState === ws.OPEN && ws.send(text),
|
||||
close: (code, reason) => ws.close(code, reason),
|
||||
};
|
||||
const session = relay.attachHost(key, peer);
|
||||
if (!session) return ws.close(4003, 'unknown session');
|
||||
ws.on('message', (data, isBinary) => {
|
||||
if (isBinary) relay.publishFrame(session, Buffer.from(data));
|
||||
else relay.relay(session, 'host', data.toString('utf8'));
|
||||
});
|
||||
ws.on('close', () => relay.detach(session, 'host', peer));
|
||||
ws.on('error', () => ws.terminate());
|
||||
});
|
||||
});
|
||||
server.httpServer?.once('close', () => destroy());
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
clearTimeout(idleTimer);
|
||||
relay.closeAll();
|
||||
stopListener();
|
||||
sockets.close();
|
||||
}
|
||||
|
||||
return {
|
||||
install,
|
||||
destroy,
|
||||
relay,
|
||||
ensureListener,
|
||||
get listening() {
|
||||
return !!listener;
|
||||
},
|
||||
get port() {
|
||||
return listener?.address()?.port ?? port;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Vite plugin mounting the virtual-camera desktop routes and phone listener. */
|
||||
export function vcamPlugin(options = {}) {
|
||||
let service = null;
|
||||
const install = (server) => {
|
||||
service ??= createVcamService({
|
||||
allowedHosts: server.config?.server?.allowedHosts || [],
|
||||
logger: server.config?.logger || console,
|
||||
...options,
|
||||
});
|
||||
service.install(server);
|
||||
};
|
||||
return {
|
||||
name: 'gev-vcam',
|
||||
configureServer: install,
|
||||
configurePreviewServer: install,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import WebSocket from 'ws';
|
||||
import { createVcamService, lanAddresses, PHONE_HEADERS } from './plugin.js';
|
||||
|
||||
/** The slice of a Vite server the service mounts on. */
|
||||
function appServer() {
|
||||
const stack = [];
|
||||
const httpServer = http.createServer((req, res) => {
|
||||
const layer = stack.find(({ prefix }) => req.url.startsWith(prefix));
|
||||
if (!layer) {
|
||||
res.statusCode = 404;
|
||||
return res.end();
|
||||
}
|
||||
req.url = req.url.slice(layer.prefix.length) || '/';
|
||||
layer.handle(req, res, () => {
|
||||
res.statusCode = 404;
|
||||
res.end();
|
||||
});
|
||||
});
|
||||
return {
|
||||
httpServer,
|
||||
middlewares: { use: (prefix, handle) => stack.push({ prefix, handle }) },
|
||||
};
|
||||
}
|
||||
|
||||
const listen = (server) =>
|
||||
new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
|
||||
function request(url, { method = 'GET', headers = {}, body, ca, checkServerIdentity } = {}) {
|
||||
const target = new URL(url);
|
||||
const client = target.protocol === 'https:' ? https : http;
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = client.request(
|
||||
target,
|
||||
{ method, headers, ca, ...(checkServerIdentity ? { checkServerIdentity } : {}) },
|
||||
(res) => {
|
||||
const chunks = [];
|
||||
res.on('data', (chunk) => chunks.push(chunk));
|
||||
res.on('end', () =>
|
||||
resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) }),
|
||||
);
|
||||
},
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.end(body);
|
||||
});
|
||||
}
|
||||
|
||||
/** Buffer a socket's messages from creation so back-to-back sends are never missed. */
|
||||
const inboxes = new WeakMap();
|
||||
function connect(url, options) {
|
||||
const ws = new WebSocket(url, options);
|
||||
const box = { queued: [], waiting: [] };
|
||||
inboxes.set(ws, box);
|
||||
ws.on('message', (data) => {
|
||||
const message = JSON.parse(data.toString());
|
||||
const waiter = box.waiting.shift();
|
||||
if (waiter) waiter(message);
|
||||
else box.queued.push(message);
|
||||
});
|
||||
return ws;
|
||||
}
|
||||
function nextMessage(ws) {
|
||||
const box = inboxes.get(ws);
|
||||
if (box.queued.length) return Promise.resolve(box.queued.shift());
|
||||
return new Promise((resolve) => box.waiting.push(resolve));
|
||||
}
|
||||
const opened = (ws) =>
|
||||
new Promise((resolve, reject) => {
|
||||
ws.once('open', resolve);
|
||||
ws.once('error', reject);
|
||||
ws.once('unexpected-response', (_req, res) => reject(new Error(`HTTP ${res.statusCode}`)));
|
||||
});
|
||||
|
||||
async function harness(t) {
|
||||
const cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-vcam-'));
|
||||
const service = createVcamService({
|
||||
env: {},
|
||||
cacheDir,
|
||||
port: 0,
|
||||
host: '127.0.0.1',
|
||||
// The phone reaches the listener on loopback in this test.
|
||||
addresses: () => ['127.0.0.1'],
|
||||
logger: {},
|
||||
});
|
||||
const app = appServer();
|
||||
service.install(app);
|
||||
await listen(app.httpServer);
|
||||
const appOrigin = `http://127.0.0.1:${app.httpServer.address().port}`;
|
||||
t.after(() => {
|
||||
service.destroy();
|
||||
app.httpServer.close();
|
||||
fs.rmSync(cacheDir, { recursive: true, force: true });
|
||||
});
|
||||
const session = async () => {
|
||||
const response = await request(`${appOrigin}/api/vcam/session`, {
|
||||
method: 'POST',
|
||||
headers: { Origin: appOrigin },
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
return JSON.parse(response.body);
|
||||
};
|
||||
const ca = () => fs.readFileSync(path.join(cacheDir, 'ca.crt'));
|
||||
return { service, appOrigin, session, ca };
|
||||
}
|
||||
|
||||
test('the desktop pairs a phone over a trusted local CA and both legs relay', async (t) => {
|
||||
const { appOrigin, session, ca } = await harness(t);
|
||||
const created = await session();
|
||||
assert.equal(created.code.length, 6);
|
||||
assert.deepEqual(created.addresses, ['127.0.0.1']);
|
||||
const phoneOrigin = `https://127.0.0.1:${created.port}`;
|
||||
|
||||
const page = await request(`${phoneOrigin}/`, { ca: ca() });
|
||||
assert.equal(page.status, 200);
|
||||
assert.match(page.body.toString(), /VIRTUAL CAMERA/);
|
||||
for (const [name, value] of Object.entries(PHONE_HEADERS))
|
||||
assert.equal(page.headers[name.toLowerCase()], value);
|
||||
assert.equal((await request(`${phoneOrigin}/api/vcam/session`, { method: 'POST', ca: ca() })).status, 404,
|
||||
'the phone listener exposes none of the desktop routes');
|
||||
const certificate = await request(`${phoneOrigin}/ca.crt`, { ca: ca() });
|
||||
assert.equal(certificate.headers['content-type'], 'application/x-x509-ca-cert');
|
||||
assert.equal(certificate.body.toString(), ca().toString());
|
||||
|
||||
const host = connect(
|
||||
`ws://127.0.0.1:${new URL(appOrigin).port}/api/vcam/host?key=${created.hostKey}`,
|
||||
{ origin: appOrigin },
|
||||
);
|
||||
const hostCode = nextMessage(host);
|
||||
await opened(host);
|
||||
assert.deepEqual(await hostCode, { type: 'code', code: created.code });
|
||||
await nextMessage(host); // phone not yet connected
|
||||
|
||||
const join = await request(`${phoneOrigin}/api/join`, {
|
||||
method: 'POST', ca: ca(), body: JSON.stringify({ code: created.code }),
|
||||
});
|
||||
assert.equal(join.status, 200);
|
||||
const { phoneKey } = JSON.parse(join.body);
|
||||
const phone = connect(`wss://127.0.0.1:${created.port}/ws?key=${phoneKey}`, {
|
||||
ca: ca(), origin: phoneOrigin,
|
||||
});
|
||||
const hostSeesPhone = nextMessage(host);
|
||||
await opened(phone);
|
||||
assert.deepEqual(await hostSeesPhone, { type: 'peer', role: 'phone', connected: true });
|
||||
|
||||
const pose = { type: 'pose', t: 1, q: [0, 0, 0, 1], p: [0, 1.5, 0] };
|
||||
const received = nextMessage(host);
|
||||
phone.send(JSON.stringify(pose));
|
||||
assert.deepEqual(await received, pose);
|
||||
await nextMessage(phone); // host presence
|
||||
const state = nextMessage(phone);
|
||||
host.send(JSON.stringify({ type: 'state', state: { recording: true } }));
|
||||
assert.deepEqual(await state, { type: 'state', state: { recording: true } });
|
||||
|
||||
// The host's binary messages are preview frames the phone pulls over HTTPS.
|
||||
host.send(Buffer.from([0xff, 0xd8, 0xff, 0xe0, 7]));
|
||||
const frame = await request(`${phoneOrigin}/api/frame?after=0`, { ca: ca(), headers: { 'X-Vcam-Key': phoneKey } });
|
||||
assert.equal(frame.status, 200);
|
||||
assert.equal(frame.headers['content-type'], 'image/jpeg');
|
||||
assert.equal(frame.headers['x-frame-seq'], '1');
|
||||
assert.equal(frame.body.at(-1), 7);
|
||||
|
||||
phone.close();
|
||||
host.close();
|
||||
});
|
||||
|
||||
test('the HTTPS fallback relays poses by POST and state by server-sent events', async (t) => {
|
||||
const { appOrigin, session, ca } = await harness(t);
|
||||
const created = await session();
|
||||
const phoneOrigin = `https://127.0.0.1:${created.port}`;
|
||||
const host = connect(
|
||||
`ws://127.0.0.1:${new URL(appOrigin).port}/api/vcam/host?key=${created.hostKey}`,
|
||||
{ origin: appOrigin },
|
||||
);
|
||||
await opened(host);
|
||||
const { phoneKey } = JSON.parse(
|
||||
(await request(`${phoneOrigin}/api/join`, { method: 'POST', ca: ca(), body: JSON.stringify({ code: created.code }) })).body,
|
||||
);
|
||||
const events = await new Promise((resolve) => {
|
||||
https.get(`${phoneOrigin}/api/events?key=${phoneKey}`, { ca: ca() }, resolve);
|
||||
});
|
||||
assert.equal(events.headers['content-type'], 'text/event-stream; charset=utf-8');
|
||||
const firstEvent = new Promise((resolve) => events.once('data', (chunk) => resolve(chunk.toString())));
|
||||
assert.match(await firstEvent, /^data: \{"type":"peer","role":"host","connected":true\}/);
|
||||
|
||||
const relayed = [];
|
||||
const collect = async () => {
|
||||
for (;;) relayed.push(await nextMessage(host));
|
||||
};
|
||||
collect();
|
||||
const sent = await request(`${phoneOrigin}/api/send`, {
|
||||
method: 'POST', ca: ca(), headers: { 'X-Vcam-Key': phoneKey },
|
||||
body: JSON.stringify([{ type: 'cmd', cmd: 'record' }, { type: 'pose', t: 2 }, 'not an object']),
|
||||
});
|
||||
assert.deepEqual(JSON.parse(sent.body), { accepted: 2 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
assert.deepEqual(relayed.filter(({ type }) => !['peer', 'code'].includes(type)), [{ type: 'cmd', cmd: 'record' }, { type: 'pose', t: 2 }]);
|
||||
|
||||
const downstream = new Promise((resolve) => events.once('data', (chunk) => resolve(chunk.toString())));
|
||||
host.send(JSON.stringify({ type: 'toast', text: 'hi' }));
|
||||
assert.equal(await downstream, 'data: {"type":"toast","text":"hi"}\n\n');
|
||||
|
||||
const ended = new Promise((resolve) => events.once('end', resolve));
|
||||
await request(`${appOrigin}/api/vcam/session`, { method: 'DELETE', headers: { Origin: appOrigin, 'X-Vcam-Host-Key': created.hostKey } });
|
||||
await ended;
|
||||
const after = await request(`${phoneOrigin}/api/send`, {
|
||||
method: 'POST', ca: ca(), headers: { 'X-Vcam-Key': phoneKey }, body: '[]',
|
||||
});
|
||||
assert.equal(after.status, 403, 'an ended session revokes the phone');
|
||||
host.close();
|
||||
});
|
||||
|
||||
test('pairing refuses foreign origins, bad codes, bad keys and cross-site sockets', async (t) => {
|
||||
const { appOrigin, session, ca } = await harness(t);
|
||||
const noOrigin = await request(`${appOrigin}/api/vcam/session`, { method: 'POST' });
|
||||
assert.equal(noOrigin.status, 403, 'a session needs the page origin');
|
||||
const foreign = await request(`${appOrigin}/api/vcam/session`, { method: 'POST', headers: { Origin: 'https://evil.example' } });
|
||||
assert.equal(foreign.status, 403);
|
||||
const proxied = await request(`${appOrigin}/api/vcam/session`, { method: 'POST', headers: { Origin: appOrigin, 'X-Forwarded-For': '1.2.3.4' } });
|
||||
assert.equal(proxied.status, 403);
|
||||
|
||||
const created = await session();
|
||||
const phoneOrigin = `https://127.0.0.1:${created.port}`;
|
||||
const badJoin = await request(`${phoneOrigin}/api/join`, { method: 'POST', ca: ca(), body: '{"code":"222222"}' });
|
||||
assert.equal(badJoin.status, 403);
|
||||
const hugeJoin = await request(`${phoneOrigin}/api/join`, { method: 'POST', ca: ca(), body: 'x'.repeat(5000) }).catch(() => ({ status: 413 }));
|
||||
assert.equal(hugeJoin.status, 413);
|
||||
const badHost = await request(`${phoneOrigin}/`, {
|
||||
ca: ca(), headers: { Host: 'rebind.example' }, checkServerIdentity: () => undefined,
|
||||
});
|
||||
assert.equal(badHost.status, 421, 'the phone listener answers IP addresses and allowed hosts only');
|
||||
|
||||
await assert.rejects(
|
||||
opened(new WebSocket(`ws://127.0.0.1:${new URL(appOrigin).port}/api/vcam/host?key=${created.hostKey}`, { origin: 'https://evil.example' })),
|
||||
/403/,
|
||||
);
|
||||
await assert.rejects(
|
||||
opened(new WebSocket(`ws://127.0.0.1:${new URL(appOrigin).port}/api/vcam/host?key=wrong`, { origin: appOrigin })),
|
||||
/403/,
|
||||
);
|
||||
const { phoneKey } = JSON.parse(
|
||||
(await request(`${phoneOrigin}/api/join`, { method: 'POST', ca: ca(), body: JSON.stringify({ code: created.code }) })).body,
|
||||
);
|
||||
await assert.rejects(
|
||||
opened(new WebSocket(`wss://127.0.0.1:${created.port}/ws?key=${phoneKey}`, { ca: ca(), origin: 'https://evil.example' })),
|
||||
/403/,
|
||||
);
|
||||
await assert.rejects(
|
||||
opened(new WebSocket(`wss://127.0.0.1:${created.port}/ws?key=wrong`, { ca: ca(), origin: phoneOrigin })),
|
||||
/403/,
|
||||
);
|
||||
});
|
||||
|
||||
test('LAN addresses prefer private ranges and skip loopback and link-local', () => {
|
||||
assert.deepEqual(
|
||||
lanAddresses({
|
||||
lo: [{ family: 'IPv4', address: '127.0.0.1', internal: true }],
|
||||
docker0: [{ family: 'IPv4', address: '172.17.0.1', internal: false }],
|
||||
en0: [
|
||||
{ family: 'IPv6', address: 'fe80::1', internal: false },
|
||||
{ family: 4, address: '192.168.1.23', internal: false },
|
||||
],
|
||||
tun0: [{ family: 'IPv4', address: '100.64.0.2', internal: false }],
|
||||
bad: [{ family: 'IPv4', address: '169.254.3.3', internal: false }],
|
||||
}),
|
||||
['192.168.1.23', '172.17.0.1', '100.64.0.2'],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* Pairing and message relay between the desktop app (host) and one phone.
|
||||
*
|
||||
* Transport-agnostic: a peer is `{ send(text), close(code, reason) }`, so the
|
||||
* WebSocket and HTTP-fallback transports share every rule here. The desktop
|
||||
* creates a session from a loopback request and receives a host key. The
|
||||
* phone proves possession of the short pairing code shown on the desktop
|
||||
* (carried in the QR code's URL fragment) and receives its own key. Too many
|
||||
* wrong codes rotate the code, so guessing cannot outlast the session.
|
||||
*
|
||||
* Messages are small JSON texts the relay forwards without interpreting,
|
||||
* bounded in size and rate. The newest preview frame (a JPEG the host sends)
|
||||
* is held for the phone to pull, so a slow phone drops frames instead of
|
||||
* queueing them.
|
||||
*/
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
// No 0/O, 1/I/L: the code is read off a screen.
|
||||
const CODE_ALPHABET = '23456789ABCDEFGHJKMNPQRSTUVWXYZ';
|
||||
export const PAIRING_CODE_LENGTH = 6;
|
||||
export const MAX_TEXT_BYTES = 4096;
|
||||
export const MAX_FRAME_BYTES = 768 * 1024;
|
||||
const MAX_CODE_FAILURES = 8;
|
||||
const MESSAGE_BURST = 240;
|
||||
const MESSAGES_PER_SECOND = 120;
|
||||
// A host that disappears (a reload, a closed tab) may come back this soon.
|
||||
const HOST_GRACE_MS = 60000;
|
||||
const MAX_SESSIONS = 4;
|
||||
|
||||
const randomKey = () => crypto.randomBytes(24).toString('base64url');
|
||||
function pairingCode(randomBytes = crypto.randomBytes) {
|
||||
const bytes = randomBytes(PAIRING_CODE_LENGTH);
|
||||
let code = '';
|
||||
for (const byte of bytes) code += CODE_ALPHABET[byte % CODE_ALPHABET.length];
|
||||
return code;
|
||||
}
|
||||
const sameSecret = (a, b) => {
|
||||
const left = Buffer.from(String(a));
|
||||
const right = Buffer.from(String(b));
|
||||
return left.length === right.length && crypto.timingSafeEqual(left, right);
|
||||
};
|
||||
const normalizeCode = (code) =>
|
||||
String(code ?? '')
|
||||
.toUpperCase()
|
||||
.replace(/[^0-9A-Z]/g, '')
|
||||
.slice(0, 32);
|
||||
|
||||
/**
|
||||
* @param {object} [options]
|
||||
* @param {() => number} [options.now]
|
||||
* @param {(session: object) => void} [options.onSessionClosed]
|
||||
*/
|
||||
export function createVcamRelay({
|
||||
now = () => Date.now(),
|
||||
onSessionClosed = () => {},
|
||||
setTimer = setTimeout,
|
||||
clearTimer = clearTimeout,
|
||||
} = {}) {
|
||||
/** @type {Map<string, object>} */
|
||||
const sessions = new Map();
|
||||
|
||||
const bucket = () => ({ tokens: MESSAGE_BURST, at: now() });
|
||||
function admit(rate) {
|
||||
const at = now();
|
||||
rate.tokens = Math.min(
|
||||
MESSAGE_BURST,
|
||||
rate.tokens + ((at - rate.at) / 1000) * MESSAGES_PER_SECOND,
|
||||
);
|
||||
rate.at = at;
|
||||
if (rate.tokens < 1) return false;
|
||||
rate.tokens -= 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
function notify(peer, message) {
|
||||
try {
|
||||
peer?.send(JSON.stringify(message));
|
||||
} catch {
|
||||
// A failed send belongs to that transport's own close handling.
|
||||
}
|
||||
}
|
||||
|
||||
function closeSession(session, reason = 'ended') {
|
||||
if (!sessions.has(session.id)) return;
|
||||
sessions.delete(session.id);
|
||||
clearTimer(session.hostTimer);
|
||||
for (const role of ['host', 'phone']) {
|
||||
const peer = session[role];
|
||||
session[role] = null;
|
||||
try {
|
||||
peer?.close(4000, reason);
|
||||
} catch {
|
||||
// Already closed.
|
||||
}
|
||||
}
|
||||
for (const waiter of session.frameWaiters.splice(0)) waiter(null);
|
||||
onSessionClosed(session);
|
||||
}
|
||||
|
||||
function scheduleHostExpiry(session) {
|
||||
clearTimer(session.hostTimer);
|
||||
session.hostTimer = setTimer(() => {
|
||||
if (!session.host) closeSession(session, 'host-gone');
|
||||
}, HOST_GRACE_MS);
|
||||
session.hostTimer?.unref?.();
|
||||
}
|
||||
|
||||
/** Create a session for the desktop; older sessions beyond the cap close. */
|
||||
function createSession() {
|
||||
while (sessions.size >= MAX_SESSIONS)
|
||||
closeSession(sessions.values().next().value, 'replaced');
|
||||
const session = {
|
||||
id: crypto.randomUUID(),
|
||||
hostKey: randomKey(),
|
||||
code: pairingCode(),
|
||||
phoneKey: null,
|
||||
failures: 0,
|
||||
host: null,
|
||||
phone: null,
|
||||
hostRate: bucket(),
|
||||
phoneRate: bucket(),
|
||||
frame: null,
|
||||
frameSeq: 0,
|
||||
frameWaiters: [],
|
||||
createdAt: now(),
|
||||
};
|
||||
sessions.set(session.id, session);
|
||||
scheduleHostExpiry(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
const sessionForHost = (hostKey) =>
|
||||
[...sessions.values()].find((s) => sameSecret(s.hostKey, hostKey)) || null;
|
||||
const sessionForPhone = (phoneKey) =>
|
||||
(phoneKey &&
|
||||
[...sessions.values()].find(
|
||||
(s) => s.phoneKey && sameSecret(s.phoneKey, phoneKey),
|
||||
)) ||
|
||||
null;
|
||||
|
||||
/**
|
||||
* Exchange a pairing code for a phone key. A new join replaces the previous
|
||||
* phone. Wrong codes count against every session; enough of them rotate
|
||||
* the codes and tell the desktop to show the new one.
|
||||
*
|
||||
* @returns {{ ok: true, phoneKey: string, sessionId: string } | { ok: false, reason: string }}
|
||||
*/
|
||||
function joinPhone(code) {
|
||||
const wanted = normalizeCode(code);
|
||||
const session =
|
||||
wanted.length === PAIRING_CODE_LENGTH
|
||||
? [...sessions.values()].find((s) => sameSecret(s.code, wanted))
|
||||
: null;
|
||||
if (!session) {
|
||||
for (const candidate of sessions.values()) {
|
||||
candidate.failures += 1;
|
||||
if (candidate.failures >= MAX_CODE_FAILURES) {
|
||||
candidate.failures = 0;
|
||||
candidate.code = pairingCode();
|
||||
notify(candidate.host, { type: 'code', code: candidate.code });
|
||||
}
|
||||
}
|
||||
return { ok: false, reason: 'invalid-code' };
|
||||
}
|
||||
session.failures = 0;
|
||||
session.phoneKey = randomKey();
|
||||
if (session.phone) {
|
||||
const previous = session.phone;
|
||||
session.phone = null;
|
||||
try {
|
||||
previous.close(4001, 'replaced');
|
||||
} catch {
|
||||
// Already closed.
|
||||
}
|
||||
}
|
||||
return { ok: true, phoneKey: session.phoneKey, sessionId: session.id };
|
||||
}
|
||||
|
||||
/** Attach a desktop transport; returns the session or null for a bad key. */
|
||||
function attachHost(hostKey, peer) {
|
||||
const session = sessionForHost(hostKey);
|
||||
if (!session) return null;
|
||||
const previous = session.host;
|
||||
session.host = peer;
|
||||
clearTimer(session.hostTimer);
|
||||
if (previous && previous !== peer) {
|
||||
try {
|
||||
previous.close(4001, 'replaced');
|
||||
} catch {
|
||||
// Already closed.
|
||||
}
|
||||
}
|
||||
notify(peer, { type: 'code', code: session.code });
|
||||
notify(peer, { type: 'peer', role: 'phone', connected: !!session.phone });
|
||||
return session;
|
||||
}
|
||||
|
||||
/** Attach a phone transport; returns the session or null for a bad key. */
|
||||
function attachPhone(phoneKey, peer) {
|
||||
const session = sessionForPhone(phoneKey);
|
||||
if (!session) return null;
|
||||
const previous = session.phone;
|
||||
session.phone = peer;
|
||||
if (previous && previous !== peer) {
|
||||
try {
|
||||
previous.close(4001, 'replaced');
|
||||
} catch {
|
||||
// Already closed.
|
||||
}
|
||||
}
|
||||
notify(session.host, { type: 'peer', role: 'phone', connected: true });
|
||||
notify(peer, { type: 'peer', role: 'host', connected: !!session.host });
|
||||
return session;
|
||||
}
|
||||
|
||||
/** Detach a transport if it is still the session's current one. */
|
||||
function detach(session, role, peer) {
|
||||
if (!session || session[role] !== peer) return;
|
||||
session[role] = null;
|
||||
const other = role === 'host' ? session.phone : session.host;
|
||||
notify(other, { type: 'peer', role, connected: false });
|
||||
if (role === 'host') scheduleHostExpiry(session);
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward one text message to the other peer. Refuses oversize, non-JSON
|
||||
* and over-rate input; the caller may close a peer that keeps sending it.
|
||||
*
|
||||
* @returns {boolean} Whether the message was admitted.
|
||||
*/
|
||||
function relay(session, role, text) {
|
||||
if (!session || !sessions.has(session.id)) return false;
|
||||
if (typeof text !== 'string' || Buffer.byteLength(text) > MAX_TEXT_BYTES)
|
||||
return false;
|
||||
if (!admit(role === 'host' ? session.hostRate : session.phoneRate))
|
||||
return false;
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const target = role === 'host' ? session.phone : session.host;
|
||||
if (target) {
|
||||
try {
|
||||
target.send(text);
|
||||
} catch {
|
||||
// The target transport owns its own close handling.
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Hold the host's newest JPEG preview frame and wake waiting phones. */
|
||||
function publishFrame(session, bytes) {
|
||||
if (!session || !sessions.has(session.id)) return false;
|
||||
if (!Buffer.isBuffer(bytes) || !bytes.length) return false;
|
||||
if (bytes.length > MAX_FRAME_BYTES) return false;
|
||||
// JPEG start-of-image marker: the phone page only ever shows JPEG.
|
||||
if (bytes[0] !== 0xff || bytes[1] !== 0xd8) return false;
|
||||
session.frame = bytes;
|
||||
session.frameSeq += 1;
|
||||
for (const waiter of session.frameWaiters.splice(0)) waiter(session);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve with the newest frame after `afterSeq`, waiting up to `waitMs`.
|
||||
* @returns {Promise<{ seq: number, bytes: Buffer } | null>}
|
||||
*/
|
||||
function nextFrame(session, afterSeq, waitMs = 2000) {
|
||||
const current = () =>
|
||||
session.frame && session.frameSeq > afterSeq
|
||||
? { seq: session.frameSeq, bytes: session.frame }
|
||||
: null;
|
||||
const ready = current();
|
||||
if (ready || waitMs <= 0) return Promise.resolve(ready);
|
||||
return new Promise((resolve) => {
|
||||
let timer = null;
|
||||
const waiter = (live) => {
|
||||
clearTimer(timer);
|
||||
resolve(live ? current() : null);
|
||||
};
|
||||
session.frameWaiters.push(waiter);
|
||||
timer = setTimer(() => {
|
||||
const index = session.frameWaiters.indexOf(waiter);
|
||||
if (index >= 0) session.frameWaiters.splice(index, 1);
|
||||
resolve(current());
|
||||
}, waitMs);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
createSession,
|
||||
joinPhone,
|
||||
attachHost,
|
||||
attachPhone,
|
||||
detach,
|
||||
relay,
|
||||
publishFrame,
|
||||
nextFrame,
|
||||
sessionForHost,
|
||||
sessionForPhone,
|
||||
closeSession,
|
||||
endSession(hostKey) {
|
||||
const session = sessionForHost(hostKey);
|
||||
if (session) closeSession(session, 'ended');
|
||||
return !!session;
|
||||
},
|
||||
get size() {
|
||||
return sessions.size;
|
||||
},
|
||||
closeAll() {
|
||||
for (const session of [...sessions.values()])
|
||||
closeSession(session, 'shutdown');
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
MAX_FRAME_BYTES,
|
||||
MAX_TEXT_BYTES,
|
||||
PAIRING_CODE_LENGTH,
|
||||
createVcamRelay,
|
||||
} from './relay.js';
|
||||
|
||||
function peer() {
|
||||
const sent = [];
|
||||
const closed = [];
|
||||
return {
|
||||
sent,
|
||||
closed,
|
||||
messages: () => sent.map((text) => JSON.parse(text)),
|
||||
send: (text) => sent.push(text),
|
||||
close: (code, reason) => closed.push([code, reason]),
|
||||
};
|
||||
}
|
||||
|
||||
function clock() {
|
||||
let at = 0;
|
||||
const timers = new Set();
|
||||
return {
|
||||
now: () => at,
|
||||
advance(ms) {
|
||||
at += ms;
|
||||
for (const timer of [...timers])
|
||||
if (timer.due <= at) {
|
||||
timers.delete(timer);
|
||||
timer.fn();
|
||||
}
|
||||
},
|
||||
setTimer: (fn, ms) => {
|
||||
const timer = { fn, due: at + ms };
|
||||
timers.add(timer);
|
||||
return timer;
|
||||
},
|
||||
clearTimer: (timer) => timers.delete(timer),
|
||||
};
|
||||
}
|
||||
|
||||
test('a phone pairs with the displayed code and relays both ways', () => {
|
||||
const relay = createVcamRelay();
|
||||
const session = relay.createSession();
|
||||
assert.equal(session.code.length, PAIRING_CODE_LENGTH);
|
||||
assert.match(session.code, /^[2-9A-HJKMNP-Z]+$/, 'no ambiguous characters');
|
||||
const host = peer();
|
||||
assert.equal(relay.attachHost(session.hostKey, host), session);
|
||||
assert.deepEqual(host.messages(), [
|
||||
{ type: 'code', code: session.code },
|
||||
{ type: 'peer', role: 'phone', connected: false },
|
||||
]);
|
||||
const joined = relay.joinPhone(` ${session.code.toLowerCase().slice(0, 3)}-${session.code.slice(3)} `);
|
||||
assert.equal(joined.ok, true, 'codes are case- and separator-insensitive');
|
||||
const phone = peer();
|
||||
assert.equal(relay.attachPhone(joined.phoneKey, phone), session);
|
||||
assert.deepEqual(host.messages().at(-1), { type: 'peer', role: 'phone', connected: true });
|
||||
assert.ok(relay.relay(session, 'phone', '{"type":"pose","q":[0,0,0,1]}'));
|
||||
assert.equal(host.sent.at(-1), '{"type":"pose","q":[0,0,0,1]}');
|
||||
assert.ok(relay.relay(session, 'host', '{"type":"state"}'));
|
||||
assert.equal(phone.sent.at(-1), '{"type":"state"}');
|
||||
relay.detach(session, 'phone', phone);
|
||||
assert.deepEqual(host.messages().at(-1), { type: 'peer', role: 'phone', connected: false });
|
||||
});
|
||||
|
||||
test('keys are per role and a new phone join replaces the previous phone', () => {
|
||||
const relay = createVcamRelay();
|
||||
const session = relay.createSession();
|
||||
assert.equal(relay.attachPhone(session.hostKey, peer()), null);
|
||||
assert.equal(relay.attachHost('nope', peer()), null);
|
||||
const first = relay.joinPhone(session.code);
|
||||
const phone = peer();
|
||||
relay.attachPhone(first.phoneKey, phone);
|
||||
const second = relay.joinPhone(session.code);
|
||||
assert.notEqual(first.phoneKey, second.phoneKey);
|
||||
assert.deepEqual(phone.closed, [[4001, 'replaced']]);
|
||||
assert.equal(relay.sessionForPhone(first.phoneKey), null, 'the old key is revoked');
|
||||
});
|
||||
|
||||
test('wrong codes rotate the pairing code and tell the desktop', () => {
|
||||
const relay = createVcamRelay();
|
||||
const session = relay.createSession();
|
||||
const host = peer();
|
||||
relay.attachHost(session.hostKey, host);
|
||||
const original = session.code;
|
||||
for (let i = 0; i < 7; i++) assert.equal(relay.joinPhone('ZZZZZZ').ok, false);
|
||||
assert.equal(session.code, original);
|
||||
relay.joinPhone('ZZZZZZ');
|
||||
assert.notEqual(session.code, original);
|
||||
assert.deepEqual(host.messages().at(-1), { type: 'code', code: session.code });
|
||||
assert.equal(relay.joinPhone(original).ok, false, 'the old code no longer pairs');
|
||||
assert.equal(relay.joinPhone(session.code).ok, true);
|
||||
});
|
||||
|
||||
test('oversize, non-object and over-rate messages are refused', () => {
|
||||
const time = clock();
|
||||
const relay = createVcamRelay(time);
|
||||
const session = relay.createSession();
|
||||
const host = peer();
|
||||
relay.attachHost(session.hostKey, host);
|
||||
const big = JSON.stringify({ type: 'x', pad: 'a'.repeat(MAX_TEXT_BYTES) });
|
||||
assert.equal(relay.relay(session, 'phone', big), false);
|
||||
assert.equal(relay.relay(session, 'phone', 'not json'), false);
|
||||
assert.equal(relay.relay(session, 'phone', '[1,2]'), false);
|
||||
let admitted = 0;
|
||||
for (let i = 0; i < 400; i++) if (relay.relay(session, 'phone', '{}')) admitted++;
|
||||
// The two malformed messages above already spent their tokens.
|
||||
assert.equal(admitted, 238, 'a burst is capped, malformed input included');
|
||||
time.advance(500);
|
||||
assert.equal(relay.relay(session, 'phone', '{}'), true, 'tokens refill over time');
|
||||
});
|
||||
|
||||
test('the newest preview frame is pulled, not queued', async () => {
|
||||
const relay = createVcamRelay();
|
||||
const session = relay.createSession();
|
||||
const jpeg = (n) => Buffer.from([0xff, 0xd8, n]);
|
||||
assert.equal(relay.publishFrame(session, Buffer.from('GIF89a')), false, 'only JPEG');
|
||||
assert.equal(relay.publishFrame(session, Buffer.alloc(MAX_FRAME_BYTES + 1, 0xff)), false);
|
||||
assert.equal(await relay.nextFrame(session, 0, 0), null);
|
||||
const waiting = relay.nextFrame(session, 0, 5000);
|
||||
relay.publishFrame(session, jpeg(1));
|
||||
relay.publishFrame(session, jpeg(2));
|
||||
const first = await waiting;
|
||||
assert.equal(first.seq, 1);
|
||||
const latest = await relay.nextFrame(session, first.seq, 0);
|
||||
assert.equal(latest.seq, 2, 'a slow phone skips straight to the newest frame');
|
||||
assert.equal(latest.bytes[2], 2);
|
||||
relay.closeSession(session);
|
||||
assert.equal(relay.publishFrame(session, jpeg(3)), false);
|
||||
});
|
||||
|
||||
test('a session outlives a desktop reload but not a departed desktop', () => {
|
||||
const time = clock();
|
||||
const closed = [];
|
||||
const relay = createVcamRelay({ ...time, onSessionClosed: (s) => closed.push(s.id) });
|
||||
const session = relay.createSession();
|
||||
const host = peer();
|
||||
relay.attachHost(session.hostKey, host);
|
||||
const phone = peer();
|
||||
relay.attachPhone(relay.joinPhone(session.code).phoneKey, phone);
|
||||
relay.detach(session, 'host', host);
|
||||
assert.deepEqual(phone.messages().at(-1), { type: 'peer', role: 'host', connected: false });
|
||||
time.advance(30000);
|
||||
relay.attachHost(session.hostKey, peer());
|
||||
time.advance(60000);
|
||||
assert.equal(relay.size, 1, 'a reattached host keeps the session');
|
||||
relay.detach(session, 'host', relay.sessionForHost(session.hostKey).host);
|
||||
time.advance(60001);
|
||||
assert.equal(relay.size, 0);
|
||||
assert.deepEqual(closed, [session.id]);
|
||||
assert.deepEqual(phone.closed, [[4000, 'host-gone']]);
|
||||
});
|
||||
|
||||
test('ending a session closes both peers and only the host key can end it', () => {
|
||||
const relay = createVcamRelay();
|
||||
const session = relay.createSession();
|
||||
const phone = peer();
|
||||
relay.attachPhone(relay.joinPhone(session.code).phoneKey, phone);
|
||||
assert.equal(relay.endSession('wrong'), false);
|
||||
assert.equal(relay.endSession(session.hostKey), true);
|
||||
assert.deepEqual(phone.closed, [[4000, 'ended']]);
|
||||
for (let i = 0; i < 6; i++) relay.createSession();
|
||||
assert.equal(relay.size, 4, 'sessions are capped');
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* Local certificate authority for the virtual-camera phone listener.
|
||||
*
|
||||
* Phones only expose camera tracking (WebXR, DeviceOrientation) to secure
|
||||
* contexts, so the LAN listener must speak HTTPS. A tapped-through
|
||||
* self-signed certificate is not enough on iOS: Safari loads the page but
|
||||
* refuses its WebSocket. Instead this module keeps one long-lived local CA,
|
||||
* which a phone can install and trust once, and issues the listener a short
|
||||
* server certificate for this machine's current LAN addresses.
|
||||
*
|
||||
* Certificates are encoded here with node:crypto (EC P-256, ECDSA-SHA256), so
|
||||
* no OpenSSL binary or dependency is needed. Keys never leave the cache
|
||||
* directory, which is git-ignored and outside the dev server's watcher.
|
||||
*/
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { isIP } from 'node:net';
|
||||
|
||||
const DAY_MS = 86400000;
|
||||
// Apple rejects user-trusted TLS server certificates valid for over 825 days.
|
||||
const SERVER_VALIDITY_DAYS = 397;
|
||||
const CA_VALIDITY_DAYS = 3650;
|
||||
// Reissue the server certificate this long before it expires.
|
||||
const RENEW_MARGIN_DAYS = 30;
|
||||
|
||||
const length = (n) =>
|
||||
n < 0x80
|
||||
? Buffer.from([n])
|
||||
: n < 0x100
|
||||
? Buffer.from([0x81, n])
|
||||
: Buffer.from([0x82, n >> 8, n & 0xff]);
|
||||
const tlv = (tag, ...parts) => {
|
||||
const body = Buffer.concat(parts);
|
||||
return Buffer.concat([Buffer.from([tag]), length(body.length), body]);
|
||||
};
|
||||
const sequence = (...parts) => tlv(0x30, ...parts);
|
||||
const set = (...parts) => tlv(0x31, ...parts);
|
||||
const explicit = (n, ...parts) => tlv(0xa0 + n, ...parts);
|
||||
const octets = (buffer) => tlv(0x04, buffer);
|
||||
const bits = (buffer, unused = 0) => tlv(0x03, Buffer.from([unused]), buffer);
|
||||
const boolean = (value) => tlv(0x01, Buffer.from([value ? 0xff : 0]));
|
||||
const utf8 = (text) => tlv(0x0c, Buffer.from(text, 'utf8'));
|
||||
function integer(buffer) {
|
||||
let bytes = Buffer.from(buffer);
|
||||
while (bytes.length > 1 && bytes[0] === 0 && !(bytes[1] & 0x80))
|
||||
bytes = bytes.subarray(1);
|
||||
if (bytes[0] & 0x80) bytes = Buffer.concat([Buffer.from([0]), bytes]);
|
||||
return tlv(0x02, bytes);
|
||||
}
|
||||
function oid(dotted) {
|
||||
const [first, second, ...rest] = dotted.split('.').map(Number);
|
||||
const bytes = [40 * first + second];
|
||||
for (const part of rest) {
|
||||
const group = [part & 0x7f];
|
||||
for (let v = part >>> 7; v; v >>>= 7) group.unshift((v & 0x7f) | 0x80);
|
||||
bytes.push(...group);
|
||||
}
|
||||
return tlv(0x06, Buffer.from(bytes));
|
||||
}
|
||||
function time(date) {
|
||||
const iso = date.toISOString().replace(/[-:T]/g, '').slice(0, 14);
|
||||
// RFC 5280: UTCTime through 2049, GeneralizedTime from 2050.
|
||||
return date.getUTCFullYear() < 2050
|
||||
? tlv(0x17, Buffer.from(`${iso.slice(2)}Z`))
|
||||
: tlv(0x18, Buffer.from(`${iso}Z`));
|
||||
}
|
||||
const name = (commonName) =>
|
||||
sequence(
|
||||
set(sequence(oid('2.5.4.10'), utf8("God's Eye View"))),
|
||||
set(sequence(oid('2.5.4.3'), utf8(commonName))),
|
||||
);
|
||||
const extension = (id, critical, value) =>
|
||||
sequence(oid(id), ...(critical ? [boolean(true)] : []), octets(value));
|
||||
const ECDSA_SHA256 = sequence(oid('1.2.840.10045.4.3.2'));
|
||||
const spkiOf = (publicKey) => publicKey.export({ type: 'spki', format: 'der' });
|
||||
// RFC 5280 method 1: SHA-1 of the subjectPublicKey bit string's contents.
|
||||
const keyIdentifier = (spki) =>
|
||||
crypto.createHash('sha1').update(spki.subarray(-65)).digest();
|
||||
|
||||
function subjectAltNames(names) {
|
||||
return sequence(
|
||||
...names.map((entry) =>
|
||||
isIP(entry) === 4
|
||||
? tlv(0x87, Buffer.from(entry.split('.').map(Number)))
|
||||
: tlv(0x82, Buffer.from(entry, 'ascii')),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function sign({ subject, issuer, publicKey, signingKey, days, extensions }) {
|
||||
const now = Date.now();
|
||||
const tbs = sequence(
|
||||
explicit(0, integer(Buffer.from([2]))),
|
||||
integer(crypto.randomBytes(16)),
|
||||
ECDSA_SHA256,
|
||||
issuer,
|
||||
sequence(time(new Date(now - DAY_MS)), time(new Date(now + days * DAY_MS))),
|
||||
subject,
|
||||
spkiOf(publicKey),
|
||||
explicit(3, sequence(...extensions)),
|
||||
);
|
||||
const signature = crypto.sign('sha256', tbs, signingKey);
|
||||
return sequence(tbs, ECDSA_SHA256, bits(signature));
|
||||
}
|
||||
|
||||
const pem = (label, der) =>
|
||||
`-----BEGIN ${label}-----\n${der
|
||||
.toString('base64')
|
||||
.match(/.{1,64}/g)
|
||||
.join('\n')}\n-----END ${label}-----\n`;
|
||||
const keyPem = (key) => key.export({ type: 'pkcs8', format: 'pem' });
|
||||
|
||||
const CA_COMMON_NAME = "God's Eye View Local VCam CA";
|
||||
|
||||
/** Create a CA that may only sign server certificates (path length 0). */
|
||||
export function createCertificateAuthority(commonName = CA_COMMON_NAME) {
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync('ec', {
|
||||
namedCurve: 'P-256',
|
||||
});
|
||||
const subject = name(commonName);
|
||||
const der = sign({
|
||||
subject,
|
||||
issuer: subject,
|
||||
publicKey,
|
||||
signingKey: privateKey,
|
||||
days: CA_VALIDITY_DAYS,
|
||||
extensions: [
|
||||
extension(
|
||||
'2.5.29.19',
|
||||
true,
|
||||
sequence(boolean(true), integer(Buffer.from([0]))),
|
||||
),
|
||||
// keyCertSign and cRLSign.
|
||||
extension('2.5.29.15', true, bits(Buffer.from([0x06]), 1)),
|
||||
extension('2.5.29.14', false, octets(keyIdentifier(spkiOf(publicKey)))),
|
||||
],
|
||||
});
|
||||
return { cert: pem('CERTIFICATE', der), key: keyPem(privateKey) };
|
||||
}
|
||||
|
||||
/** Issue a TLS server certificate for the given IP addresses and DNS names. */
|
||||
export function issueServerCertificate(authority, names) {
|
||||
const caKey = crypto.createPrivateKey(authority.key);
|
||||
const caCert = new crypto.X509Certificate(authority.cert);
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync('ec', {
|
||||
namedCurve: 'P-256',
|
||||
});
|
||||
const caSpki = spkiOf(caCert.publicKey);
|
||||
const der = sign({
|
||||
subject: name(names[0]),
|
||||
issuer: name(caCert.subject.match(/CN=([^\n]+)/)?.[1] ?? CA_COMMON_NAME),
|
||||
publicKey,
|
||||
signingKey: caKey,
|
||||
days: SERVER_VALIDITY_DAYS,
|
||||
extensions: [
|
||||
extension('2.5.29.19', true, sequence()),
|
||||
// digitalSignature.
|
||||
extension('2.5.29.15', true, bits(Buffer.from([0x80]), 7)),
|
||||
extension('2.5.29.37', false, sequence(oid('1.3.6.1.5.5.7.3.1'))),
|
||||
extension('2.5.29.17', false, subjectAltNames(names)),
|
||||
extension('2.5.29.35', false, sequence(tlv(0x80, keyIdentifier(caSpki)))),
|
||||
],
|
||||
});
|
||||
return { cert: pem('CERTIFICATE', der), key: keyPem(privateKey) };
|
||||
}
|
||||
|
||||
function readPair(directory, base) {
|
||||
try {
|
||||
return {
|
||||
cert: fs.readFileSync(path.join(directory, `${base}.crt`), 'utf8'),
|
||||
key: fs.readFileSync(path.join(directory, `${base}.key`), 'utf8'),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writePair(directory, base, pair) {
|
||||
fs.writeFileSync(path.join(directory, `${base}.key`), pair.key, {
|
||||
mode: 0o600,
|
||||
});
|
||||
fs.writeFileSync(path.join(directory, `${base}.crt`), pair.cert);
|
||||
}
|
||||
|
||||
function serverCertificateCovers(pair, authority, names) {
|
||||
try {
|
||||
const cert = new crypto.X509Certificate(pair.cert);
|
||||
if (!cert.verify(new crypto.X509Certificate(authority.cert).publicKey))
|
||||
return false;
|
||||
if (Date.parse(cert.validTo) - Date.now() < RENEW_MARGIN_DAYS * DAY_MS)
|
||||
return false;
|
||||
const listed = new Set(
|
||||
String(cert.subjectAltName || '')
|
||||
.split(',')
|
||||
.map((entry) => entry.trim().replace(/^(DNS|IP Address):/, '')),
|
||||
);
|
||||
return names.every((entry) => listed.has(entry));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load or create the cached CA and a server certificate covering `names`.
|
||||
* The CA survives restarts so a phone's one-time trust keeps working; the
|
||||
* server certificate is reissued when an address changes or it nears expiry.
|
||||
*
|
||||
* @param {string} directory - Cache directory, created with owner-only access.
|
||||
* @param {string[]} names - IPv4 addresses and DNS names; the first is the subject.
|
||||
* @returns {{ ca: string, cert: string, key: string, caFingerprint: string }}
|
||||
*/
|
||||
export function loadLocalCertificates(directory, names) {
|
||||
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
||||
let authority = readPair(directory, 'ca');
|
||||
try {
|
||||
if (authority && !new crypto.X509Certificate(authority.cert).ca)
|
||||
authority = null;
|
||||
} catch {
|
||||
authority = null;
|
||||
}
|
||||
if (!authority) {
|
||||
authority = createCertificateAuthority();
|
||||
writePair(directory, 'ca', authority);
|
||||
}
|
||||
let server = readPair(directory, 'server');
|
||||
if (!server || !serverCertificateCovers(server, authority, names)) {
|
||||
server = issueServerCertificate(authority, names);
|
||||
writePair(directory, 'server', server);
|
||||
}
|
||||
return {
|
||||
ca: authority.cert,
|
||||
cert: server.cert,
|
||||
key: server.key,
|
||||
caFingerprint: new crypto.X509Certificate(authority.cert).fingerprint256,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user