accelr8 3.8.2 → 4.0.0-next.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.
@@ -0,0 +1,626 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ VOICE_PAGE_CSS,
4
+ VOICE_PAGE_JS,
5
+ voicePageCsp,
6
+ voicePageHtml
7
+ } from "./chunk-EQPM4FIJ.js";
8
+ import {
9
+ VoiceStore,
10
+ initialVoiceState,
11
+ isVoiceUrl,
12
+ normalizeVoiceState
13
+ } from "./chunk-CAQJPW54.js";
14
+ import {
15
+ accelr8Dir
16
+ } from "./chunk-62O2F73J.js";
17
+
18
+ // src/livekit-window.ts
19
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
20
+ import fs2 from "node:fs";
21
+ import http from "node:http";
22
+ import os2 from "node:os";
23
+ import path2 from "node:path";
24
+ import { pathToFileURL } from "node:url";
25
+
26
+ // src/livekit-open.ts
27
+ import { spawn } from "node:child_process";
28
+ import fs from "node:fs";
29
+ import os from "node:os";
30
+ import path from "node:path";
31
+ var WINDOW = "--window-size=440,600";
32
+ var LINUX_APPS = ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge", "brave-browser"];
33
+ function isVoicePageHost(hostname) {
34
+ return hostname === "127.0.0.1" || /^a8-[0-9a-f]{8,64}\.localhost$/.test(hostname);
35
+ }
36
+ function assertLocalPageUrl(url) {
37
+ const u = new URL(url);
38
+ if (u.protocol === "file:" && u.pathname.endsWith("/voice.html")) return u;
39
+ if (u.protocol !== "http:" || !isVoicePageHost(u.hostname)) {
40
+ throw new Error("the voice window only opens the local voice page");
41
+ }
42
+ return u;
43
+ }
44
+ function findBrowsers(platform = process.platform, env = process.env, exists = fs.existsSync) {
45
+ if (platform === "darwin") {
46
+ const names = ["Google Chrome.app", "Chromium.app", "Microsoft Edge.app", "Brave Browser.app"];
47
+ for (const dir of ["/Applications", path.join(env.HOME || os.homedir(), "Applications")]) {
48
+ for (const n of names) {
49
+ const p = path.join(dir, n);
50
+ if (exists(p)) return { macApp: p };
51
+ }
52
+ }
53
+ return { macApp: null };
54
+ }
55
+ if (platform === "win32") {
56
+ const roots = [env.ProgramFiles, env["ProgramFiles(x86)"], env.LOCALAPPDATA].filter(Boolean);
57
+ const rel = [["Google", "Chrome", "Application", "chrome.exe"], ["Microsoft", "Edge", "Application", "msedge.exe"]];
58
+ for (const r of rel) for (const root of roots) {
59
+ const p = path.win32.join(root, ...r);
60
+ if (exists(p)) return { winExe: p };
61
+ }
62
+ return { winExe: null };
63
+ }
64
+ const dirs = (env.PATH ?? "").split(":").filter(Boolean);
65
+ const snap = LINUX_APPS.filter((bin) => {
66
+ const dir = dirs.find((d) => exists(path.posix.join(d, bin)));
67
+ return !!dir && /(^|\/)snap\/bin\/?$/.test(dir);
68
+ });
69
+ return { snap };
70
+ }
71
+ function openCommands(target, platform, found, app = true) {
72
+ const t = typeof target === "string" ? { app: target, browser: target } : target;
73
+ const out = [];
74
+ if (platform === "darwin") {
75
+ if (app && found.macApp) out.push({ method: "app-window", command: "open", args: ["-na", found.macApp, "--args", `--app=${t.app}`, WINDOW] });
76
+ out.push({ method: "default-browser", command: "open", args: [t.browser] });
77
+ } else if (platform === "win32") {
78
+ if (app && found.winExe) out.push({ method: "app-window", command: found.winExe, args: [`--app=${t.app}`, WINDOW] });
79
+ out.push({ method: "default-browser", command: "rundll32", args: ["url.dll,FileProtocolHandler", t.browser] });
80
+ } else {
81
+ if (app) {
82
+ for (const bin of LINUX_APPS) {
83
+ out.push({ method: "app-window", command: bin, args: [`--app=${found.snap?.includes(bin) ? t.browser : t.app}`, WINDOW] });
84
+ }
85
+ }
86
+ out.push({ method: "default-browser", command: "xdg-open", args: [t.browser] });
87
+ }
88
+ return out;
89
+ }
90
+ var runDetached = (command, args) => new Promise((resolve) => {
91
+ let child;
92
+ try {
93
+ child = spawn(command, args, { stdio: "ignore", detached: true, windowsHide: true });
94
+ } catch {
95
+ resolve(false);
96
+ return;
97
+ }
98
+ child.once("error", () => resolve(false));
99
+ child.once("spawn", () => {
100
+ child.unref();
101
+ resolve(true);
102
+ });
103
+ });
104
+ async function openVoicePage(target, opts = {}) {
105
+ const t = typeof target === "string" ? { app: target, browser: target } : target;
106
+ const appUrl = assertLocalPageUrl(t.app);
107
+ const page = assertLocalPageUrl(t.browser);
108
+ if (page.protocol !== "http:") throw new Error("the default browser opens the voice page itself");
109
+ const platform = opts.platform ?? process.platform;
110
+ const run = opts.run ?? runDetached;
111
+ for (const a of openCommands({ app: appUrl.toString(), browser: page.toString() }, platform, opts.found ?? findBrowsers(platform), opts.app ?? true)) {
112
+ if (await run(a.command, a.args)) return { method: a.method };
113
+ }
114
+ throw new Error(`could not open a browser for voice \xB7 open ${page.origin}${page.pathname} yourself`);
115
+ }
116
+
117
+ // src/livekit-window.ts
118
+ var VOICE_PORTS = [38490, 38491, 38492, 38493, 38494];
119
+ var MAX_BODY = 64 * 1024;
120
+ var COOKIE = "a8voice";
121
+ var LABEL_RE = /^a8-[0-9a-f]{24}$/;
122
+ function voiceHostLabel(dir = accelr8Dir()) {
123
+ const file = path2.join(dir, "voice-host");
124
+ try {
125
+ const v = fs2.readFileSync(file, "utf8").trim();
126
+ if (LABEL_RE.test(v)) return v;
127
+ } catch {
128
+ }
129
+ const label = "a8-" + randomBytes(12).toString("hex");
130
+ try {
131
+ fs2.mkdirSync(dir, { recursive: true, mode: 448 });
132
+ fs2.writeFileSync(file, label + "\n", { mode: 384 });
133
+ fs2.chmodSync(file, 384);
134
+ } catch {
135
+ }
136
+ return label;
137
+ }
138
+ function writeVoiceRedirect(pageUrl, parent = os2.tmpdir()) {
139
+ const dir = fs2.mkdtempSync(path2.join(parent, "accelr8-voice-"));
140
+ const file = path2.join(dir, "voice.html");
141
+ fs2.writeFileSync(
142
+ file,
143
+ `<!doctype html><meta charset="utf-8"><meta name="referrer" content="no-referrer"><title>accelr8 voice</title><meta http-equiv="refresh" content="0;url=${pageUrl}"><script>location.replace(${JSON.stringify(pageUrl)})</script>
144
+ `,
145
+ { mode: 384, flag: "wx" }
146
+ );
147
+ return { url: pathToFileURL(file).href, dir };
148
+ }
149
+ async function defaultClientJs() {
150
+ const m = await import("./livekit-client-bundle-HLYFRJB2.js");
151
+ return m.LIVEKIT_CLIENT_JS;
152
+ }
153
+ function listen(server, port, host) {
154
+ return new Promise((resolve, reject) => {
155
+ const onError = (e) => {
156
+ server.off("listening", onListening);
157
+ reject(e);
158
+ };
159
+ const onListening = () => {
160
+ server.off("error", onError);
161
+ resolve(server.address().port);
162
+ };
163
+ server.once("error", onError);
164
+ server.once("listening", onListening);
165
+ server.listen(port, host);
166
+ });
167
+ }
168
+ async function bindLoopback(handler, ports, onSocket) {
169
+ let lastErr = null;
170
+ for (const p of [...ports, 0, 0, 0]) {
171
+ const v4 = http.createServer(handler);
172
+ v4.on("connection", onSocket);
173
+ let port;
174
+ try {
175
+ port = await listen(v4, p, "127.0.0.1");
176
+ } catch (e) {
177
+ lastErr = e;
178
+ v4.close();
179
+ continue;
180
+ }
181
+ const v6 = http.createServer(handler);
182
+ v6.on("connection", onSocket);
183
+ try {
184
+ await listen(v6, port, "::1");
185
+ return { servers: [v4, v6], port };
186
+ } catch (e) {
187
+ if (e?.code !== "EADDRINUSE") {
188
+ v6.close();
189
+ return { servers: [v4], port };
190
+ }
191
+ lastErr = e;
192
+ v4.close();
193
+ v6.close();
194
+ }
195
+ }
196
+ throw lastErr ?? new Error("no port");
197
+ }
198
+ async function openVoiceWindow(o) {
199
+ if (!isVoiceUrl(o.liveKitUrl)) throw new Error("bad LiveKit url");
200
+ const nonce = randomBytes(24).toString("base64url");
201
+ const cookieSecret = Buffer.from(randomBytes(24).toString("base64url"));
202
+ const label = voiceHostLabel(o.stateDir);
203
+ const styleHash = createHash("sha256").update(VOICE_PAGE_CSS, "utf8").digest("base64");
204
+ const graceMs = o.graceMs ?? 8e3;
205
+ const leaveTimeoutMs = o.leaveTimeoutMs ?? 3e3;
206
+ const store = new VoiceStore({ ...initialVoiceState(o.room, o.identity) });
207
+ const loadJs = o.loadClientJs ?? defaultClientJs;
208
+ let port = 0;
209
+ let servers = [];
210
+ let finished = false;
211
+ let wantMuted = o.startMuted ?? true;
212
+ let pageHost = null;
213
+ let loads = 0;
214
+ let redirectDir = null;
215
+ let attach = null;
216
+ let sse = null;
217
+ let closing = false;
218
+ let everAttached = false;
219
+ let graceTimer = null;
220
+ let loadTimer = null;
221
+ let pingTimer = null;
222
+ let tokenWork = null;
223
+ const pending = /* @__PURE__ */ new Map();
224
+ const sockets = /* @__PURE__ */ new Set();
225
+ let resolveEnded;
226
+ const ended = new Promise((r) => {
227
+ resolveEnded = r;
228
+ });
229
+ let clientJs = null;
230
+ const log = [];
231
+ const note = (s) => {
232
+ log.push(`${(/* @__PURE__ */ new Date()).toISOString().slice(11, 19)} ${s}`);
233
+ if (log.length > 8) log.shift();
234
+ };
235
+ const deny = (res, code) => {
236
+ res.writeHead(code, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" });
237
+ res.end(code === 404 ? "This voice window is closed. Run /voice in accelr8 to open a new one." : "no");
238
+ };
239
+ const json = (res, code, body) => {
240
+ res.writeHead(code, { "Content-Type": "application/json", "Cache-Control": "no-store" });
241
+ res.end(JSON.stringify(body));
242
+ };
243
+ const event = (name, data) => {
244
+ if (!sse || sse.writableEnded) return false;
245
+ sse.write(`event: ${name}
246
+ data: ${JSON.stringify(data)}
247
+
248
+ `);
249
+ return true;
250
+ };
251
+ const dropRedirect = () => {
252
+ if (!redirectDir) return;
253
+ try {
254
+ fs2.rmSync(redirectDir, { recursive: true, force: true });
255
+ } catch {
256
+ }
257
+ redirectDir = null;
258
+ };
259
+ const hasCookie = (req) => {
260
+ for (const part of String(req.headers.cookie ?? "").split(";")) {
261
+ const i = part.indexOf("=");
262
+ if (i < 0 || part.slice(0, i).trim() !== COOKIE) continue;
263
+ const v = Buffer.from(part.slice(i + 1).trim());
264
+ if (v.length === cookieSecret.length && timingSafeEqual(v, cookieSecret)) return true;
265
+ }
266
+ return false;
267
+ };
268
+ function finish(reason, error = null, pageReason) {
269
+ if (finished) return;
270
+ finished = true;
271
+ note(`ended: ${reason}${error ? " \xB7 " + error : ""}`);
272
+ for (const t2 of [graceTimer, loadTimer]) if (t2) clearTimeout(t2);
273
+ if (pingTimer) clearInterval(pingTimer);
274
+ graceTimer = loadTimer = pingTimer = null;
275
+ store.update({ connection: "disconnected", endReason: reason, ...error ? { error } : {} });
276
+ for (const [, p] of pending) {
277
+ clearTimeout(p.timer);
278
+ p.resolve([]);
279
+ }
280
+ pending.clear();
281
+ event("bye", { reason: pageReason ?? (reason === "left" ? "you left voice in accelr8" : "accelr8 ended voice") });
282
+ try {
283
+ sse?.end();
284
+ } catch {
285
+ }
286
+ sse = null;
287
+ attach = null;
288
+ dropRedirect();
289
+ for (const s of servers) s.close();
290
+ const t = setTimeout(() => {
291
+ for (const s of sockets) s.destroy();
292
+ }, 300);
293
+ t.unref();
294
+ resolveEnded(reason);
295
+ }
296
+ function pageGone(why) {
297
+ if (finished) return;
298
+ note(`page gone (${why})`);
299
+ attach = null;
300
+ sse = null;
301
+ closing = false;
302
+ if (pingTimer) {
303
+ clearInterval(pingTimer);
304
+ pingTimer = null;
305
+ }
306
+ wantMuted = true;
307
+ store.update({ connection: "connecting", needsClick: false, participants: [], playbackBlocked: false, muted: true });
308
+ if (!graceTimer) {
309
+ graceTimer = setTimeout(() => {
310
+ graceTimer = null;
311
+ if (!attach) finish("window_closed");
312
+ }, graceMs);
313
+ graceTimer.unref?.();
314
+ }
315
+ }
316
+ function readBody(req) {
317
+ return new Promise((resolve, reject) => {
318
+ let size = 0;
319
+ const chunks = [];
320
+ req.on("data", (c) => {
321
+ size += c.length;
322
+ if (size > MAX_BODY) {
323
+ reject(new Error("too big"));
324
+ req.destroy();
325
+ return;
326
+ }
327
+ chunks.push(c);
328
+ });
329
+ req.on("end", () => {
330
+ try {
331
+ resolve(JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}"));
332
+ } catch (e) {
333
+ reject(e);
334
+ }
335
+ });
336
+ req.on("error", reject);
337
+ });
338
+ }
339
+ function openEvents(req, res) {
340
+ if (sse && !closing) return deny(res, 409);
341
+ if (sse) {
342
+ try {
343
+ sse.end();
344
+ } catch {
345
+ }
346
+ }
347
+ const id = randomBytes(12).toString("base64url");
348
+ attach = id;
349
+ sse = res;
350
+ closing = false;
351
+ everAttached = true;
352
+ if (graceTimer) {
353
+ clearTimeout(graceTimer);
354
+ graceTimer = null;
355
+ }
356
+ if (loadTimer) {
357
+ clearTimeout(loadTimer);
358
+ loadTimer = null;
359
+ }
360
+ res.writeHead(200, {
361
+ "Content-Type": "text/event-stream; charset=utf-8",
362
+ "Cache-Control": "no-store",
363
+ Connection: "keep-alive",
364
+ "X-Accel-Buffering": "no"
365
+ });
366
+ res.write("retry: 1000\n\n");
367
+ event("hello", { attach: id, muted: wantMuted });
368
+ note("page attached");
369
+ if (pingTimer) clearInterval(pingTimer);
370
+ pingTimer = setInterval(() => {
371
+ if (sse === res) res.write(": ping\n\n");
372
+ }, 15e3);
373
+ pingTimer.unref?.();
374
+ req.on("close", () => {
375
+ if (sse === res) pageGone("closed");
376
+ });
377
+ }
378
+ async function onPost(sub, req, res) {
379
+ const type = String(req.headers["content-type"] ?? "");
380
+ if (sub !== "/closing" && !type.startsWith("application/json")) return deny(res, 415);
381
+ let body;
382
+ try {
383
+ body = await readBody(req);
384
+ } catch {
385
+ return deny(res, 400);
386
+ }
387
+ if (finished) return deny(res, 404);
388
+ if (!body || typeof body !== "object" || typeof body.attach !== "string" || body.attach !== attach) return deny(res, 409);
389
+ switch (sub) {
390
+ case "/state": {
391
+ const patch = normalizeVoiceState(body);
392
+ if (patch.connection === "disconnected") {
393
+ finish(patch.endReason ?? "error", patch.error ?? null);
394
+ } else {
395
+ if (patch.connection === "connected" && store.snapshot().connection !== "connected") note("in the call");
396
+ store.update(patch);
397
+ }
398
+ return json(res, 200, { ok: true });
399
+ }
400
+ case "/join": {
401
+ try {
402
+ tokenWork = tokenWork ?? o.getToken();
403
+ const token = await tokenWork;
404
+ if (finished || body.attach !== attach) return deny(res, 409);
405
+ note("join clicked \xB7 pass handed over");
406
+ return json(res, 200, { token });
407
+ } catch (e) {
408
+ const m = e instanceof Error ? e.message : String(e);
409
+ note("no voice pass: " + m);
410
+ return json(res, 503, { error: m });
411
+ } finally {
412
+ tokenWork = null;
413
+ }
414
+ }
415
+ case "/reply": {
416
+ const p = typeof body.id === "string" ? pending.get(body.id) : void 0;
417
+ if (p) {
418
+ pending.delete(body.id);
419
+ clearTimeout(p.timer);
420
+ p.resolve(Array.isArray(body.devices) ? body.devices.slice(0, 64).flatMap((d) => d && typeof d.id === "string" ? [{
421
+ id: d.id.slice(0, 256),
422
+ label: String(d.label ?? "").replace(/[\u0000-\u001f\u007f-\u009f]/g, "").slice(0, 80) || "unnamed device",
423
+ kind: d.kind === "audiooutput" ? "audiooutput" : "audioinput",
424
+ isDefault: d.isDefault === true
425
+ }] : []) : []);
426
+ }
427
+ return json(res, 200, { ok: true });
428
+ }
429
+ case "/closing": {
430
+ closing = true;
431
+ note("page closing");
432
+ return json(res, 200, { ok: true });
433
+ }
434
+ }
435
+ return deny(res, 404);
436
+ }
437
+ const common = { "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", "Referrer-Policy": "no-referrer" };
438
+ function servePage(req, res, host) {
439
+ const reload = loads > 0 && host === pageHost && hasCookie(req);
440
+ if (loads > 0 && !reload) {
441
+ note("a second page load without the cookie");
442
+ deny(res, 404);
443
+ finish("error", "another program tried to open this voice window", "another program tried to open this voice window");
444
+ return;
445
+ }
446
+ const first = loads === 0;
447
+ loads++;
448
+ if (first) {
449
+ pageHost = host;
450
+ dropRedirect();
451
+ note("page loaded");
452
+ }
453
+ res.writeHead(200, {
454
+ ...common,
455
+ "Content-Type": "text/html; charset=utf-8",
456
+ "Content-Security-Policy": voicePageCsp(o.liveKitUrl, styleHash),
457
+ "Cross-Origin-Opener-Policy": "same-origin",
458
+ "X-Frame-Options": "DENY",
459
+ "Permissions-Policy": "microphone=(self), camera=(), geolocation=()",
460
+ // Lax, not Strict: a reload must carry it, and the first load came from
461
+ // a file:// page (another site). Cross-site requests are refused above.
462
+ ...first ? { "Set-Cookie": `${COOKIE}=${cookieSecret.toString()}; Path=/voice/${nonce}; HttpOnly; SameSite=Lax` } : {}
463
+ });
464
+ res.end(voicePageHtml({ nonce, liveKitUrl: o.liveKitUrl, title: o.title, bridgeLossMs: o.bridgeLossMs }));
465
+ }
466
+ const handle = (req, res) => {
467
+ const host = String(req.headers.host ?? "").toLowerCase();
468
+ const hosts = pageHost ? [pageHost] : [`${label}.localhost:${port}`, `127.0.0.1:${port}`];
469
+ if (!hosts.includes(host)) return deny(res, 421);
470
+ let pathname;
471
+ try {
472
+ pathname = new URL(req.url ?? "/", "http://x").pathname;
473
+ } catch {
474
+ return deny(res, 400);
475
+ }
476
+ const m = /^\/voice\/([A-Za-z0-9_-]{16,64})(\/[a-z-]+(?:\.js)?)?$/.exec(pathname);
477
+ const sub = m?.[2] ?? "";
478
+ const origin2 = req.headers.origin;
479
+ const site = req.headers["sec-fetch-site"];
480
+ const mode = req.headers["sec-fetch-mode"];
481
+ const dest = req.headers["sec-fetch-dest"];
482
+ const navigation = req.method === "GET" && sub === "" && (mode === void 0 || mode === "navigate") && (dest === void 0 || dest === "document");
483
+ if (typeof site === "string" && site !== "same-origin" && site !== "none" && !(navigation && site === "cross-site")) return deny(res, 403);
484
+ if (origin2 !== void 0 && origin2 !== `http://${host}`) return deny(res, 403);
485
+ if (!m || m[1] !== nonce || finished) return deny(res, 404);
486
+ if (req.method === "POST") {
487
+ if (origin2 === void 0) return deny(res, 403);
488
+ if (!hasCookie(req)) return deny(res, 403);
489
+ void onPost(sub, req, res).catch(() => {
490
+ if (!res.headersSent) deny(res, 500);
491
+ });
492
+ return;
493
+ }
494
+ if (req.method !== "GET") return deny(res, 405);
495
+ if (sub === "") return servePage(req, res, host);
496
+ if (!hasCookie(req)) return deny(res, 403);
497
+ switch (sub) {
498
+ case "/app.js":
499
+ res.writeHead(200, { ...common, "Content-Type": "text/javascript; charset=utf-8" });
500
+ res.end(VOICE_PAGE_JS);
501
+ return;
502
+ case "/livekit-client.js": {
503
+ const send = (js) => {
504
+ res.writeHead(200, { ...common, "Content-Type": "text/javascript; charset=utf-8" });
505
+ res.end(js);
506
+ };
507
+ if (clientJs) return send(clientJs);
508
+ loadJs().then((js) => {
509
+ clientJs = js;
510
+ send(js);
511
+ }, () => deny(res, 500));
512
+ return;
513
+ }
514
+ case "/events":
515
+ return openEvents(req, res);
516
+ }
517
+ return deny(res, 404);
518
+ };
519
+ const onSocket = (s) => {
520
+ sockets.add(s);
521
+ s.once("close", () => sockets.delete(s));
522
+ };
523
+ try {
524
+ ({ servers, port } = await bindLoopback(handle, o.ports ?? VOICE_PORTS, onSocket));
525
+ } catch (e) {
526
+ throw new Error("could not start the local voice page: " + (e instanceof Error ? e.message : String(e)));
527
+ }
528
+ const appPage = `http://${label}.localhost:${port}/voice/${nonce}`;
529
+ const plainPage = `http://127.0.0.1:${port}/voice/${nonce}`;
530
+ let appTarget = appPage;
531
+ try {
532
+ const r = writeVoiceRedirect(appPage, o.tmpDir);
533
+ redirectDir = r.dir;
534
+ appTarget = r.url;
535
+ } catch (e) {
536
+ note("no redirect file (" + (e instanceof Error ? e.message : String(e)) + "): the app window gets the page URL");
537
+ }
538
+ let openedWith = "";
539
+ try {
540
+ openedWith = (await (o.open ?? openVoicePage)({ app: appTarget, browser: plainPage })).method;
541
+ } catch (e) {
542
+ finished = true;
543
+ dropRedirect();
544
+ for (const s of servers) s.close();
545
+ for (const s of sockets) s.destroy();
546
+ throw e;
547
+ }
548
+ note(`window opened (${openedWith})`);
549
+ loadTimer = setTimeout(() => {
550
+ loadTimer = null;
551
+ if (!everAttached) finish("window_closed", "the voice window did not open");
552
+ }, o.loadTimeoutMs ?? 45e3);
553
+ loadTimer.unref?.();
554
+ const origin = () => `http://${pageHost ?? (openedWith === "default-browser" ? `127.0.0.1:${port}` : `${label}.localhost:${port}`)}`;
555
+ const controller = {
556
+ kind: "window",
557
+ openedWith,
558
+ get origin() {
559
+ return origin();
560
+ },
561
+ ended,
562
+ snapshot: () => store.snapshot(),
563
+ subscribe: (fn) => store.subscribe(fn),
564
+ async setMuted(muted) {
565
+ if (finished) return;
566
+ wantMuted = muted;
567
+ event("cmd", { cmd: "mute", muted });
568
+ },
569
+ listDevices(kind = "audioinput") {
570
+ if (finished || !attach) return Promise.resolve([]);
571
+ const id = randomBytes(8).toString("hex");
572
+ return new Promise((resolve) => {
573
+ const timer = setTimeout(() => {
574
+ pending.delete(id);
575
+ resolve([]);
576
+ }, 3e3);
577
+ timer.unref?.();
578
+ pending.set(id, { resolve, timer });
579
+ if (!event("cmd", { cmd: "devices", id, kind })) {
580
+ clearTimeout(timer);
581
+ pending.delete(id);
582
+ resolve([]);
583
+ }
584
+ });
585
+ },
586
+ async setInputDevice(id) {
587
+ if (!finished) event("cmd", { cmd: "device", id: String(id).slice(0, 256) });
588
+ },
589
+ async leave() {
590
+ if (finished) {
591
+ await ended;
592
+ return;
593
+ }
594
+ if (!event("cmd", { cmd: "leave" })) {
595
+ finish("left");
596
+ await ended;
597
+ return;
598
+ }
599
+ const t = setTimeout(() => finish("left"), leaveTimeoutMs);
600
+ t.unref?.();
601
+ await ended;
602
+ clearTimeout(t);
603
+ },
604
+ quit() {
605
+ finish("left");
606
+ },
607
+ debug() {
608
+ const s = store.snapshot();
609
+ return [
610
+ `transport: livekit \xB7 voice window (${openedWith})`,
611
+ // the label is this install's, not for bug reports
612
+ `page: ${finished ? "closed" : attach ? "attached" : everAttached ? "away, waiting for it" : "not loaded yet"} \xB7 ${origin().replace(/a8-[0-9a-f]+/, "a8-\u2026")}`,
613
+ `call: ${s.connection}${s.needsClick ? " \xB7 waiting for a click on Join" : ""}${s.muted ? " \xB7 muted" : " \xB7 mic on"}`,
614
+ `people: ${s.participants.length}${s.error ? " \xB7 " + s.error : ""}`,
615
+ ...log.slice(-4)
616
+ ];
617
+ }
618
+ };
619
+ return controller;
620
+ }
621
+ export {
622
+ VOICE_PORTS,
623
+ openVoiceWindow,
624
+ voiceHostLabel,
625
+ writeVoiceRedirect
626
+ };