viveworker 0.7.0-beta.3 → 0.8.0
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.
- package/README.md +33 -4
- package/package.json +7 -3
- package/scripts/lib/remote-pairing/README.md +164 -0
- package/scripts/lib/remote-pairing/_browser-bundle-entry.mjs +86 -0
- package/scripts/lib/remote-pairing/audit.mjs +122 -0
- package/scripts/lib/remote-pairing/bridge-relay-client.mjs +737 -0
- package/scripts/lib/remote-pairing/control.mjs +156 -0
- package/scripts/lib/remote-pairing/envelope.mjs +224 -0
- package/scripts/lib/remote-pairing/http-dispatch.mjs +530 -0
- package/scripts/lib/remote-pairing/keys-core.mjs +110 -0
- package/scripts/lib/remote-pairing/keys.mjs +181 -0
- package/scripts/lib/remote-pairing/noise.mjs +436 -0
- package/scripts/lib/remote-pairing/orchestrator.mjs +356 -0
- package/scripts/lib/remote-pairing/pairings.mjs +446 -0
- package/scripts/lib/remote-pairing/rpc.mjs +381 -0
- package/scripts/moltbook-scout-auto.sh +16 -8
- package/scripts/share-cli.mjs +14 -130
- package/scripts/viveworker-bridge.mjs +1696 -223
- package/scripts/viveworker.mjs +27 -6
- package/web/app.css +727 -9
- package/web/app.js +1810 -232
- package/web/i18n.js +207 -17
- package/web/index.html +115 -1
- package/web/remote-pairing/api-router.js +873 -0
- package/web/remote-pairing/keys.js +237 -0
- package/web/remote-pairing/pairing-state.js +313 -0
- package/web/remote-pairing/rpc-client.js +765 -0
- package/web/remote-pairing/transport.js +804 -0
- package/web/remote-pairing/wake.js +149 -0
- package/web/remote-pairing-test.html +400 -0
- package/web/remote-pairing.bundle.js +3 -0
- package/web/remote-pairing.bundle.js.LEGAL.txt +15 -0
- package/web/remote-pairing.bundle.js.map +7 -0
- package/web/sw.js +190 -20
|
@@ -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>
|