viveworker 0.7.0 → 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 +1324 -103
- package/scripts/viveworker.mjs +27 -6
- package/web/app.css +634 -9
- package/web/app.js +1731 -187
- package/web/i18n.js +187 -9
- package/web/index.html +32 -2
- 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,356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* orchestrator.mjs — High-level startup glue for remote pairing on the bridge.
|
|
3
|
+
*
|
|
4
|
+
* Wires the four pieces:
|
|
5
|
+
* 1. ensureIdentityKeypair (`keys.mjs`) — bridge's static X25519 key
|
|
6
|
+
* 2. loadPairings (`pairings.mjs`) — paired-phones allowlist
|
|
7
|
+
* 3. createHttpDispatch (`http-dispatch.mjs`) — adapt RPC → bridge listener
|
|
8
|
+
* 4. BridgeRelayClient (`bridge-relay-client.mjs`) — open relay connections
|
|
9
|
+
*
|
|
10
|
+
* Plus a cheap fs.watch on the pairings file so adding/removing a pairing
|
|
11
|
+
* via the LAN-pairing flow is picked up live (no bridge restart needed).
|
|
12
|
+
*
|
|
13
|
+
* Usage from `viveworker-bridge.mjs`:
|
|
14
|
+
*
|
|
15
|
+
* import { startRemotePairingRelay } from "./lib/remote-pairing/orchestrator.mjs";
|
|
16
|
+
*
|
|
17
|
+
* const remotePairingHandle = await startRemotePairingRelay({
|
|
18
|
+
* relayUrl: process.env.REMOTE_PAIRING_RELAY_URL,
|
|
19
|
+
* requestListener: bridgeRequestHandler,
|
|
20
|
+
* logger: console,
|
|
21
|
+
* });
|
|
22
|
+
*
|
|
23
|
+
* // …on shutdown:
|
|
24
|
+
* remotePairingHandle.close();
|
|
25
|
+
*
|
|
26
|
+
* The handle exposes `.getStatus()` for the bridge's `/health` or settings
|
|
27
|
+
* UI, and `.reloadNow()` for explicit reloads (e.g. right after the LAN
|
|
28
|
+
* pairing flow writes to the file — saves waiting on the fs.watch debounce).
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { promises as fs } from "node:fs";
|
|
32
|
+
import { watch as fsWatch } from "node:fs";
|
|
33
|
+
|
|
34
|
+
import { ensureIdentityKeypair, REMOTE_PAIRING_ENV_FILE } from "./keys.mjs";
|
|
35
|
+
import { loadPairings, markSeenPersisted, REMOTE_PAIRINGS_FILE } from "./pairings.mjs";
|
|
36
|
+
import { createHttpDispatch } from "./http-dispatch.mjs";
|
|
37
|
+
import { BridgeRelayClient } from "./bridge-relay-client.mjs";
|
|
38
|
+
import { bytesToHex } from "./keys-core.mjs";
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Defaults
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
/** Default relay URL — overridable via env or option. */
|
|
45
|
+
export const DEFAULT_RELAY_URL = "wss://pairing.viveworker.com";
|
|
46
|
+
|
|
47
|
+
/** Debounce for fs.watch — multiple events per save are common on macOS. */
|
|
48
|
+
const RELOAD_DEBOUNCE_MS = 250;
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Public API
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @typedef {Object} StartRemotePairingRelayOptions
|
|
56
|
+
* @property {string} [relayUrl] defaults to DEFAULT_RELAY_URL or env
|
|
57
|
+
* @property {import("./http-dispatch.mjs").RequestListener} requestListener
|
|
58
|
+
* @property {string} [identityKeypairFile] overrides REMOTE_PAIRING_ENV_FILE
|
|
59
|
+
* @property {string} [pairingsFile] overrides REMOTE_PAIRINGS_FILE
|
|
60
|
+
* @property {boolean} [enabled] if false, returns a dormant handle
|
|
61
|
+
* that does nothing. Useful for "feature-flag off" without callers
|
|
62
|
+
* having to scatter `if` checks.
|
|
63
|
+
* @property {string} [remoteAddressPrefix] forwarded to http-dispatch
|
|
64
|
+
* @property {number} [responseTimeoutMs] forwarded to http-dispatch
|
|
65
|
+
* @property {{debug?:Function, warn?:Function, error?:Function, info?:Function}} [logger]
|
|
66
|
+
* @property {typeof WebSocket} [WebSocketImpl] defaults to require("ws")
|
|
67
|
+
* @property {number} [pingIntervalMs] forwarded to BridgeRelayClient
|
|
68
|
+
* @property {number[]} [backoffMs] forwarded to BridgeRelayClient
|
|
69
|
+
* @property {number} [handshakeTimeoutMs] forwarded to BridgeRelayClient
|
|
70
|
+
* @property {Uint8Array} [prologue] forwarded to BridgeRelayClient
|
|
71
|
+
* @property {boolean} [watchPairingsFile] defaults to true
|
|
72
|
+
* @property {(event: object) => void | Promise<void>} [auditEventSink]
|
|
73
|
+
*/
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @typedef {Object} RemotePairingHandle
|
|
77
|
+
* @property {BridgeRelayClient | null} client
|
|
78
|
+
* @property {{priv: Uint8Array, pub: Uint8Array} | null} identityKeypair
|
|
79
|
+
* @property {() => Promise<void>} reloadNow
|
|
80
|
+
* @property {() => void} kick
|
|
81
|
+
* @property {(topic: string, data?: unknown) => void} broadcast
|
|
82
|
+
* @property {() => RemotePairingStatus} getStatus
|
|
83
|
+
* @property {() => void} close
|
|
84
|
+
*/
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @typedef {Object} RemotePairingStatus
|
|
88
|
+
* @property {boolean} enabled
|
|
89
|
+
* @property {string} relayUrl
|
|
90
|
+
* @property {string | null} identityFingerprint
|
|
91
|
+
* @property {string | null} identityPubHex
|
|
92
|
+
* @property {Array<{ pairingId: string, label: string, phonePub: string, state: string,
|
|
93
|
+
* lastSeenAtMs: number | null, channelBindingHex: string | null,
|
|
94
|
+
* phoneFingerprint: string }>} sessions
|
|
95
|
+
*/
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* @param {StartRemotePairingRelayOptions} opts
|
|
99
|
+
* @returns {Promise<RemotePairingHandle>}
|
|
100
|
+
*/
|
|
101
|
+
export async function startRemotePairingRelay(opts) {
|
|
102
|
+
const log = normalizeLogger(opts.logger);
|
|
103
|
+
const enabled = opts.enabled !== false;
|
|
104
|
+
|
|
105
|
+
const handle = {
|
|
106
|
+
client: null,
|
|
107
|
+
identityKeypair: null,
|
|
108
|
+
reloadNow: async () => {},
|
|
109
|
+
kick: () => {},
|
|
110
|
+
broadcast: () => {},
|
|
111
|
+
getStatus: () => ({
|
|
112
|
+
enabled: false,
|
|
113
|
+
relayUrl: "",
|
|
114
|
+
identityFingerprint: null,
|
|
115
|
+
identityPubHex: null,
|
|
116
|
+
sessions: [],
|
|
117
|
+
}),
|
|
118
|
+
close: () => {},
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
if (!enabled) {
|
|
122
|
+
log.info?.("[remote-pairing] disabled — orchestrator returning dormant handle");
|
|
123
|
+
return handle;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (typeof opts.requestListener !== "function") {
|
|
127
|
+
throw new TypeError("startRemotePairingRelay: requestListener required");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const relayUrl = (opts.relayUrl ?? process.env.REMOTE_PAIRING_RELAY_URL ?? DEFAULT_RELAY_URL).trim();
|
|
131
|
+
if (!relayUrl) throw new Error("startRemotePairingRelay: empty relayUrl");
|
|
132
|
+
const audit = createAuditEmitter(opts.auditEventSink, log);
|
|
133
|
+
|
|
134
|
+
const identityKeypairFile = opts.identityKeypairFile ?? REMOTE_PAIRING_ENV_FILE;
|
|
135
|
+
const pairingsFile = opts.pairingsFile ?? REMOTE_PAIRINGS_FILE;
|
|
136
|
+
|
|
137
|
+
// 1. Ensure the bridge has a static keypair.
|
|
138
|
+
const identityKeypair = await ensureIdentityKeypair(identityKeypairFile);
|
|
139
|
+
log.info?.(`[remote-pairing] identity pub=${bytesToHex(identityKeypair.pub)}`);
|
|
140
|
+
|
|
141
|
+
// 2. Build the dispatch (adapter from RPC → bridge HTTP handler).
|
|
142
|
+
const dispatch = createHttpDispatch({
|
|
143
|
+
requestListener: opts.requestListener,
|
|
144
|
+
remoteAddressPrefix: opts.remoteAddressPrefix,
|
|
145
|
+
responseTimeoutMs: opts.responseTimeoutMs,
|
|
146
|
+
logger: log,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// 3. Resolve a default WebSocketImpl if the caller didn't pass one.
|
|
150
|
+
const WebSocketImpl = opts.WebSocketImpl ?? (await defaultWebSocketImpl());
|
|
151
|
+
|
|
152
|
+
// 4. Construct the relay client. We pre-load the pairings here so the
|
|
153
|
+
// initial sessions spawn at start() time without the client doing its
|
|
154
|
+
// own (potentially-throwing) load.
|
|
155
|
+
const initialPairings = await loadPairings(pairingsFile).catch((err) => {
|
|
156
|
+
log.warn?.(`[remote-pairing] failed to load ${pairingsFile}: ${err.message}`);
|
|
157
|
+
return [];
|
|
158
|
+
});
|
|
159
|
+
log.info?.(`[remote-pairing] loaded ${initialPairings.length} paired phone(s) from ${pairingsFile}`);
|
|
160
|
+
|
|
161
|
+
const client = new BridgeRelayClient({
|
|
162
|
+
relayUrl,
|
|
163
|
+
identityKeypair,
|
|
164
|
+
pairings: initialPairings,
|
|
165
|
+
pairingsFile, // for reload()
|
|
166
|
+
dispatch,
|
|
167
|
+
onSessionState: ({ pairingId, phoneFingerprint, label, deviceId, state, prev }) => {
|
|
168
|
+
if (state === prev) return;
|
|
169
|
+
log.debug?.(`[remote-pairing] ${redactPairingId(pairingId)} ${prev} → ${state}`);
|
|
170
|
+
if (state === "connected") {
|
|
171
|
+
audit({
|
|
172
|
+
type: "session_connected",
|
|
173
|
+
outcome: "success",
|
|
174
|
+
pairingId: redactPairingId(pairingId),
|
|
175
|
+
phoneFingerprint,
|
|
176
|
+
label,
|
|
177
|
+
deviceId,
|
|
178
|
+
state,
|
|
179
|
+
previousState: prev,
|
|
180
|
+
});
|
|
181
|
+
} else if (state === "failed") {
|
|
182
|
+
audit({
|
|
183
|
+
type: "session_failed",
|
|
184
|
+
outcome: "failure",
|
|
185
|
+
pairingId: redactPairingId(pairingId),
|
|
186
|
+
phoneFingerprint,
|
|
187
|
+
label,
|
|
188
|
+
deviceId,
|
|
189
|
+
state,
|
|
190
|
+
previousState: prev,
|
|
191
|
+
});
|
|
192
|
+
} else if (prev === "connected" && state === "disconnected") {
|
|
193
|
+
audit({
|
|
194
|
+
type: "session_disconnected",
|
|
195
|
+
outcome: "info",
|
|
196
|
+
pairingId: redactPairingId(pairingId),
|
|
197
|
+
phoneFingerprint,
|
|
198
|
+
label,
|
|
199
|
+
deviceId,
|
|
200
|
+
state,
|
|
201
|
+
previousState: prev,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
onError: (err, ctx) => {
|
|
206
|
+
log.warn?.(
|
|
207
|
+
`[remote-pairing] error${ctx?.pairingId ? ` (${redactPairingId(ctx.pairingId)})` : ""}: ${err?.message}`,
|
|
208
|
+
);
|
|
209
|
+
audit({
|
|
210
|
+
type: "session_error",
|
|
211
|
+
outcome: "failure",
|
|
212
|
+
pairingId: ctx?.pairingId ? redactPairingId(ctx.pairingId) : "",
|
|
213
|
+
phoneFingerprint: ctx?.phoneFingerprint,
|
|
214
|
+
label: ctx?.label,
|
|
215
|
+
deviceId: ctx?.deviceId,
|
|
216
|
+
reason: err?.message || "remote pairing error",
|
|
217
|
+
});
|
|
218
|
+
},
|
|
219
|
+
onSeen: ({ pairing, atMs, channelBinding }) => {
|
|
220
|
+
log.debug?.(`[remote-pairing] ${redactPairingId(pairing.pairingId)} seen at ${new Date(atMs).toISOString()}`);
|
|
221
|
+
markSeenPersisted(pairing.phonePub, { atMs, channelBinding }, pairingsFile).catch((err) => {
|
|
222
|
+
log.warn?.(`[remote-pairing] last-seen persist failed for ${redactPairingId(pairing.pairingId)}: ${err?.message}`);
|
|
223
|
+
});
|
|
224
|
+
},
|
|
225
|
+
WebSocketImpl,
|
|
226
|
+
pingIntervalMs: opts.pingIntervalMs,
|
|
227
|
+
backoffMs: opts.backoffMs,
|
|
228
|
+
handshakeTimeoutMs: opts.handshakeTimeoutMs,
|
|
229
|
+
prologue: opts.prologue,
|
|
230
|
+
logger: log,
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
await client.start();
|
|
234
|
+
|
|
235
|
+
// 5. Optional fs.watch. Some filesystems / Docker volumes don't fire
|
|
236
|
+
// events; the LAN-pair handler should still call reloadNow() after
|
|
237
|
+
// writing. fs.watch is just a convenience for interactive editing.
|
|
238
|
+
let watcher = null;
|
|
239
|
+
let reloadTimer = null;
|
|
240
|
+
const triggerReload = () => {
|
|
241
|
+
if (reloadTimer) clearTimeout(reloadTimer);
|
|
242
|
+
reloadTimer = setTimeout(async () => {
|
|
243
|
+
reloadTimer = null;
|
|
244
|
+
try {
|
|
245
|
+
await client.reload();
|
|
246
|
+
log.debug?.(`[remote-pairing] reloaded — ${client.getSessions().length} session(s) live`);
|
|
247
|
+
} catch (err) {
|
|
248
|
+
log.warn?.(`[remote-pairing] reload failed: ${err?.message}`);
|
|
249
|
+
}
|
|
250
|
+
}, RELOAD_DEBOUNCE_MS);
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
if (opts.watchPairingsFile !== false) {
|
|
254
|
+
try {
|
|
255
|
+
watcher = fsWatch(pairingsFile, { persistent: false }, () => triggerReload());
|
|
256
|
+
// Some platforms fire ENOENT on first events; a no-op error handler
|
|
257
|
+
// keeps the bridge from crashing if the file is rotated mid-run.
|
|
258
|
+
watcher.on?.("error", (err) => {
|
|
259
|
+
log.warn?.(`[remote-pairing] pairings watcher error: ${err?.message}`);
|
|
260
|
+
});
|
|
261
|
+
} catch (err) {
|
|
262
|
+
log.debug?.(`[remote-pairing] fs.watch unavailable for ${pairingsFile}: ${err?.message}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ---- Populate the handle now that everything's wired ----
|
|
267
|
+
|
|
268
|
+
handle.client = client;
|
|
269
|
+
handle.identityKeypair = identityKeypair;
|
|
270
|
+
handle.reloadNow = async () => {
|
|
271
|
+
if (reloadTimer) {
|
|
272
|
+
clearTimeout(reloadTimer);
|
|
273
|
+
reloadTimer = null;
|
|
274
|
+
}
|
|
275
|
+
await client.reload();
|
|
276
|
+
};
|
|
277
|
+
handle.kick = () => client.kick();
|
|
278
|
+
handle.broadcast = (topic, data) => client.broadcast(topic, data);
|
|
279
|
+
handle.getStatus = () => ({
|
|
280
|
+
enabled: true,
|
|
281
|
+
relayUrl,
|
|
282
|
+
identityFingerprint: identityKeypair ? fingerprintPub(identityKeypair.pub) : null,
|
|
283
|
+
identityPubHex: identityKeypair ? bytesToHex(identityKeypair.pub) : null,
|
|
284
|
+
sessions: client.getSessions(),
|
|
285
|
+
});
|
|
286
|
+
handle.close = () => {
|
|
287
|
+
if (reloadTimer) clearTimeout(reloadTimer);
|
|
288
|
+
if (watcher) {
|
|
289
|
+
try { watcher.close(); } catch {}
|
|
290
|
+
}
|
|
291
|
+
client.close();
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
return handle;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
// Helpers
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
async function defaultWebSocketImpl() {
|
|
302
|
+
// Lazy-import `ws` so consumers that supply their own implementation (or
|
|
303
|
+
// run in environments without the package — like tests) don't pay the
|
|
304
|
+
// resolution cost.
|
|
305
|
+
try {
|
|
306
|
+
const mod = await import("ws");
|
|
307
|
+
return mod.default ?? mod.WebSocket ?? mod;
|
|
308
|
+
} catch (err) {
|
|
309
|
+
throw new Error(
|
|
310
|
+
`startRemotePairingRelay: couldn't load 'ws' (${err.message}). ` +
|
|
311
|
+
`Install it (npm i ws) or pass WebSocketImpl explicitly.`,
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function normalizeLogger(logger) {
|
|
317
|
+
if (!logger) return {};
|
|
318
|
+
return {
|
|
319
|
+
debug: typeof logger.debug === "function" ? logger.debug.bind(logger) : undefined,
|
|
320
|
+
warn: typeof logger.warn === "function" ? logger.warn.bind(logger) : undefined,
|
|
321
|
+
error: typeof logger.error === "function" ? logger.error.bind(logger) : undefined,
|
|
322
|
+
info: typeof logger.info === "function" ? logger.info.bind(logger) : undefined,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function redactPairingId(pairingId) {
|
|
327
|
+
const value = String(pairingId || "");
|
|
328
|
+
return value ? `pairing:${value.slice(0, 6)}…` : "pairing:unknown";
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function createAuditEmitter(sink, log) {
|
|
332
|
+
if (typeof sink !== "function") return () => {};
|
|
333
|
+
return (event) => {
|
|
334
|
+
try {
|
|
335
|
+
const maybe = sink(event);
|
|
336
|
+
if (maybe && typeof maybe.then === "function") {
|
|
337
|
+
maybe.catch((err) => {
|
|
338
|
+
log.warn?.(`[remote-pairing] audit write failed: ${err?.message}`);
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
} catch (err) {
|
|
342
|
+
log.warn?.(`[remote-pairing] audit write failed: ${err?.message}`);
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function fingerprintPub(pub) {
|
|
348
|
+
// Reuse the existing fingerprint helper — keeps the hex form consistent
|
|
349
|
+
// with how pairings.mjs labels phones.
|
|
350
|
+
// (Kept inline to avoid a circular import via keys.mjs which itself uses
|
|
351
|
+
// it; orchestrator.mjs is a top-level wiring module so it's fine.)
|
|
352
|
+
return bytesToHex(pub).slice(0, 12).toUpperCase().replace(/(.{4})/g, "$1-").replace(/-$/, "");
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Expose this so unit tests can poke at the debounce without sleeping.
|
|
356
|
+
export const __RELOAD_DEBOUNCE_MS = RELOAD_DEBOUNCE_MS;
|