viveworker 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/README.md +115 -4
  3. package/package.json +13 -3
  4. package/plugins/viveworker-control-plane/.codex-plugin/plugin.json +49 -0
  5. package/plugins/viveworker-control-plane/.mcp.json +11 -0
  6. package/plugins/viveworker-control-plane/assets/viveworker-logo-v2.png +0 -0
  7. package/plugins/viveworker-control-plane/assets/viveworker-logo-v2.svg +39 -0
  8. package/plugins/viveworker-control-plane/skills/viveworker-control-plane/SKILL.md +83 -0
  9. package/scripts/lib/markdown-render.mjs +128 -1
  10. package/scripts/lib/remote-pairing/README.md +164 -0
  11. package/scripts/lib/remote-pairing/_browser-bundle-entry.mjs +86 -0
  12. package/scripts/lib/remote-pairing/audit.mjs +122 -0
  13. package/scripts/lib/remote-pairing/bridge-relay-client.mjs +737 -0
  14. package/scripts/lib/remote-pairing/control.mjs +156 -0
  15. package/scripts/lib/remote-pairing/envelope.mjs +224 -0
  16. package/scripts/lib/remote-pairing/http-dispatch.mjs +530 -0
  17. package/scripts/lib/remote-pairing/keys-core.mjs +110 -0
  18. package/scripts/lib/remote-pairing/keys.mjs +181 -0
  19. package/scripts/lib/remote-pairing/noise.mjs +436 -0
  20. package/scripts/lib/remote-pairing/orchestrator.mjs +356 -0
  21. package/scripts/lib/remote-pairing/pairings.mjs +446 -0
  22. package/scripts/lib/remote-pairing/rpc.mjs +381 -0
  23. package/scripts/mcp-server.mjs +891 -0
  24. package/scripts/moltbook-scout-auto.sh +16 -8
  25. package/scripts/share-cli.mjs +14 -130
  26. package/scripts/stats-cli.mjs +683 -0
  27. package/scripts/viveworker-bridge.mjs +1676 -127
  28. package/scripts/viveworker.mjs +289 -7
  29. package/skills/viveworker-control-plane/SKILL.md +104 -0
  30. package/skills/viveworker-control-plane/agents/openai.yaml +12 -0
  31. package/templates/CLAUDE.viveworker.md +67 -0
  32. package/web/app.css +683 -9
  33. package/web/app.js +1780 -187
  34. package/web/build-id.js +1 -0
  35. package/web/i18n.js +187 -9
  36. package/web/icons/apple-touch-icon.png +0 -0
  37. package/web/icons/viveworker-icon-192.png +0 -0
  38. package/web/icons/viveworker-icon-512.png +0 -0
  39. package/web/icons/viveworker-v-pulse.svg +16 -1
  40. package/web/index.html +32 -2
  41. package/web/remote-pairing/api-router.js +873 -0
  42. package/web/remote-pairing/keys.js +237 -0
  43. package/web/remote-pairing/pairing-state.js +313 -0
  44. package/web/remote-pairing/rpc-client.js +765 -0
  45. package/web/remote-pairing/transport.js +804 -0
  46. package/web/remote-pairing/wake.js +149 -0
  47. package/web/remote-pairing-test.html +400 -0
  48. package/web/remote-pairing.bundle.js +3 -0
  49. package/web/remote-pairing.bundle.js.LEGAL.txt +15 -0
  50. package/web/remote-pairing.bundle.js.map +7 -0
  51. package/web/sw.js +190 -20
  52. package/web/icons/viveworker-beacon-v.svg +0 -19
  53. package/web/icons/viveworker-icon-1024.png +0 -0
  54. package/web/icons/viveworker-v-check.svg +0 -19
@@ -0,0 +1,149 @@
1
+ /**
2
+ * web/remote-pairing/wake.js — Browser wake-up bindings for the transport.
3
+ *
4
+ * The browser tab is the only thing in this stack that decides "the user
5
+ * just looked at the app, reconnect now". The transport class itself only
6
+ * exposes `kick()` — a no-op when the connection is healthy and a backoff-
7
+ * resetting reconnect when it isn't. This module wires that up to the
8
+ * platform events that signal "user is back / network is back / something
9
+ * pushed the SW":
10
+ *
11
+ * - document.visibilitychange → "visible" (tab/PWA foregrounded)
12
+ * - window.online (cellular handoff finished, etc.)
13
+ * - window.focus (desktop browsers that don't
14
+ * fire visibilitychange on tab switch)
15
+ * - window.pageshow w/ persisted=true (iOS BFCache restore)
16
+ * - navigator.serviceWorker.message of type (Web Push arrived in the
17
+ * "remote-pairing-wake" background; SW broadcasts to
18
+ * any open client)
19
+ *
20
+ * Call `bindWakeEvents(transport)` AFTER `transport.connect()` — the kick()
21
+ * has a `_started` guard so calling it before the application opted into
22
+ * the transport is a no-op, but explicitly binding after connect() also
23
+ * makes the lifetime crystal clear.
24
+ *
25
+ * `kick()` is idempotent and handles all the in-flight states (OPENING,
26
+ * HANDSHAKING, RESUMING, CONNECTED) by doing nothing — only DISCONNECTED
27
+ * with a queued reconnect actually fires off a new attempt. So multiple
28
+ * wake events arriving in quick succession (visibilitychange + focus on
29
+ * tab switch, online + pageshow on iOS resume) are safe.
30
+ */
31
+
32
+ /**
33
+ * @typedef {Object} BindWakeOptions
34
+ * @property {Document} [doc] override globalThis.document (for tests)
35
+ * @property {Window} [win] override globalThis.window (for tests)
36
+ * @property {ServiceWorkerContainer} [sw]
37
+ * override globalThis.navigator?.serviceWorker
38
+ * @property {Iterable<string>} [swMessageTypes]
39
+ * SW message `type` values that count as
40
+ * wake hints. Defaults to
41
+ * ["remote-pairing-wake"]
42
+ * @property {(reason: string, info?: object) => void} [onWake]
43
+ * called before each kick() — for logs /
44
+ * telemetry. Errors are swallowed.
45
+ */
46
+
47
+ /**
48
+ * Bind platform wake events to `transport.kick()`.
49
+ *
50
+ * @param {{ kick(): void }} transport
51
+ * @param {BindWakeOptions} [opts]
52
+ * @returns {() => void} unbind function — idempotent; safe to call multiple times
53
+ */
54
+ export function bindWakeEvents(transport, opts = {}) {
55
+ if (!transport || typeof transport.kick !== "function") {
56
+ throw new TypeError("transport with .kick() required");
57
+ }
58
+
59
+ const doc = opts.doc ?? globalThis.document ?? null;
60
+ const win = opts.win ?? globalThis.window ?? null;
61
+ const sw = opts.sw ?? globalThis.navigator?.serviceWorker ?? null;
62
+ const onWake = typeof opts.onWake === "function" ? opts.onWake : null;
63
+ const swMessageTypes = new Set(
64
+ opts.swMessageTypes ? Array.from(opts.swMessageTypes) : ["remote-pairing-wake"],
65
+ );
66
+
67
+ /** @type {Array<() => void>} */
68
+ const teardown = [];
69
+
70
+ const fireKick = (reason, info) => {
71
+ if (onWake) {
72
+ try { onWake(reason, info); } catch { /* swallow — never block kick on a logger */ }
73
+ }
74
+ try { transport.kick(); } catch { /* kick should never throw, but be defensive */ }
75
+ };
76
+
77
+ // --------------------------------------------------------------------------
78
+ // visibilitychange (mobile + desktop) — fires on tab switch, foreground/
79
+ // background, OS multitasking. We only kick on the visible side.
80
+ // --------------------------------------------------------------------------
81
+ if (doc?.addEventListener) {
82
+ const onVis = () => {
83
+ if (doc.visibilityState === "visible") fireKick("visibilitychange");
84
+ };
85
+ doc.addEventListener("visibilitychange", onVis);
86
+ teardown.push(() => doc.removeEventListener("visibilitychange", onVis));
87
+ }
88
+
89
+ if (win?.addEventListener) {
90
+ // ------------------------------------------------------------------------
91
+ // online — fires when navigator.onLine flips false→true. Common during
92
+ // cellular handoffs, Wi-Fi reconnects, and tunnel reconnects.
93
+ // ------------------------------------------------------------------------
94
+ const onOnline = () => fireKick("online");
95
+ win.addEventListener("online", onOnline);
96
+ teardown.push(() => win.removeEventListener("online", onOnline));
97
+
98
+ // ------------------------------------------------------------------------
99
+ // focus — desktop browsers don't always fire visibilitychange on tab
100
+ // switch (Safari at minimum). Belt+suspenders.
101
+ // ------------------------------------------------------------------------
102
+ const onFocus = () => fireKick("focus");
103
+ win.addEventListener("focus", onFocus);
104
+ teardown.push(() => win.removeEventListener("focus", onFocus));
105
+
106
+ // ------------------------------------------------------------------------
107
+ // pageshow with persisted=true — fires on BFCache restore (iOS Safari is
108
+ // the common case: open PWA, swipe-back from another app, the page is
109
+ // restored without a fresh navigation, so `online` may not fire even
110
+ // though the WS was killed by the OS during background).
111
+ // ------------------------------------------------------------------------
112
+ const onPageshow = (ev) => {
113
+ if (ev?.persisted) fireKick("pageshow-bfcache");
114
+ };
115
+ win.addEventListener("pageshow", onPageshow);
116
+ teardown.push(() => win.removeEventListener("pageshow", onPageshow));
117
+ }
118
+
119
+ // --------------------------------------------------------------------------
120
+ // SW broadcast — when sw.js receives a Web Push tagged for remote-pairing
121
+ // (or whatever caller-defined types), it postMessages to all open clients.
122
+ // We listen here so a background Web Push (PWA in another window or just
123
+ // out-of-focus) wakes the transport without the user touching the screen.
124
+ // --------------------------------------------------------------------------
125
+ if (sw?.addEventListener) {
126
+ const onSWMessage = (ev) => {
127
+ const data = ev?.data;
128
+ const type = data?.type;
129
+ if (typeof type === "string" && swMessageTypes.has(type)) {
130
+ fireKick("sw-message", { type, data });
131
+ }
132
+ };
133
+ sw.addEventListener("message", onSWMessage);
134
+ teardown.push(() => sw.removeEventListener("message", onSWMessage));
135
+ }
136
+
137
+ // --------------------------------------------------------------------------
138
+ // Return unbind. Idempotent — calling twice is safe.
139
+ // --------------------------------------------------------------------------
140
+ let unbound = false;
141
+ return () => {
142
+ if (unbound) return;
143
+ unbound = true;
144
+ for (const fn of teardown) {
145
+ try { fn(); } catch { /* unbinding errors are harmless */ }
146
+ }
147
+ teardown.length = 0;
148
+ };
149
+ }
@@ -0,0 +1,400 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="robots" content="noindex,nofollow">
7
+ <title>viveworker · remote-pairing test</title>
8
+ <style>
9
+ /* Minimal styling. The intent is to be readable in any browser without
10
+ depending on the main app shell. */
11
+ :root { color-scheme: light dark; }
12
+ body {
13
+ font: 14px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
14
+ margin: 0;
15
+ padding: 1rem;
16
+ max-width: 1200px;
17
+ margin: 0 auto;
18
+ }
19
+ h1 { font-size: 1.4rem; margin: 0 0 0.5rem; }
20
+ h2 { font-size: 1rem; margin: 0 0 0.5rem; }
21
+ fieldset { border: 1px solid #888; border-radius: 6px; padding: 0.75rem; margin-bottom: 0.75rem; }
22
+ fieldset legend { padding: 0 0.4rem; font-weight: 600; }
23
+ label { display: inline-flex; flex-direction: column; gap: 0.15rem; margin-right: 1rem; }
24
+ input[type="text"], input[type="url"] {
25
+ font: inherit;
26
+ padding: 0.3rem 0.5rem;
27
+ border: 1px solid #888;
28
+ border-radius: 4px;
29
+ min-width: 220px;
30
+ }
31
+ button {
32
+ font: inherit;
33
+ padding: 0.3rem 0.8rem;
34
+ border: 1px solid #888;
35
+ border-radius: 4px;
36
+ cursor: pointer;
37
+ background: transparent;
38
+ }
39
+ button:disabled { opacity: 0.4; cursor: not-allowed; }
40
+
41
+ .grid {
42
+ display: grid;
43
+ grid-template-columns: repeat(2, 1fr);
44
+ gap: 0.75rem;
45
+ }
46
+ @media (max-width: 720px) { .grid { grid-template-columns: 1fr; } }
47
+
48
+ .peer {
49
+ border: 1px solid #888;
50
+ border-radius: 6px;
51
+ padding: 0.75rem;
52
+ }
53
+ .peer .row { display: flex; gap: 0.5rem; align-items: center; margin: 0.4rem 0; }
54
+ .peer ul { padding-left: 1.2rem; max-height: 200px; overflow-y: auto; margin: 0.4rem 0 0; }
55
+ .state-disconnected { color: #888; }
56
+ .state-opening, .state-handshaking, .state-resuming { color: #c80; }
57
+ .state-connected { color: #080; font-weight: 600; }
58
+ .state-failed { color: #c00; font-weight: 600; }
59
+
60
+ code, pre { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 12px; }
61
+ pre#log {
62
+ background: #f5f5f5;
63
+ border: 1px solid #ccc;
64
+ border-radius: 4px;
65
+ padding: 0.5rem;
66
+ max-height: 360px;
67
+ overflow-y: auto;
68
+ white-space: pre-wrap;
69
+ }
70
+ @media (prefers-color-scheme: dark) {
71
+ pre#log { background: #1c1c1c; border-color: #333; }
72
+ }
73
+ </style>
74
+ </head>
75
+ <body>
76
+ <h1>viveworker remote-pairing — Phase 2 browser smoke test</h1>
77
+
78
+ <details style="margin-bottom: 0.75rem;">
79
+ <summary>How to run</summary>
80
+ <ol>
81
+ <li>Start the relay in one terminal:
82
+ <pre><code>cd worker-pairing && npx wrangler dev --local --port 8801</code></pre>
83
+ </li>
84
+ <li>Serve the <code>web/</code> directory in another terminal:
85
+ <pre><code>cd web && npx http-server -p 8000 -c-1</code></pre>
86
+ (any static server works; <code>-c-1</code> disables caching so edits show up immediately)
87
+ </li>
88
+ <li>Open <code>http://127.0.0.1:8000/remote-pairing-test.html</code> in your browser.</li>
89
+ <li>Click <strong>Start</strong>. Both peers should reach <code>connected</code> with matching channel bindings.</li>
90
+ <li>Type messages into either peer's input and click Send — the other peer's "Received" list updates.</li>
91
+ <li>Click <strong>Force-disconnect</strong> on a peer to simulate a network blip; you should see it transition through <code>disconnected → opening → resuming → connected</code> and the encrypted channel survives.</li>
92
+ </ol>
93
+ <p>
94
+ This page intentionally lives outside the main PWA shell — it loads only the
95
+ remote-pairing modules, so any failure isolates to the new code.
96
+ </p>
97
+ </details>
98
+
99
+ <fieldset>
100
+ <legend>Configuration</legend>
101
+ <label>Relay URL <input id="relay" type="url" value="ws://127.0.0.1:8801"></label>
102
+ <label>Pairing ID <input id="pairing" type="text"></label>
103
+ <label>Relay token <input id="relay-token" type="text" placeholder="auto-generated on Start"></label>
104
+ <button id="start">Start</button>
105
+ <button id="stop" disabled>Stop</button>
106
+ <button id="reset">Reset</button>
107
+ </fieldset>
108
+
109
+ <div class="grid">
110
+ <section class="peer" data-peer="phone">
111
+ <h2>Phone (initiator)</h2>
112
+ <div class="row">State: <span class="state state-disconnected" id="phone-state">disconnected</span></div>
113
+ <div class="row">Identity fingerprint: <code id="phone-fp">—</code></div>
114
+ <div class="row">Channel binding: <code id="phone-binding">—</code></div>
115
+ <div class="row">Outbound seq: <code id="phone-seq">0</code></div>
116
+ <div class="row">
117
+ <input id="phone-input" type="text" placeholder="message to send" disabled>
118
+ <button id="phone-send" disabled>Send</button>
119
+ <button id="phone-disconnect" disabled>Force-disconnect</button>
120
+ </div>
121
+ <h3 style="font-size: 0.85rem; margin: 0.4rem 0 0;">Received</h3>
122
+ <ul id="phone-recv"></ul>
123
+ </section>
124
+
125
+ <section class="peer" data-peer="bridge">
126
+ <h2>Bridge (responder)</h2>
127
+ <div class="row">State: <span class="state state-disconnected" id="bridge-state">disconnected</span></div>
128
+ <div class="row">Identity fingerprint: <code id="bridge-fp">—</code></div>
129
+ <div class="row">Channel binding: <code id="bridge-binding">—</code></div>
130
+ <div class="row">Outbound seq: <code id="bridge-seq">0</code></div>
131
+ <div class="row">
132
+ <input id="bridge-input" type="text" placeholder="message to send" disabled>
133
+ <button id="bridge-send" disabled>Send</button>
134
+ <button id="bridge-disconnect" disabled>Force-disconnect</button>
135
+ </div>
136
+ <h3 style="font-size: 0.85rem; margin: 0.4rem 0 0;">Received</h3>
137
+ <ul id="bridge-recv"></ul>
138
+ </section>
139
+ </div>
140
+
141
+ <h2 style="margin-top: 1rem;">Logs</h2>
142
+ <pre id="log"></pre>
143
+
144
+ <!-- Run as ES module — no build step. The bundle is checked into git;
145
+ transport.js / wake.js / keys.js are vanilla ESM. -->
146
+ <script type="module">
147
+ import {
148
+ generateIdentityKeypair,
149
+ fingerprintIdentity,
150
+ bytesToHex,
151
+ } from "/remote-pairing.bundle.js";
152
+ import { RemotePairingTransport, STATE } from "/remote-pairing/transport.js";
153
+ import { bindWakeEvents } from "/remote-pairing/wake.js";
154
+
155
+ // -------------------------------------------------------------------------
156
+ // DOM helpers
157
+ // -------------------------------------------------------------------------
158
+
159
+ const $ = (id) => document.getElementById(id);
160
+ const logEl = $("log");
161
+
162
+ function log(line) {
163
+ const stamp = new Date().toLocaleTimeString("en-US", { hour12: false });
164
+ logEl.textContent += `[${stamp}] ${line}\n`;
165
+ logEl.scrollTop = logEl.scrollHeight;
166
+ }
167
+
168
+ function setState(name, state) {
169
+ const el = $(`${name}-state`);
170
+ el.textContent = state;
171
+ el.className = `state state-${state}`;
172
+ }
173
+
174
+ function setBinding(name, bytes) {
175
+ $(`${name}-binding`).textContent = bytes
176
+ ? bytesToHex(bytes).slice(0, 32) + "…"
177
+ : "—";
178
+ }
179
+
180
+ function setFingerprint(name, pub) {
181
+ $(`${name}-fp`).textContent = fingerprintIdentity(pub);
182
+ }
183
+
184
+ function appendRecv(name, text) {
185
+ const li = document.createElement("li");
186
+ const stamp = new Date().toLocaleTimeString("en-US", { hour12: false });
187
+ li.textContent = `[${stamp}] ${text}`;
188
+ $(`${name}-recv`).appendChild(li);
189
+ }
190
+
191
+ // -------------------------------------------------------------------------
192
+ // Default pairing ID picks something distinct per page-load so reload
193
+ // doesn't get mixed up with a previous session's outbox.
194
+ // -------------------------------------------------------------------------
195
+
196
+ $("pairing").value = `browser-test-${Math.random().toString(36).slice(2, 8)}-${Date.now().toString(36)}`;
197
+
198
+ // -------------------------------------------------------------------------
199
+ // Per-peer wiring
200
+ // -------------------------------------------------------------------------
201
+
202
+ /**
203
+ * @param {"phone" | "bridge"} role
204
+ * @param {object} opts full RemotePairingTransport options minus callbacks
205
+ * @returns {{ transport: RemotePairingTransport, unbindWake: () => void }}
206
+ */
207
+ function buildPeer(role, opts) {
208
+ const transport = new RemotePairingTransport({
209
+ ...opts,
210
+ onMessage: (pt) => {
211
+ const text = new TextDecoder().decode(pt);
212
+ appendRecv(role, text);
213
+ log(`${role} ← "${text}"`);
214
+ },
215
+ onStateChange: (next, prev, info) => {
216
+ setState(role, next);
217
+ log(`${role} ${prev} → ${next}${info?.resumed ? " (resumed)" : ""}`);
218
+ // Refresh seq display on every state change (cheap, no extra hooks).
219
+ $(`${role}-seq`).textContent = String(transport._outboundSeq);
220
+ // Enable / disable controls based on state.
221
+ const connected = next === STATE.CONNECTED;
222
+ $(`${role}-input`).disabled = !connected;
223
+ $(`${role}-send`).disabled = !connected;
224
+ $(`${role}-disconnect`).disabled = !connected;
225
+ },
226
+ onError: (err) => {
227
+ log(`${role} ERROR: ${err?.message ?? err}`);
228
+ },
229
+ onHandshakeComplete: ({ channelBinding }) => {
230
+ setBinding(role, channelBinding);
231
+ },
232
+ });
233
+
234
+ $(`${role}-send`).onclick = () => {
235
+ const input = $(`${role}-input`);
236
+ const text = input.value;
237
+ if (!text) return;
238
+ try {
239
+ transport.send(new TextEncoder().encode(text));
240
+ log(`${role} → "${text}"`);
241
+ input.value = "";
242
+ } catch (err) {
243
+ log(`${role} send error: ${err.message}`);
244
+ }
245
+ };
246
+
247
+ $(`${role}-disconnect`).onclick = () => {
248
+ // Reach into the WS to simulate a network drop without telling the
249
+ // transport. This is the path we want to exercise: relay closes our
250
+ // WS, we reconnect with RESUME_REQ, get RESUME_OK, channel survives.
251
+ try {
252
+ transport._ws?.close(1006, "test-disconnect");
253
+ log(`${role} forced WS close — expect resume cycle`);
254
+ } catch (err) {
255
+ log(`${role} forced disconnect failed: ${err.message}`);
256
+ }
257
+ };
258
+
259
+ // Phase 2d: wire up wake events too, just so they're exercised in this
260
+ // page even though there's no SW push or visibility change firing
261
+ // during a one-tab demo. The unbind goes into our reset path.
262
+ const unbindWake = bindWakeEvents(transport, {
263
+ onWake: (reason) => log(`${role} wake reason=${reason}`),
264
+ });
265
+
266
+ return { transport, unbindWake };
267
+ }
268
+
269
+ // -------------------------------------------------------------------------
270
+ // Top-level controls
271
+ // -------------------------------------------------------------------------
272
+
273
+ /** @type {{transport: RemotePairingTransport, unbindWake: () => void} | null} */
274
+ let phone = null;
275
+ /** @type {{transport: RemotePairingTransport, unbindWake: () => void} | null} */
276
+ let bridge = null;
277
+
278
+ $("start").onclick = async () => {
279
+ $("start").disabled = true;
280
+ try {
281
+ const relayUrl = $("relay").value.trim();
282
+ const pairingId = $("pairing").value.trim();
283
+ let relayToken = $("relay-token").value.trim();
284
+ if (!relayUrl) throw new Error("relay URL required");
285
+ if (!pairingId) throw new Error("pairing id required");
286
+ if (!relayToken) {
287
+ relayToken = await generateRelayToken(pairingId);
288
+ $("relay-token").value = relayToken;
289
+ }
290
+
291
+ // For the smoke test we generate fresh keypairs in the same tab. In
292
+ // production the phone has the bridge's pubkey from LAN bootstrap.
293
+ const phoneKeys = generateIdentityKeypair();
294
+ const bridgeKeys = generateIdentityKeypair();
295
+ setFingerprint("phone", phoneKeys.pub);
296
+ setFingerprint("bridge", bridgeKeys.pub);
297
+
298
+ phone = buildPeer("phone", {
299
+ relayUrl,
300
+ pairingId,
301
+ relayToken,
302
+ role: "phone",
303
+ identityKeypair: phoneKeys,
304
+ remoteStatic: bridgeKeys.pub,
305
+ });
306
+ bridge = buildPeer("bridge", {
307
+ relayUrl,
308
+ pairingId,
309
+ relayToken,
310
+ role: "bridge",
311
+ identityKeypair: bridgeKeys,
312
+ });
313
+
314
+ $("stop").disabled = false;
315
+
316
+ log("connecting both peers…");
317
+ await Promise.all([phone.transport.connect(), bridge.transport.connect()]);
318
+ log("both peers connected");
319
+ } catch (err) {
320
+ log(`start failed: ${err?.message ?? err}`);
321
+ $("start").disabled = false;
322
+ }
323
+ };
324
+
325
+ $("stop").onclick = () => {
326
+ try { phone?.unbindWake(); } catch {}
327
+ try { bridge?.unbindWake(); } catch {}
328
+ try { phone?.transport.close(); } catch {}
329
+ try { bridge?.transport.close(); } catch {}
330
+ phone = null;
331
+ bridge = null;
332
+ $("stop").disabled = true;
333
+ $("start").disabled = false;
334
+ log("stopped");
335
+ };
336
+
337
+ $("reset").onclick = () => {
338
+ $("stop").click();
339
+ logEl.textContent = "";
340
+ for (const role of ["phone", "bridge"]) {
341
+ setBinding(role, null);
342
+ $(`${role}-fp`).textContent = "—";
343
+ $(`${role}-seq`).textContent = "0";
344
+ $(`${role}-recv`).textContent = "";
345
+ }
346
+ // Roll a new pairing ID so a stale outbox can't influence the next run.
347
+ $("pairing").value = `browser-test-${Math.random().toString(36).slice(2, 8)}-${Date.now().toString(36)}`;
348
+ $("relay-token").value = "";
349
+ log("reset");
350
+ };
351
+
352
+ async function generateRelayToken(pairingId) {
353
+ const bytes = new Uint8Array(24);
354
+ crypto.getRandomValues(bytes);
355
+ const nonce = base64url(bytes);
356
+ let counter = crypto.getRandomValues(new Uint32Array(1))[0];
357
+ for (;;) {
358
+ const token = `v1.${nonce}.${counter.toString(36)}`;
359
+ const digest = new Uint8Array(await crypto.subtle.digest(
360
+ "SHA-256",
361
+ new TextEncoder().encode(`viveworker-remote-pairing-relay-token:${pairingId}:${token}`),
362
+ ));
363
+ if (countLeadingZeroBits(digest) >= 16) return token;
364
+ counter = (counter + 1) >>> 0;
365
+ }
366
+ }
367
+
368
+ function countLeadingZeroBits(bytes) {
369
+ let bits = 0;
370
+ for (const b of bytes) {
371
+ if (b === 0) {
372
+ bits += 8;
373
+ continue;
374
+ }
375
+ for (let i = 7; i >= 0; i--) {
376
+ if ((b & (1 << i)) !== 0) return bits;
377
+ bits += 1;
378
+ }
379
+ }
380
+ return bits;
381
+ }
382
+
383
+ function base64url(bytes) {
384
+ let bin = "";
385
+ for (const b of bytes) bin += String.fromCharCode(b);
386
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/u, "");
387
+ }
388
+
389
+ // Surface uncaught errors so a silent crash doesn't leave you wondering.
390
+ window.addEventListener("error", (ev) => {
391
+ log(`uncaught: ${ev?.error?.message ?? ev?.message ?? ev}`);
392
+ });
393
+ window.addEventListener("unhandledrejection", (ev) => {
394
+ log(`unhandled rejection: ${ev?.reason?.message ?? ev?.reason ?? "?"}`);
395
+ });
396
+
397
+ log("page loaded — configure relay URL + pairing ID, then click Start");
398
+ </script>
399
+ </body>
400
+ </html>