rechrome 1.32.0 → 1.34.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/package.json +1 -1
- package/rechrome.js +217 -25
- package/rechrome.ts +217 -25
- package/serve.js +16 -0
- package/serve.ts +16 -0
package/package.json
CHANGED
package/rechrome.js
CHANGED
|
@@ -377,20 +377,32 @@ export function buildListenChoices(interfaces: ReturnType<typeof networkInterfac
|
|
|
377
377
|
return choices.sort((a, b) => rank[a.kind] - rank[b.kind]);
|
|
378
378
|
}
|
|
379
379
|
|
|
380
|
+
// The Mac app's binary only acts as the CLI when it is told to (or has a terminal): from a
|
|
381
|
+
// daemon it tries to start the GUI and fails. Harmless for the standalone CLI.
|
|
382
|
+
const TAILSCALE_ENV = { ...process.env, TAILSCALE_BE_CLI: "1" };
|
|
380
383
|
const tailscaleBinary = () => Bun.which("tailscale") || (existsSync("/Applications/Tailscale.app/Contents/MacOS/Tailscale") ? "/Applications/Tailscale.app/Contents/MacOS/Tailscale" : null);
|
|
381
384
|
|
|
382
|
-
/** Run the Tailscale CLI read-only
|
|
383
|
-
async function
|
|
385
|
+
/** Run the Tailscale CLI read-only: its output, or why it gave none (missing, failed, slow). */
|
|
386
|
+
async function runTailscaleDetailed(args: string[]): Promise<{ output: string } | { error: string; missing?: true }> {
|
|
384
387
|
const binary = tailscaleBinary();
|
|
385
|
-
if (!binary) return
|
|
388
|
+
if (!binary) return { error: "not installed", missing: true };
|
|
386
389
|
try {
|
|
387
|
-
const proc = Bun.spawn([binary, ...args], { stdout: "pipe", stderr: "
|
|
388
|
-
|
|
390
|
+
const proc = Bun.spawn([binary, ...args], { env: TAILSCALE_ENV, stdout: "pipe", stderr: "pipe" });
|
|
391
|
+
let timedOut = false;
|
|
392
|
+
const timeout = setTimeout(() => { timedOut = true; proc.kill(); }, 2000);
|
|
389
393
|
try {
|
|
390
|
-
const output = await new Response(proc.stdout).text();
|
|
391
|
-
|
|
394
|
+
const [output, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
|
395
|
+
if (await proc.exited === 0) return { output };
|
|
396
|
+
const reason = timedOut ? "no answer within 2s" : (stderr.trim() || output.trim()).split("\n")[0] || `exit ${proc.exitCode}`;
|
|
397
|
+
return { error: `\`tailscale ${args.join(" ")}\`: ${reason}` };
|
|
392
398
|
} finally { clearTimeout(timeout); }
|
|
393
|
-
} catch { return
|
|
399
|
+
} catch (error) { return { error: String(error) }; }
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Run the Tailscale CLI read-only; null when it is missing, disconnected, or slow. */
|
|
403
|
+
async function runTailscale(args: string[]): Promise<string | null> {
|
|
404
|
+
const result = await runTailscaleDetailed(args);
|
|
405
|
+
return "output" in result ? result.output : null;
|
|
394
406
|
}
|
|
395
407
|
|
|
396
408
|
export async function detectListenChoices(): Promise<ListenChoice[]> {
|
|
@@ -445,6 +457,140 @@ export async function detectTailscaleServe(port: number, prefix: string): Promis
|
|
|
445
457
|
return { dnsName, routeUrl, ...serveMountsAndFunnel(serveStatus, routeUrl ? new URL(routeUrl).hostname : dnsName) };
|
|
446
458
|
}
|
|
447
459
|
|
|
460
|
+
type ServeHandler = { hostPort: string; mount: string; target: URL };
|
|
461
|
+
|
|
462
|
+
/** Every Serve route that proxies to a loopback HTTP port. */
|
|
463
|
+
function loopbackServeHandlers(serveStatus: unknown): ServeHandler[] {
|
|
464
|
+
const web = (serveStatus as { Web?: Record<string, { Handlers?: Record<string, { Proxy?: string }> }> } | null)?.Web ?? {};
|
|
465
|
+
const handlers: ServeHandler[] = [];
|
|
466
|
+
for (const [hostPort, config] of Object.entries(web)) {
|
|
467
|
+
for (const [path, handler] of Object.entries(config.Handlers ?? {})) {
|
|
468
|
+
if (!handler.Proxy) continue;
|
|
469
|
+
let target: URL;
|
|
470
|
+
try { target = new URL(/^[a-z]+:\/\//i.test(handler.Proxy) ? handler.Proxy : `http://${handler.Proxy}`); } catch { continue; }
|
|
471
|
+
if (!["127.0.0.1", "localhost", "[::1]"].includes(target.hostname) || !target.port) continue;
|
|
472
|
+
handlers.push({ hostPort, mount: path.replace(/\/+$/, "") || "/", target });
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return handlers;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
export type ServeRepair = { hostPort: string; mount: string; from: number; to: number; proxy: string };
|
|
479
|
+
export type ServeStale = { hostPort: string; mount: string; port: number };
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Health check for Tailscale Serve against listeners.json. `repair`: a route on a listener's own
|
|
483
|
+
* mount that proxies to another port (the listener moved), re-pointed at the listener's port.
|
|
484
|
+
* `stale`: a route next to a listener's mount (same first path segment, e.g. /rechrome beside
|
|
485
|
+
* /rechrome/taku) whose port no listener uses — a removed listener's leftover, which answers 502.
|
|
486
|
+
* Root ("/") mounts are never touched: they could be anything.
|
|
487
|
+
*/
|
|
488
|
+
export function planTailscaleServeRepairs(serveStatus: unknown, listeners: { port: number; prefix?: string }[]): { repair: ServeRepair[]; stale: ServeStale[] } {
|
|
489
|
+
const ports = new Set(listeners.map(l => l.port));
|
|
490
|
+
const mounts = new Map<string, number>();
|
|
491
|
+
for (const l of listeners) {
|
|
492
|
+
const mount = normalizePrefix(l.prefix).replace(/\/+$/, "");
|
|
493
|
+
if (mount) mounts.set(mount, l.port);
|
|
494
|
+
}
|
|
495
|
+
const roots = new Set([...mounts.keys()].map(m => m.split("/")[1]));
|
|
496
|
+
const repair: ServeRepair[] = [], stale: ServeStale[] = [];
|
|
497
|
+
for (const { hostPort, mount, target } of loopbackServeHandlers(serveStatus)) {
|
|
498
|
+
const port = Number(target.port), want = mounts.get(mount);
|
|
499
|
+
if (want !== undefined) {
|
|
500
|
+
if (port !== want) {
|
|
501
|
+
const proxy = new URL(target); proxy.port = String(want);
|
|
502
|
+
repair.push({ hostPort, mount, from: port, to: want, proxy: proxy.href.replace(/\/$/, mount === "/" ? "/" : "") });
|
|
503
|
+
}
|
|
504
|
+
} else if (mount !== "/" && roots.has(mount.split("/")[1]) && !ports.has(port)) {
|
|
505
|
+
stale.push({ hostPort, mount, port });
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return { repair, stale };
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** Drop the Serve routes that proxy to a removed listener, so they don't linger answering 502. */
|
|
512
|
+
async function removeTailscaleServeRoutes(listener: { port: number; prefix?: string }): Promise<void> {
|
|
513
|
+
const binary = tailscaleBinary(), raw = binary && await runTailscale(["serve", "status", "--json"]);
|
|
514
|
+
if (!binary || !raw) return;
|
|
515
|
+
let serveStatus: unknown;
|
|
516
|
+
try { serveStatus = JSON.parse(raw); } catch { return; }
|
|
517
|
+
const mount = normalizePrefix(listener.prefix).replace(/\/+$/, "") || "/";
|
|
518
|
+
for (const h of loopbackServeHandlers(serveStatus)) {
|
|
519
|
+
if (Number(h.target.port) !== listener.port || h.mount !== mount) continue;
|
|
520
|
+
const args = ["serve", `--https=${h.hostPort.split(/:(?=\d+$)/)[1] ?? "443"}`, `--set-path=${h.mount}`, "off"];
|
|
521
|
+
const ok = await Bun.spawn([binary, ...args], { env: TAILSCALE_ENV, stdout: "ignore", stderr: "inherit", windowsHide: true }).exited === 0;
|
|
522
|
+
console.log(ok ? `Removed its Tailscale Serve route ${h.hostPort}${h.mount}.` : `Remove its Tailscale Serve route yourself: tailscale ${args.join(" ")}`);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
type HealthListener = Pick<Listener, "name" | "port" | "prefix" | "profiles">;
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Read-only Tailscale Serve health for the listeners: `absent` (no Tailscale here), `unresponsive`
|
|
530
|
+
* (installed, but its CLI doesn't answer — e.g. not running, or the Mac app's GUI binary), or
|
|
531
|
+
* `ok` with each shared listener's route plus the routes to re-point or remove.
|
|
532
|
+
*/
|
|
533
|
+
export type TailscaleHealth =
|
|
534
|
+
| { state: "absent" }
|
|
535
|
+
| { state: "unresponsive"; error: string }
|
|
536
|
+
| { state: "ok"; routes: { listener: string; port: number; url: string | null }[]; repair: ServeRepair[]; stale: ServeStale[] };
|
|
537
|
+
|
|
538
|
+
export async function inspectTailscaleServe(listeners: HealthListener[]): Promise<TailscaleHealth> {
|
|
539
|
+
const [serve, status] = await Promise.all([runTailscaleDetailed(["serve", "status", "--json"]), runTailscale(["status", "--json"])]);
|
|
540
|
+
if (!("output" in serve)) return serve.missing ? { state: "absent" } : { state: "unresponsive", error: serve.error };
|
|
541
|
+
let serveStatus: unknown, dnsName: string | null = null;
|
|
542
|
+
try { serveStatus = JSON.parse(serve.output || "null"); } catch { return { state: "unresponsive", error: "`tailscale serve status --json` printed no JSON" }; }
|
|
543
|
+
try { dnsName = (JSON.parse(status ?? "null")?.Self?.DNSName as string | undefined)?.replace(/\.$/, "") || null; } catch { /* not connected */ }
|
|
544
|
+
const routes = listeners.filter(l => l.profiles !== "*")
|
|
545
|
+
.map(l => ({ listener: l.name, port: l.port, url: findTailscaleServeRoute(dnsName, serveStatus, l.port, normalizePrefix(l.prefix)) }));
|
|
546
|
+
return { state: "ok", routes, ...planTailscaleServeRepairs(serveStatus, listeners) };
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const httpsPortOf = (hostPort: string) => hostPort.split(/:(?=\d+$)/)[1] ?? "443";
|
|
550
|
+
|
|
551
|
+
/** Problems in a health report, one line each with the command that fixes it; empty when healthy. */
|
|
552
|
+
export function tailscaleHealthProblems(health: TailscaleHealth): string[] {
|
|
553
|
+
if (health.state === "absent") return [];
|
|
554
|
+
if (health.state === "unresponsive") return [`Tailscale is installed but its CLI doesn't answer, so Serve routes go unchecked: ${health.error}`];
|
|
555
|
+
return [
|
|
556
|
+
...health.repair.map(r => `${r.hostPort}${r.mount} proxies to 127.0.0.1:${r.from}, but the listener is on ${r.to} (clients get HTTP 502). Fix: tailscale serve --bg --https=${httpsPortOf(r.hostPort)} --set-path=${r.mount} ${r.proxy}`),
|
|
557
|
+
...health.stale.map(st => `${st.hostPort}${st.mount} proxies to 127.0.0.1:${st.port}, where no listener runs (clients get HTTP 502). Remove it: tailscale serve --https=${httpsPortOf(st.hostPort)} --set-path=${st.mount} off`),
|
|
558
|
+
];
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** The `tailscale:` block of `rech status` / `rech share ls`: each shared listener's route, then problems. */
|
|
562
|
+
export function formatTailscaleHealth(health: TailscaleHealth): string[] {
|
|
563
|
+
if (health.state === "absent") return [];
|
|
564
|
+
const lines = health.state === "ok"
|
|
565
|
+
? health.routes.map(r => r.url ? `✓ ${r.listener} → ${r.url} (127.0.0.1:${r.port})` : `- ${r.listener}: no Tailscale Serve route (fine if another proxy exposes it)`)
|
|
566
|
+
: [];
|
|
567
|
+
lines.push(...tailscaleHealthProblems(health).map(p => `⚠ ${p}`));
|
|
568
|
+
return lines.map((line, i) => `${i ? " ".repeat(11) : "tailscale: "}${line}`);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Daemon health check: keep Tailscale Serve routes pointed at the listeners' current ports.
|
|
573
|
+
* Re-points a moved listener's route on this node; only reports stale routes (removing a
|
|
574
|
+
* route is the user's call) and a Tailscale CLI that doesn't answer. Returns log lines, empty
|
|
575
|
+
* when healthy or Tailscale is absent.
|
|
576
|
+
*/
|
|
577
|
+
export async function checkTailscaleServe(listeners: HealthListener[]): Promise<string[]> {
|
|
578
|
+
const health = await inspectTailscaleServe(listeners);
|
|
579
|
+
const binary = tailscaleBinary();
|
|
580
|
+
if (health.state !== "ok" || !binary) return tailscaleHealthProblems(health).map(p => `tailscale serve: ${p}`);
|
|
581
|
+
const lines: string[] = [];
|
|
582
|
+
for (const r of health.repair) {
|
|
583
|
+
const args = ["serve", "--bg", `--https=${httpsPortOf(r.hostPort)}`, `--set-path=${r.mount}`, r.proxy];
|
|
584
|
+
const proc = Bun.spawn([binary, ...args], { env: TAILSCALE_ENV, stdout: "ignore", stderr: "pipe", windowsHide: true });
|
|
585
|
+
const ok = await proc.exited === 0;
|
|
586
|
+
lines.push(ok
|
|
587
|
+
? `tailscale serve: re-pointed ${r.hostPort}${r.mount} from 127.0.0.1:${r.from} to ${r.to}`
|
|
588
|
+
: `tailscale serve: ${r.hostPort}${r.mount} points at 127.0.0.1:${r.from}, listener is on ${r.to}; re-pointing failed (${(await new Response(proc.stderr).text()).trim()}). Run: tailscale ${args.join(" ")}`);
|
|
589
|
+
}
|
|
590
|
+
lines.push(...tailscaleHealthProblems({ ...health, repair: [] }).map(p => `tailscale serve: ${p}`));
|
|
591
|
+
return lines;
|
|
592
|
+
}
|
|
593
|
+
|
|
448
594
|
/** The same connection (key and profile) at another base URL, e.g. where a proxy exposes the listener. */
|
|
449
595
|
export function rebaseConnectionUrl(routeUrl: string, localUrl: string): string {
|
|
450
596
|
const local = new URL(localUrl);
|
|
@@ -1408,7 +1554,7 @@ async function exposeWithTailscaleServe(listener: Listener): Promise<string | nu
|
|
|
1408
1554
|
const mount = prefix === "/" ? "/" : prefix.slice(0, -1);
|
|
1409
1555
|
const args = ["serve", "--bg", `--set-path=${mount}`, `http://127.0.0.1:${listener.port}${mount === "/" ? "" : mount}`];
|
|
1410
1556
|
console.error(`Running: tailscale ${args.join(" ")}`);
|
|
1411
|
-
const proc = Bun.spawn([binary, ...args], { stdout: "ignore", stderr: "inherit", windowsHide: true });
|
|
1557
|
+
const proc = Bun.spawn([binary, ...args], { env: TAILSCALE_ENV, stdout: "ignore", stderr: "inherit", windowsHide: true });
|
|
1412
1558
|
if (await proc.exited !== 0) {
|
|
1413
1559
|
console.error(`tailscale serve failed. Run it yourself, then rech share again:\n tailscale ${args.join(" ")}`);
|
|
1414
1560
|
return null;
|
|
@@ -1669,7 +1815,9 @@ async function callServe(
|
|
|
1669
1815
|
return JSON.parse(text);
|
|
1670
1816
|
} catch {
|
|
1671
1817
|
// Not the daemon answering, e.g. a reverse proxy's 404 because the URL's path prefix is wrong.
|
|
1672
|
-
|
|
1818
|
+
let detail = `HTTP ${res.status} from ${serviceUrl(url, "run")} is not a rechrome daemon response: ${text.slice(0, 200).trim()}`;
|
|
1819
|
+
if (res.status === 502 || res.status === 503 || res.status === 504)
|
|
1820
|
+
detail += `\n The proxy is up but nothing answers behind it: this link's listener was likely removed or moved. Ask its owner for a fresh link (\`rech share\`), then \`rechrome connect <link>\`.`;
|
|
1673
1821
|
if (throwOnFailure) throw new Error(detail);
|
|
1674
1822
|
console.error(`[rech] rech-client -> ${serviceUrl(url, "run")}\n -x: ${detail}`);
|
|
1675
1823
|
process.exit(1);
|
|
@@ -2618,6 +2766,9 @@ async function provisionExtensionToken(opts: {
|
|
|
2618
2766
|
`--profile-directory=${profileDir}`,
|
|
2619
2767
|
`--load-extension=${dist}`,
|
|
2620
2768
|
`--disable-extensions-except=${dist}`,
|
|
2769
|
+
// Chromium 137+ ignores --load-extension unless this feature is off (Chrome for Testing
|
|
2770
|
+
// too): the extension silently never loads and status.html is an error page.
|
|
2771
|
+
"--disable-features=DisableLoadExtensionCommandLineSwitch",
|
|
2621
2772
|
"--remote-debugging-port=0",
|
|
2622
2773
|
"--no-first-run",
|
|
2623
2774
|
"--no-default-browser-check",
|
|
@@ -2625,40 +2776,70 @@ async function provisionExtensionToken(opts: {
|
|
|
2625
2776
|
];
|
|
2626
2777
|
if (!opts.headed) args.push("--headless=new");
|
|
2627
2778
|
if (process.platform === "linux") args.push("--no-sandbox");
|
|
2779
|
+
// Headless can't answer a Keychain prompt, so first launch could stall on it (as Playwright does).
|
|
2780
|
+
if (process.platform === "darwin") args.push("--use-mock-keychain");
|
|
2628
2781
|
args.push("about:blank");
|
|
2629
2782
|
const proc = Bun.spawn([chromeBin, ...args], { stdout: "ignore", stderr: "ignore", windowsHide: true });
|
|
2630
2783
|
let cdp: CDPClient | null = null;
|
|
2784
|
+
// Every step is bounded: a stuck Chrome must end in an error, never a silent hang.
|
|
2785
|
+
const within = <T>(ms: number, what: string, work: Promise<T>) => Promise.race([work,
|
|
2786
|
+
Bun.sleep(ms).then(() => { throw new Error(`${what} took over ${ms / 1000}s`); })]);
|
|
2631
2787
|
try {
|
|
2632
2788
|
// Chrome writes the chosen port to DevToolsActivePort once the debug server is up.
|
|
2633
2789
|
let port: number | null = null;
|
|
2634
|
-
for (let i = 0; i <
|
|
2790
|
+
for (let i = 0; i < 300; i++) {
|
|
2635
2791
|
await Bun.sleep(100);
|
|
2636
2792
|
const line = (await file(portFile).text().catch(() => "")).split("\n")[0]?.trim();
|
|
2637
2793
|
if (line && /^\d+$/.test(line)) { port = parseInt(line); break; }
|
|
2638
2794
|
if (proc.exitCode !== null) throw new Error("Chrome exited before opening the DevTools port");
|
|
2639
2795
|
}
|
|
2640
|
-
if (!port) throw new Error("Chrome
|
|
2641
|
-
const ver = await (await fetch(`http://127.0.0.1:${port}/json/version
|
|
2796
|
+
if (!port) throw new Error("Chrome didn't open its DevTools port within 30s");
|
|
2797
|
+
const ver = await (await fetch(`http://127.0.0.1:${port}/json/version`, { signal: AbortSignal.timeout(5000) })).json();
|
|
2642
2798
|
cdp = new CDPClient(ver.webSocketDebuggerUrl as string);
|
|
2643
|
-
await cdp.open();
|
|
2644
|
-
|
|
2799
|
+
await within(5000, "Connecting to Chrome DevTools", cdp.open());
|
|
2800
|
+
// Wait for the extension's service worker: if it never shows, the extension didn't load.
|
|
2801
|
+
const extensionUrl = `chrome-extension://${EXTENSION_ID}/`;
|
|
2802
|
+
let loaded = false;
|
|
2803
|
+
for (let i = 0; i < 50 && !loaded; i++) {
|
|
2804
|
+
const { targetInfos } = await cdp.send("Target.getTargets");
|
|
2805
|
+
loaded = (targetInfos as { url: string }[]).some(t => t.url.startsWith(extensionUrl));
|
|
2806
|
+
if (!loaded) await Bun.sleep(200);
|
|
2807
|
+
}
|
|
2808
|
+
if (!loaded) throw new Error(`The extension at ${dist} didn't load in Chrome for Testing (no ${extensionUrl} target within 10s)`);
|
|
2809
|
+
const { targetId } = await cdp.send("Target.createTarget", { url: `${extensionUrl}status.html` });
|
|
2645
2810
|
const { sessionId } = await cdp.send("Target.attachToTarget", { targetId, flatten: true });
|
|
2646
2811
|
// The extension page may still be loading; retry the write until localStorage reflects it.
|
|
2647
|
-
let ok = false;
|
|
2812
|
+
let ok = false, last = "";
|
|
2648
2813
|
const expr = `(()=>{try{localStorage.setItem('auth-token',${JSON.stringify(token)});return localStorage.getItem('auth-token');}catch(e){return 'ERR:'+e.message}})()`;
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2814
|
+
const deadline = Date.now() + 10_000;
|
|
2815
|
+
while (Date.now() < deadline) {
|
|
2816
|
+
const r = await within(3000, "Runtime.evaluate", cdp.send("Runtime.evaluate", { expression: expr, returnByValue: true }, sessionId)).catch(e => ({ error: String(e) }));
|
|
2817
|
+
const value = (r as any)?.result?.value;
|
|
2818
|
+
if (value === token) { ok = true; break; }
|
|
2819
|
+
last = typeof value === "string" ? value : (r as any)?.error ?? (r as any)?.result?.description ?? "";
|
|
2820
|
+
await Bun.sleep(200);
|
|
2653
2821
|
}
|
|
2654
|
-
if (!ok) throw new Error(`Could not seed auth token into
|
|
2822
|
+
if (!ok) throw new Error(`Could not seed auth token into ${extensionUrl}${last ? ` (${last})` : ""}`);
|
|
2655
2823
|
// Graceful close flushes localStorage to the profile's leveldb before we kill Chrome.
|
|
2656
|
-
await cdp.send("Browser.close").catch(() => {});
|
|
2824
|
+
await within(5000, "Browser.close", cdp.send("Browser.close")).catch(() => {});
|
|
2825
|
+
await within(5000, "Chrome exit", proc.exited).catch(() => {});
|
|
2657
2826
|
} finally {
|
|
2658
2827
|
cdp?.close();
|
|
2828
|
+
await stopThrowawayChrome(proc, userDataDir);
|
|
2829
|
+
}
|
|
2830
|
+
}
|
|
2831
|
+
|
|
2832
|
+
/**
|
|
2833
|
+
* Stop a Chrome we launched on a throwaway user-data-dir: SIGTERM, then SIGKILL, then any helper
|
|
2834
|
+
* still holding that exact --user-data-dir (scoped to it, so never the user's own Chrome).
|
|
2835
|
+
*/
|
|
2836
|
+
async function stopThrowawayChrome(proc: ReturnType<typeof Bun.spawn>, userDataDir: string): Promise<void> {
|
|
2837
|
+
if (proc.exitCode === null) {
|
|
2659
2838
|
try { proc.kill(); } catch {}
|
|
2660
|
-
await proc.exited.
|
|
2839
|
+
const exited = await Promise.race([proc.exited.then(() => true, () => true), Bun.sleep(3000).then(() => false)]);
|
|
2840
|
+
if (!exited) { try { proc.kill("SIGKILL"); } catch {} await Promise.race([proc.exited.catch(() => {}), Bun.sleep(2000)]); }
|
|
2661
2841
|
}
|
|
2842
|
+
if (process.platform !== "win32") Bun.spawnSync(["pkill", "-9", "-f", `--user-data-dir=${userDataDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}( |$)`], { stdout: "ignore", stderr: "ignore" });
|
|
2662
2843
|
}
|
|
2663
2844
|
|
|
2664
2845
|
async function provisionProfile(name: string, opts: { headed?: boolean } = {}): Promise<void> {
|
|
@@ -2759,6 +2940,7 @@ async function removeListener(name: string): Promise<void> {
|
|
|
2759
2940
|
config.listeners = config.listeners.filter(l => l !== listener);
|
|
2760
2941
|
await writeListeners(config);
|
|
2761
2942
|
console.log("Listener removed from configuration; daemon reloads automatically.");
|
|
2943
|
+
await removeTailscaleServeRoutes(listener);
|
|
2762
2944
|
}
|
|
2763
2945
|
|
|
2764
2946
|
async function resolveProfileKeys(selectors: string[]): Promise<string[]> {
|
|
@@ -2851,6 +3033,8 @@ async function urlList(): Promise<void> {
|
|
|
2851
3033
|
printTable(rows);
|
|
2852
3034
|
const wide = config.listeners.filter(l => l.profiles !== "*" && l.profiles.length > 1);
|
|
2853
3035
|
if (wide.length) console.log(`\nOne key covers several profiles on: ${wide.map(l => `${l.name} (${(l.profiles as string[]).length})`).join(", ")}. Anyone with such a link can use each of them.`);
|
|
3036
|
+
const tailscale = formatTailscaleHealth(await inspectTailscaleServe(config.listeners));
|
|
3037
|
+
if (tailscale.length) console.log(`\n${tailscale.join("\n")}`);
|
|
2854
3038
|
console.log(`\nPrint a full URL (contains the secret key): rech share <profile>, or rech share --all`);
|
|
2855
3039
|
}
|
|
2856
3040
|
|
|
@@ -3302,7 +3486,11 @@ async function status(): Promise<void> {
|
|
|
3302
3486
|
: null;
|
|
3303
3487
|
// Show the URL this client connects to; through a proxy, the daemon's bind is on another port.
|
|
3304
3488
|
const details = [pingBody?.listener && `listener ${pingBody.listener}`, pingBody?.bind && `bind ${pingBody.bind}`].filter(Boolean).join(", ");
|
|
3305
|
-
|
|
3489
|
+
// Any HTTP answer is not the daemon: a proxy's 404/502 means the link's listener is gone or moved.
|
|
3490
|
+
const answered = pingResponse?.ok || pingResponse?.status === 401;
|
|
3491
|
+
console.log(`serve: ${!ping ? `not reachable at ${serviceUrl(url)}`
|
|
3492
|
+
: answered ? `running ${serviceUrl(url)}${details ? ` (${details})` : ""}`
|
|
3493
|
+
: `✗ ${serviceUrl(url)} answers HTTP ${pingResponse?.status ?? ping.status}, not a rechrome daemon — this link's listener was likely removed or moved; ask its host for a fresh link (\`rech share <profile>\`), then \`rech connect '<url>'\``}`);
|
|
3306
3494
|
if (pingResponse?.status === 401)
|
|
3307
3495
|
console.log(`auth: ✗ key rejected — ask the host for a fresh URL (\`rech share <profile>\`), then \`rech connect '<url>'\``);
|
|
3308
3496
|
// daemonManager().id — there is no PM_BIN constant. Referencing one threw a
|
|
@@ -3325,7 +3513,11 @@ async function status(): Promise<void> {
|
|
|
3325
3513
|
const current = effective ? await resolveProfileEmail(effective).catch(() => effective) : undefined;
|
|
3326
3514
|
const allowed = pingBody?.profiles === "*" ? "all registered profiles" : pingBody?.profiles?.join(", ");
|
|
3327
3515
|
console.log(`profile: ${current ?? "(none selected; add ?profile= to the URL or pass --profile)"}${allowed ? ` — this listener serves: ${allowed}` : ""}`);
|
|
3328
|
-
if (isHost)
|
|
3516
|
+
if (isHost) {
|
|
3517
|
+
const listeners = (await readListeners().catch(() => null))?.listeners ?? [];
|
|
3518
|
+
for (const line of formatTailscaleHealth(await inspectTailscaleServe(listeners))) console.log(line);
|
|
3519
|
+
console.log(`\nMore: rech profile (profiles) · rech share ls (who can connect, and where)`);
|
|
3520
|
+
}
|
|
3329
3521
|
}
|
|
3330
3522
|
|
|
3331
3523
|
|
package/rechrome.ts
CHANGED
|
@@ -377,20 +377,32 @@ export function buildListenChoices(interfaces: ReturnType<typeof networkInterfac
|
|
|
377
377
|
return choices.sort((a, b) => rank[a.kind] - rank[b.kind]);
|
|
378
378
|
}
|
|
379
379
|
|
|
380
|
+
// The Mac app's binary only acts as the CLI when it is told to (or has a terminal): from a
|
|
381
|
+
// daemon it tries to start the GUI and fails. Harmless for the standalone CLI.
|
|
382
|
+
const TAILSCALE_ENV = { ...process.env, TAILSCALE_BE_CLI: "1" };
|
|
380
383
|
const tailscaleBinary = () => Bun.which("tailscale") || (existsSync("/Applications/Tailscale.app/Contents/MacOS/Tailscale") ? "/Applications/Tailscale.app/Contents/MacOS/Tailscale" : null);
|
|
381
384
|
|
|
382
|
-
/** Run the Tailscale CLI read-only
|
|
383
|
-
async function
|
|
385
|
+
/** Run the Tailscale CLI read-only: its output, or why it gave none (missing, failed, slow). */
|
|
386
|
+
async function runTailscaleDetailed(args: string[]): Promise<{ output: string } | { error: string; missing?: true }> {
|
|
384
387
|
const binary = tailscaleBinary();
|
|
385
|
-
if (!binary) return
|
|
388
|
+
if (!binary) return { error: "not installed", missing: true };
|
|
386
389
|
try {
|
|
387
|
-
const proc = Bun.spawn([binary, ...args], { stdout: "pipe", stderr: "
|
|
388
|
-
|
|
390
|
+
const proc = Bun.spawn([binary, ...args], { env: TAILSCALE_ENV, stdout: "pipe", stderr: "pipe" });
|
|
391
|
+
let timedOut = false;
|
|
392
|
+
const timeout = setTimeout(() => { timedOut = true; proc.kill(); }, 2000);
|
|
389
393
|
try {
|
|
390
|
-
const output = await new Response(proc.stdout).text();
|
|
391
|
-
|
|
394
|
+
const [output, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
|
395
|
+
if (await proc.exited === 0) return { output };
|
|
396
|
+
const reason = timedOut ? "no answer within 2s" : (stderr.trim() || output.trim()).split("\n")[0] || `exit ${proc.exitCode}`;
|
|
397
|
+
return { error: `\`tailscale ${args.join(" ")}\`: ${reason}` };
|
|
392
398
|
} finally { clearTimeout(timeout); }
|
|
393
|
-
} catch { return
|
|
399
|
+
} catch (error) { return { error: String(error) }; }
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Run the Tailscale CLI read-only; null when it is missing, disconnected, or slow. */
|
|
403
|
+
async function runTailscale(args: string[]): Promise<string | null> {
|
|
404
|
+
const result = await runTailscaleDetailed(args);
|
|
405
|
+
return "output" in result ? result.output : null;
|
|
394
406
|
}
|
|
395
407
|
|
|
396
408
|
export async function detectListenChoices(): Promise<ListenChoice[]> {
|
|
@@ -445,6 +457,140 @@ export async function detectTailscaleServe(port: number, prefix: string): Promis
|
|
|
445
457
|
return { dnsName, routeUrl, ...serveMountsAndFunnel(serveStatus, routeUrl ? new URL(routeUrl).hostname : dnsName) };
|
|
446
458
|
}
|
|
447
459
|
|
|
460
|
+
type ServeHandler = { hostPort: string; mount: string; target: URL };
|
|
461
|
+
|
|
462
|
+
/** Every Serve route that proxies to a loopback HTTP port. */
|
|
463
|
+
function loopbackServeHandlers(serveStatus: unknown): ServeHandler[] {
|
|
464
|
+
const web = (serveStatus as { Web?: Record<string, { Handlers?: Record<string, { Proxy?: string }> }> } | null)?.Web ?? {};
|
|
465
|
+
const handlers: ServeHandler[] = [];
|
|
466
|
+
for (const [hostPort, config] of Object.entries(web)) {
|
|
467
|
+
for (const [path, handler] of Object.entries(config.Handlers ?? {})) {
|
|
468
|
+
if (!handler.Proxy) continue;
|
|
469
|
+
let target: URL;
|
|
470
|
+
try { target = new URL(/^[a-z]+:\/\//i.test(handler.Proxy) ? handler.Proxy : `http://${handler.Proxy}`); } catch { continue; }
|
|
471
|
+
if (!["127.0.0.1", "localhost", "[::1]"].includes(target.hostname) || !target.port) continue;
|
|
472
|
+
handlers.push({ hostPort, mount: path.replace(/\/+$/, "") || "/", target });
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return handlers;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
export type ServeRepair = { hostPort: string; mount: string; from: number; to: number; proxy: string };
|
|
479
|
+
export type ServeStale = { hostPort: string; mount: string; port: number };
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Health check for Tailscale Serve against listeners.json. `repair`: a route on a listener's own
|
|
483
|
+
* mount that proxies to another port (the listener moved), re-pointed at the listener's port.
|
|
484
|
+
* `stale`: a route next to a listener's mount (same first path segment, e.g. /rechrome beside
|
|
485
|
+
* /rechrome/taku) whose port no listener uses — a removed listener's leftover, which answers 502.
|
|
486
|
+
* Root ("/") mounts are never touched: they could be anything.
|
|
487
|
+
*/
|
|
488
|
+
export function planTailscaleServeRepairs(serveStatus: unknown, listeners: { port: number; prefix?: string }[]): { repair: ServeRepair[]; stale: ServeStale[] } {
|
|
489
|
+
const ports = new Set(listeners.map(l => l.port));
|
|
490
|
+
const mounts = new Map<string, number>();
|
|
491
|
+
for (const l of listeners) {
|
|
492
|
+
const mount = normalizePrefix(l.prefix).replace(/\/+$/, "");
|
|
493
|
+
if (mount) mounts.set(mount, l.port);
|
|
494
|
+
}
|
|
495
|
+
const roots = new Set([...mounts.keys()].map(m => m.split("/")[1]));
|
|
496
|
+
const repair: ServeRepair[] = [], stale: ServeStale[] = [];
|
|
497
|
+
for (const { hostPort, mount, target } of loopbackServeHandlers(serveStatus)) {
|
|
498
|
+
const port = Number(target.port), want = mounts.get(mount);
|
|
499
|
+
if (want !== undefined) {
|
|
500
|
+
if (port !== want) {
|
|
501
|
+
const proxy = new URL(target); proxy.port = String(want);
|
|
502
|
+
repair.push({ hostPort, mount, from: port, to: want, proxy: proxy.href.replace(/\/$/, mount === "/" ? "/" : "") });
|
|
503
|
+
}
|
|
504
|
+
} else if (mount !== "/" && roots.has(mount.split("/")[1]) && !ports.has(port)) {
|
|
505
|
+
stale.push({ hostPort, mount, port });
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return { repair, stale };
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** Drop the Serve routes that proxy to a removed listener, so they don't linger answering 502. */
|
|
512
|
+
async function removeTailscaleServeRoutes(listener: { port: number; prefix?: string }): Promise<void> {
|
|
513
|
+
const binary = tailscaleBinary(), raw = binary && await runTailscale(["serve", "status", "--json"]);
|
|
514
|
+
if (!binary || !raw) return;
|
|
515
|
+
let serveStatus: unknown;
|
|
516
|
+
try { serveStatus = JSON.parse(raw); } catch { return; }
|
|
517
|
+
const mount = normalizePrefix(listener.prefix).replace(/\/+$/, "") || "/";
|
|
518
|
+
for (const h of loopbackServeHandlers(serveStatus)) {
|
|
519
|
+
if (Number(h.target.port) !== listener.port || h.mount !== mount) continue;
|
|
520
|
+
const args = ["serve", `--https=${h.hostPort.split(/:(?=\d+$)/)[1] ?? "443"}`, `--set-path=${h.mount}`, "off"];
|
|
521
|
+
const ok = await Bun.spawn([binary, ...args], { env: TAILSCALE_ENV, stdout: "ignore", stderr: "inherit", windowsHide: true }).exited === 0;
|
|
522
|
+
console.log(ok ? `Removed its Tailscale Serve route ${h.hostPort}${h.mount}.` : `Remove its Tailscale Serve route yourself: tailscale ${args.join(" ")}`);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
type HealthListener = Pick<Listener, "name" | "port" | "prefix" | "profiles">;
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Read-only Tailscale Serve health for the listeners: `absent` (no Tailscale here), `unresponsive`
|
|
530
|
+
* (installed, but its CLI doesn't answer — e.g. not running, or the Mac app's GUI binary), or
|
|
531
|
+
* `ok` with each shared listener's route plus the routes to re-point or remove.
|
|
532
|
+
*/
|
|
533
|
+
export type TailscaleHealth =
|
|
534
|
+
| { state: "absent" }
|
|
535
|
+
| { state: "unresponsive"; error: string }
|
|
536
|
+
| { state: "ok"; routes: { listener: string; port: number; url: string | null }[]; repair: ServeRepair[]; stale: ServeStale[] };
|
|
537
|
+
|
|
538
|
+
export async function inspectTailscaleServe(listeners: HealthListener[]): Promise<TailscaleHealth> {
|
|
539
|
+
const [serve, status] = await Promise.all([runTailscaleDetailed(["serve", "status", "--json"]), runTailscale(["status", "--json"])]);
|
|
540
|
+
if (!("output" in serve)) return serve.missing ? { state: "absent" } : { state: "unresponsive", error: serve.error };
|
|
541
|
+
let serveStatus: unknown, dnsName: string | null = null;
|
|
542
|
+
try { serveStatus = JSON.parse(serve.output || "null"); } catch { return { state: "unresponsive", error: "`tailscale serve status --json` printed no JSON" }; }
|
|
543
|
+
try { dnsName = (JSON.parse(status ?? "null")?.Self?.DNSName as string | undefined)?.replace(/\.$/, "") || null; } catch { /* not connected */ }
|
|
544
|
+
const routes = listeners.filter(l => l.profiles !== "*")
|
|
545
|
+
.map(l => ({ listener: l.name, port: l.port, url: findTailscaleServeRoute(dnsName, serveStatus, l.port, normalizePrefix(l.prefix)) }));
|
|
546
|
+
return { state: "ok", routes, ...planTailscaleServeRepairs(serveStatus, listeners) };
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const httpsPortOf = (hostPort: string) => hostPort.split(/:(?=\d+$)/)[1] ?? "443";
|
|
550
|
+
|
|
551
|
+
/** Problems in a health report, one line each with the command that fixes it; empty when healthy. */
|
|
552
|
+
export function tailscaleHealthProblems(health: TailscaleHealth): string[] {
|
|
553
|
+
if (health.state === "absent") return [];
|
|
554
|
+
if (health.state === "unresponsive") return [`Tailscale is installed but its CLI doesn't answer, so Serve routes go unchecked: ${health.error}`];
|
|
555
|
+
return [
|
|
556
|
+
...health.repair.map(r => `${r.hostPort}${r.mount} proxies to 127.0.0.1:${r.from}, but the listener is on ${r.to} (clients get HTTP 502). Fix: tailscale serve --bg --https=${httpsPortOf(r.hostPort)} --set-path=${r.mount} ${r.proxy}`),
|
|
557
|
+
...health.stale.map(st => `${st.hostPort}${st.mount} proxies to 127.0.0.1:${st.port}, where no listener runs (clients get HTTP 502). Remove it: tailscale serve --https=${httpsPortOf(st.hostPort)} --set-path=${st.mount} off`),
|
|
558
|
+
];
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** The `tailscale:` block of `rech status` / `rech share ls`: each shared listener's route, then problems. */
|
|
562
|
+
export function formatTailscaleHealth(health: TailscaleHealth): string[] {
|
|
563
|
+
if (health.state === "absent") return [];
|
|
564
|
+
const lines = health.state === "ok"
|
|
565
|
+
? health.routes.map(r => r.url ? `✓ ${r.listener} → ${r.url} (127.0.0.1:${r.port})` : `- ${r.listener}: no Tailscale Serve route (fine if another proxy exposes it)`)
|
|
566
|
+
: [];
|
|
567
|
+
lines.push(...tailscaleHealthProblems(health).map(p => `⚠ ${p}`));
|
|
568
|
+
return lines.map((line, i) => `${i ? " ".repeat(11) : "tailscale: "}${line}`);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Daemon health check: keep Tailscale Serve routes pointed at the listeners' current ports.
|
|
573
|
+
* Re-points a moved listener's route on this node; only reports stale routes (removing a
|
|
574
|
+
* route is the user's call) and a Tailscale CLI that doesn't answer. Returns log lines, empty
|
|
575
|
+
* when healthy or Tailscale is absent.
|
|
576
|
+
*/
|
|
577
|
+
export async function checkTailscaleServe(listeners: HealthListener[]): Promise<string[]> {
|
|
578
|
+
const health = await inspectTailscaleServe(listeners);
|
|
579
|
+
const binary = tailscaleBinary();
|
|
580
|
+
if (health.state !== "ok" || !binary) return tailscaleHealthProblems(health).map(p => `tailscale serve: ${p}`);
|
|
581
|
+
const lines: string[] = [];
|
|
582
|
+
for (const r of health.repair) {
|
|
583
|
+
const args = ["serve", "--bg", `--https=${httpsPortOf(r.hostPort)}`, `--set-path=${r.mount}`, r.proxy];
|
|
584
|
+
const proc = Bun.spawn([binary, ...args], { env: TAILSCALE_ENV, stdout: "ignore", stderr: "pipe", windowsHide: true });
|
|
585
|
+
const ok = await proc.exited === 0;
|
|
586
|
+
lines.push(ok
|
|
587
|
+
? `tailscale serve: re-pointed ${r.hostPort}${r.mount} from 127.0.0.1:${r.from} to ${r.to}`
|
|
588
|
+
: `tailscale serve: ${r.hostPort}${r.mount} points at 127.0.0.1:${r.from}, listener is on ${r.to}; re-pointing failed (${(await new Response(proc.stderr).text()).trim()}). Run: tailscale ${args.join(" ")}`);
|
|
589
|
+
}
|
|
590
|
+
lines.push(...tailscaleHealthProblems({ ...health, repair: [] }).map(p => `tailscale serve: ${p}`));
|
|
591
|
+
return lines;
|
|
592
|
+
}
|
|
593
|
+
|
|
448
594
|
/** The same connection (key and profile) at another base URL, e.g. where a proxy exposes the listener. */
|
|
449
595
|
export function rebaseConnectionUrl(routeUrl: string, localUrl: string): string {
|
|
450
596
|
const local = new URL(localUrl);
|
|
@@ -1408,7 +1554,7 @@ async function exposeWithTailscaleServe(listener: Listener): Promise<string | nu
|
|
|
1408
1554
|
const mount = prefix === "/" ? "/" : prefix.slice(0, -1);
|
|
1409
1555
|
const args = ["serve", "--bg", `--set-path=${mount}`, `http://127.0.0.1:${listener.port}${mount === "/" ? "" : mount}`];
|
|
1410
1556
|
console.error(`Running: tailscale ${args.join(" ")}`);
|
|
1411
|
-
const proc = Bun.spawn([binary, ...args], { stdout: "ignore", stderr: "inherit", windowsHide: true });
|
|
1557
|
+
const proc = Bun.spawn([binary, ...args], { env: TAILSCALE_ENV, stdout: "ignore", stderr: "inherit", windowsHide: true });
|
|
1412
1558
|
if (await proc.exited !== 0) {
|
|
1413
1559
|
console.error(`tailscale serve failed. Run it yourself, then rech share again:\n tailscale ${args.join(" ")}`);
|
|
1414
1560
|
return null;
|
|
@@ -1669,7 +1815,9 @@ async function callServe(
|
|
|
1669
1815
|
return JSON.parse(text);
|
|
1670
1816
|
} catch {
|
|
1671
1817
|
// Not the daemon answering, e.g. a reverse proxy's 404 because the URL's path prefix is wrong.
|
|
1672
|
-
|
|
1818
|
+
let detail = `HTTP ${res.status} from ${serviceUrl(url, "run")} is not a rechrome daemon response: ${text.slice(0, 200).trim()}`;
|
|
1819
|
+
if (res.status === 502 || res.status === 503 || res.status === 504)
|
|
1820
|
+
detail += `\n The proxy is up but nothing answers behind it: this link's listener was likely removed or moved. Ask its owner for a fresh link (\`rech share\`), then \`rechrome connect <link>\`.`;
|
|
1673
1821
|
if (throwOnFailure) throw new Error(detail);
|
|
1674
1822
|
console.error(`[rech] rech-client -> ${serviceUrl(url, "run")}\n -x: ${detail}`);
|
|
1675
1823
|
process.exit(1);
|
|
@@ -2618,6 +2766,9 @@ async function provisionExtensionToken(opts: {
|
|
|
2618
2766
|
`--profile-directory=${profileDir}`,
|
|
2619
2767
|
`--load-extension=${dist}`,
|
|
2620
2768
|
`--disable-extensions-except=${dist}`,
|
|
2769
|
+
// Chromium 137+ ignores --load-extension unless this feature is off (Chrome for Testing
|
|
2770
|
+
// too): the extension silently never loads and status.html is an error page.
|
|
2771
|
+
"--disable-features=DisableLoadExtensionCommandLineSwitch",
|
|
2621
2772
|
"--remote-debugging-port=0",
|
|
2622
2773
|
"--no-first-run",
|
|
2623
2774
|
"--no-default-browser-check",
|
|
@@ -2625,40 +2776,70 @@ async function provisionExtensionToken(opts: {
|
|
|
2625
2776
|
];
|
|
2626
2777
|
if (!opts.headed) args.push("--headless=new");
|
|
2627
2778
|
if (process.platform === "linux") args.push("--no-sandbox");
|
|
2779
|
+
// Headless can't answer a Keychain prompt, so first launch could stall on it (as Playwright does).
|
|
2780
|
+
if (process.platform === "darwin") args.push("--use-mock-keychain");
|
|
2628
2781
|
args.push("about:blank");
|
|
2629
2782
|
const proc = Bun.spawn([chromeBin, ...args], { stdout: "ignore", stderr: "ignore", windowsHide: true });
|
|
2630
2783
|
let cdp: CDPClient | null = null;
|
|
2784
|
+
// Every step is bounded: a stuck Chrome must end in an error, never a silent hang.
|
|
2785
|
+
const within = <T>(ms: number, what: string, work: Promise<T>) => Promise.race([work,
|
|
2786
|
+
Bun.sleep(ms).then(() => { throw new Error(`${what} took over ${ms / 1000}s`); })]);
|
|
2631
2787
|
try {
|
|
2632
2788
|
// Chrome writes the chosen port to DevToolsActivePort once the debug server is up.
|
|
2633
2789
|
let port: number | null = null;
|
|
2634
|
-
for (let i = 0; i <
|
|
2790
|
+
for (let i = 0; i < 300; i++) {
|
|
2635
2791
|
await Bun.sleep(100);
|
|
2636
2792
|
const line = (await file(portFile).text().catch(() => "")).split("\n")[0]?.trim();
|
|
2637
2793
|
if (line && /^\d+$/.test(line)) { port = parseInt(line); break; }
|
|
2638
2794
|
if (proc.exitCode !== null) throw new Error("Chrome exited before opening the DevTools port");
|
|
2639
2795
|
}
|
|
2640
|
-
if (!port) throw new Error("Chrome
|
|
2641
|
-
const ver = await (await fetch(`http://127.0.0.1:${port}/json/version
|
|
2796
|
+
if (!port) throw new Error("Chrome didn't open its DevTools port within 30s");
|
|
2797
|
+
const ver = await (await fetch(`http://127.0.0.1:${port}/json/version`, { signal: AbortSignal.timeout(5000) })).json();
|
|
2642
2798
|
cdp = new CDPClient(ver.webSocketDebuggerUrl as string);
|
|
2643
|
-
await cdp.open();
|
|
2644
|
-
|
|
2799
|
+
await within(5000, "Connecting to Chrome DevTools", cdp.open());
|
|
2800
|
+
// Wait for the extension's service worker: if it never shows, the extension didn't load.
|
|
2801
|
+
const extensionUrl = `chrome-extension://${EXTENSION_ID}/`;
|
|
2802
|
+
let loaded = false;
|
|
2803
|
+
for (let i = 0; i < 50 && !loaded; i++) {
|
|
2804
|
+
const { targetInfos } = await cdp.send("Target.getTargets");
|
|
2805
|
+
loaded = (targetInfos as { url: string }[]).some(t => t.url.startsWith(extensionUrl));
|
|
2806
|
+
if (!loaded) await Bun.sleep(200);
|
|
2807
|
+
}
|
|
2808
|
+
if (!loaded) throw new Error(`The extension at ${dist} didn't load in Chrome for Testing (no ${extensionUrl} target within 10s)`);
|
|
2809
|
+
const { targetId } = await cdp.send("Target.createTarget", { url: `${extensionUrl}status.html` });
|
|
2645
2810
|
const { sessionId } = await cdp.send("Target.attachToTarget", { targetId, flatten: true });
|
|
2646
2811
|
// The extension page may still be loading; retry the write until localStorage reflects it.
|
|
2647
|
-
let ok = false;
|
|
2812
|
+
let ok = false, last = "";
|
|
2648
2813
|
const expr = `(()=>{try{localStorage.setItem('auth-token',${JSON.stringify(token)});return localStorage.getItem('auth-token');}catch(e){return 'ERR:'+e.message}})()`;
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2814
|
+
const deadline = Date.now() + 10_000;
|
|
2815
|
+
while (Date.now() < deadline) {
|
|
2816
|
+
const r = await within(3000, "Runtime.evaluate", cdp.send("Runtime.evaluate", { expression: expr, returnByValue: true }, sessionId)).catch(e => ({ error: String(e) }));
|
|
2817
|
+
const value = (r as any)?.result?.value;
|
|
2818
|
+
if (value === token) { ok = true; break; }
|
|
2819
|
+
last = typeof value === "string" ? value : (r as any)?.error ?? (r as any)?.result?.description ?? "";
|
|
2820
|
+
await Bun.sleep(200);
|
|
2653
2821
|
}
|
|
2654
|
-
if (!ok) throw new Error(`Could not seed auth token into
|
|
2822
|
+
if (!ok) throw new Error(`Could not seed auth token into ${extensionUrl}${last ? ` (${last})` : ""}`);
|
|
2655
2823
|
// Graceful close flushes localStorage to the profile's leveldb before we kill Chrome.
|
|
2656
|
-
await cdp.send("Browser.close").catch(() => {});
|
|
2824
|
+
await within(5000, "Browser.close", cdp.send("Browser.close")).catch(() => {});
|
|
2825
|
+
await within(5000, "Chrome exit", proc.exited).catch(() => {});
|
|
2657
2826
|
} finally {
|
|
2658
2827
|
cdp?.close();
|
|
2828
|
+
await stopThrowawayChrome(proc, userDataDir);
|
|
2829
|
+
}
|
|
2830
|
+
}
|
|
2831
|
+
|
|
2832
|
+
/**
|
|
2833
|
+
* Stop a Chrome we launched on a throwaway user-data-dir: SIGTERM, then SIGKILL, then any helper
|
|
2834
|
+
* still holding that exact --user-data-dir (scoped to it, so never the user's own Chrome).
|
|
2835
|
+
*/
|
|
2836
|
+
async function stopThrowawayChrome(proc: ReturnType<typeof Bun.spawn>, userDataDir: string): Promise<void> {
|
|
2837
|
+
if (proc.exitCode === null) {
|
|
2659
2838
|
try { proc.kill(); } catch {}
|
|
2660
|
-
await proc.exited.
|
|
2839
|
+
const exited = await Promise.race([proc.exited.then(() => true, () => true), Bun.sleep(3000).then(() => false)]);
|
|
2840
|
+
if (!exited) { try { proc.kill("SIGKILL"); } catch {} await Promise.race([proc.exited.catch(() => {}), Bun.sleep(2000)]); }
|
|
2661
2841
|
}
|
|
2842
|
+
if (process.platform !== "win32") Bun.spawnSync(["pkill", "-9", "-f", `--user-data-dir=${userDataDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}( |$)`], { stdout: "ignore", stderr: "ignore" });
|
|
2662
2843
|
}
|
|
2663
2844
|
|
|
2664
2845
|
async function provisionProfile(name: string, opts: { headed?: boolean } = {}): Promise<void> {
|
|
@@ -2759,6 +2940,7 @@ async function removeListener(name: string): Promise<void> {
|
|
|
2759
2940
|
config.listeners = config.listeners.filter(l => l !== listener);
|
|
2760
2941
|
await writeListeners(config);
|
|
2761
2942
|
console.log("Listener removed from configuration; daemon reloads automatically.");
|
|
2943
|
+
await removeTailscaleServeRoutes(listener);
|
|
2762
2944
|
}
|
|
2763
2945
|
|
|
2764
2946
|
async function resolveProfileKeys(selectors: string[]): Promise<string[]> {
|
|
@@ -2851,6 +3033,8 @@ async function urlList(): Promise<void> {
|
|
|
2851
3033
|
printTable(rows);
|
|
2852
3034
|
const wide = config.listeners.filter(l => l.profiles !== "*" && l.profiles.length > 1);
|
|
2853
3035
|
if (wide.length) console.log(`\nOne key covers several profiles on: ${wide.map(l => `${l.name} (${(l.profiles as string[]).length})`).join(", ")}. Anyone with such a link can use each of them.`);
|
|
3036
|
+
const tailscale = formatTailscaleHealth(await inspectTailscaleServe(config.listeners));
|
|
3037
|
+
if (tailscale.length) console.log(`\n${tailscale.join("\n")}`);
|
|
2854
3038
|
console.log(`\nPrint a full URL (contains the secret key): rech share <profile>, or rech share --all`);
|
|
2855
3039
|
}
|
|
2856
3040
|
|
|
@@ -3302,7 +3486,11 @@ async function status(): Promise<void> {
|
|
|
3302
3486
|
: null;
|
|
3303
3487
|
// Show the URL this client connects to; through a proxy, the daemon's bind is on another port.
|
|
3304
3488
|
const details = [pingBody?.listener && `listener ${pingBody.listener}`, pingBody?.bind && `bind ${pingBody.bind}`].filter(Boolean).join(", ");
|
|
3305
|
-
|
|
3489
|
+
// Any HTTP answer is not the daemon: a proxy's 404/502 means the link's listener is gone or moved.
|
|
3490
|
+
const answered = pingResponse?.ok || pingResponse?.status === 401;
|
|
3491
|
+
console.log(`serve: ${!ping ? `not reachable at ${serviceUrl(url)}`
|
|
3492
|
+
: answered ? `running ${serviceUrl(url)}${details ? ` (${details})` : ""}`
|
|
3493
|
+
: `✗ ${serviceUrl(url)} answers HTTP ${pingResponse?.status ?? ping.status}, not a rechrome daemon — this link's listener was likely removed or moved; ask its host for a fresh link (\`rech share <profile>\`), then \`rech connect '<url>'\``}`);
|
|
3306
3494
|
if (pingResponse?.status === 401)
|
|
3307
3495
|
console.log(`auth: ✗ key rejected — ask the host for a fresh URL (\`rech share <profile>\`), then \`rech connect '<url>'\``);
|
|
3308
3496
|
// daemonManager().id — there is no PM_BIN constant. Referencing one threw a
|
|
@@ -3325,7 +3513,11 @@ async function status(): Promise<void> {
|
|
|
3325
3513
|
const current = effective ? await resolveProfileEmail(effective).catch(() => effective) : undefined;
|
|
3326
3514
|
const allowed = pingBody?.profiles === "*" ? "all registered profiles" : pingBody?.profiles?.join(", ");
|
|
3327
3515
|
console.log(`profile: ${current ?? "(none selected; add ?profile= to the URL or pass --profile)"}${allowed ? ` — this listener serves: ${allowed}` : ""}`);
|
|
3328
|
-
if (isHost)
|
|
3516
|
+
if (isHost) {
|
|
3517
|
+
const listeners = (await readListeners().catch(() => null))?.listeners ?? [];
|
|
3518
|
+
for (const line of formatTailscaleHealth(await inspectTailscaleServe(listeners))) console.log(line);
|
|
3519
|
+
console.log(`\nMore: rech profile (profiles) · rech share ls (who can connect, and where)`);
|
|
3520
|
+
}
|
|
3329
3521
|
}
|
|
3330
3522
|
|
|
3331
3523
|
|
package/serve.js
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
resolvePlaywrightCli,
|
|
18
18
|
readTokenRegistry,
|
|
19
19
|
readChromeProfileCache,
|
|
20
|
+
checkTailscaleServe,
|
|
20
21
|
} from "./rechrome.js";
|
|
21
22
|
|
|
22
23
|
const TAILSCALE_BIN = process.env.TAILSCALE_BIN || "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
|
|
@@ -1041,4 +1042,19 @@ export async function serve() {
|
|
|
1041
1042
|
} finally { reloading = false; }
|
|
1042
1043
|
}, 1000);
|
|
1043
1044
|
log("Connection credentials remain in local configuration; listener changes reload automatically");
|
|
1045
|
+
// Keep Tailscale Serve routes on the listeners' current ports: right away, after every listener
|
|
1046
|
+
// change, and every few minutes (routes can be edited by hand). Each finding is logged once.
|
|
1047
|
+
let checkedFor = "", lastReport = "", lastCheck = 0, checking = false;
|
|
1048
|
+
setInterval(async () => {
|
|
1049
|
+
if (checking || (applied === checkedFor && Date.now() - lastCheck < 5 * 60_000)) return;
|
|
1050
|
+
checking = true;
|
|
1051
|
+
try {
|
|
1052
|
+
const listeners = JSON.parse(applied || "[]") as Listener[];
|
|
1053
|
+
const lines = await checkTailscaleServe(listeners);
|
|
1054
|
+
const report = lines.join("\n");
|
|
1055
|
+
if (report && report !== lastReport) for (const line of lines) log(line);
|
|
1056
|
+
lastReport = report; checkedFor = applied; lastCheck = Date.now();
|
|
1057
|
+
} catch (error) { log(`tailscale serve health check failed: ${error}`); }
|
|
1058
|
+
finally { checking = false; }
|
|
1059
|
+
}, 2000);
|
|
1044
1060
|
}
|
package/serve.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
resolvePlaywrightCli,
|
|
18
18
|
readTokenRegistry,
|
|
19
19
|
readChromeProfileCache,
|
|
20
|
+
checkTailscaleServe,
|
|
20
21
|
} from "./rechrome.ts";
|
|
21
22
|
|
|
22
23
|
const TAILSCALE_BIN = process.env.TAILSCALE_BIN || "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
|
|
@@ -1041,4 +1042,19 @@ export async function serve() {
|
|
|
1041
1042
|
} finally { reloading = false; }
|
|
1042
1043
|
}, 1000);
|
|
1043
1044
|
log("Connection credentials remain in local configuration; listener changes reload automatically");
|
|
1045
|
+
// Keep Tailscale Serve routes on the listeners' current ports: right away, after every listener
|
|
1046
|
+
// change, and every few minutes (routes can be edited by hand). Each finding is logged once.
|
|
1047
|
+
let checkedFor = "", lastReport = "", lastCheck = 0, checking = false;
|
|
1048
|
+
setInterval(async () => {
|
|
1049
|
+
if (checking || (applied === checkedFor && Date.now() - lastCheck < 5 * 60_000)) return;
|
|
1050
|
+
checking = true;
|
|
1051
|
+
try {
|
|
1052
|
+
const listeners = JSON.parse(applied || "[]") as Listener[];
|
|
1053
|
+
const lines = await checkTailscaleServe(listeners);
|
|
1054
|
+
const report = lines.join("\n");
|
|
1055
|
+
if (report && report !== lastReport) for (const line of lines) log(line);
|
|
1056
|
+
lastReport = report; checkedFor = applied; lastCheck = Date.now();
|
|
1057
|
+
} catch (error) { log(`tailscale serve health check failed: ${error}`); }
|
|
1058
|
+
finally { checking = false; }
|
|
1059
|
+
}, 2000);
|
|
1044
1060
|
}
|