rechrome 1.31.8 → 1.33.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.
Files changed (5) hide show
  1. package/package.json +1 -1
  2. package/rechrome.js +178 -14
  3. package/rechrome.ts +178 -14
  4. package/serve.js +74 -32
  5. package/serve.ts +74 -32
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rechrome",
3
- "version": "1.31.8",
3
+ "version": "1.33.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/rechrome.git"
package/rechrome.js CHANGED
@@ -377,6 +377,9 @@ 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
385
  /** Run the Tailscale CLI read-only; null when it is missing, disconnected, or slow. */
@@ -384,7 +387,7 @@ async function runTailscale(args: string[]): Promise<string | null> {
384
387
  const binary = tailscaleBinary();
385
388
  if (!binary) return null;
386
389
  try {
387
- const proc = Bun.spawn([binary, ...args], { stdout: "pipe", stderr: "ignore" });
390
+ const proc = Bun.spawn([binary, ...args], { env: TAILSCALE_ENV, stdout: "pipe", stderr: "ignore" });
388
391
  const timeout = setTimeout(() => proc.kill(), 2000);
389
392
  try {
390
393
  const output = await new Response(proc.stdout).text();
@@ -445,6 +448,101 @@ export async function detectTailscaleServe(port: number, prefix: string): Promis
445
448
  return { dnsName, routeUrl, ...serveMountsAndFunnel(serveStatus, routeUrl ? new URL(routeUrl).hostname : dnsName) };
446
449
  }
447
450
 
451
+ type ServeHandler = { hostPort: string; mount: string; target: URL };
452
+
453
+ /** Every Serve route that proxies to a loopback HTTP port. */
454
+ function loopbackServeHandlers(serveStatus: unknown): ServeHandler[] {
455
+ const web = (serveStatus as { Web?: Record<string, { Handlers?: Record<string, { Proxy?: string }> }> } | null)?.Web ?? {};
456
+ const handlers: ServeHandler[] = [];
457
+ for (const [hostPort, config] of Object.entries(web)) {
458
+ for (const [path, handler] of Object.entries(config.Handlers ?? {})) {
459
+ if (!handler.Proxy) continue;
460
+ let target: URL;
461
+ try { target = new URL(/^[a-z]+:\/\//i.test(handler.Proxy) ? handler.Proxy : `http://${handler.Proxy}`); } catch { continue; }
462
+ if (!["127.0.0.1", "localhost", "[::1]"].includes(target.hostname) || !target.port) continue;
463
+ handlers.push({ hostPort, mount: path.replace(/\/+$/, "") || "/", target });
464
+ }
465
+ }
466
+ return handlers;
467
+ }
468
+
469
+ export type ServeRepair = { hostPort: string; mount: string; from: number; to: number; proxy: string };
470
+ export type ServeStale = { hostPort: string; mount: string; port: number };
471
+
472
+ /**
473
+ * Health check for Tailscale Serve against listeners.json. `repair`: a route on a listener's own
474
+ * mount that proxies to another port (the listener moved), re-pointed at the listener's port.
475
+ * `stale`: a route next to a listener's mount (same first path segment, e.g. /rechrome beside
476
+ * /rechrome/taku) whose port no listener uses — a removed listener's leftover, which answers 502.
477
+ * Root ("/") mounts are never touched: they could be anything.
478
+ */
479
+ export function planTailscaleServeRepairs(serveStatus: unknown, listeners: { port: number; prefix?: string }[]): { repair: ServeRepair[]; stale: ServeStale[] } {
480
+ const ports = new Set(listeners.map(l => l.port));
481
+ const mounts = new Map<string, number>();
482
+ for (const l of listeners) {
483
+ const mount = normalizePrefix(l.prefix).replace(/\/+$/, "");
484
+ if (mount) mounts.set(mount, l.port);
485
+ }
486
+ const roots = new Set([...mounts.keys()].map(m => m.split("/")[1]));
487
+ const repair: ServeRepair[] = [], stale: ServeStale[] = [];
488
+ for (const { hostPort, mount, target } of loopbackServeHandlers(serveStatus)) {
489
+ const port = Number(target.port), want = mounts.get(mount);
490
+ if (want !== undefined) {
491
+ if (port !== want) {
492
+ const proxy = new URL(target); proxy.port = String(want);
493
+ repair.push({ hostPort, mount, from: port, to: want, proxy: proxy.href.replace(/\/$/, mount === "/" ? "/" : "") });
494
+ }
495
+ } else if (mount !== "/" && roots.has(mount.split("/")[1]) && !ports.has(port)) {
496
+ stale.push({ hostPort, mount, port });
497
+ }
498
+ }
499
+ return { repair, stale };
500
+ }
501
+
502
+ /** Drop the Serve routes that proxy to a removed listener, so they don't linger answering 502. */
503
+ async function removeTailscaleServeRoutes(listener: { port: number; prefix?: string }): Promise<void> {
504
+ const binary = tailscaleBinary(), raw = binary && await runTailscale(["serve", "status", "--json"]);
505
+ if (!binary || !raw) return;
506
+ let serveStatus: unknown;
507
+ try { serveStatus = JSON.parse(raw); } catch { return; }
508
+ const mount = normalizePrefix(listener.prefix).replace(/\/+$/, "") || "/";
509
+ for (const h of loopbackServeHandlers(serveStatus)) {
510
+ if (Number(h.target.port) !== listener.port || h.mount !== mount) continue;
511
+ const args = ["serve", `--https=${h.hostPort.split(/:(?=\d+$)/)[1] ?? "443"}`, `--set-path=${h.mount}`, "off"];
512
+ const ok = await Bun.spawn([binary, ...args], { env: TAILSCALE_ENV, stdout: "ignore", stderr: "inherit", windowsHide: true }).exited === 0;
513
+ console.log(ok ? `Removed its Tailscale Serve route ${h.hostPort}${h.mount}.` : `Remove its Tailscale Serve route yourself: tailscale ${args.join(" ")}`);
514
+ }
515
+ }
516
+
517
+ /**
518
+ * Daemon health check: keep Tailscale Serve routes pointed at the listeners' current ports.
519
+ * Re-points a moved listener's route on this node; only reports stale routes (removing a
520
+ * route is the user's call). Returns log lines, empty when healthy or Tailscale is absent.
521
+ */
522
+ export async function checkTailscaleServe(listeners: { port: number; prefix?: string }[]): Promise<string[]> {
523
+ const binary = tailscaleBinary();
524
+ if (!binary) return [];
525
+ const raw = await runTailscale(["serve", "status", "--json"]);
526
+ if (!raw) return [];
527
+ let serveStatus: unknown;
528
+ try { serveStatus = JSON.parse(raw); } catch { return []; }
529
+ const { repair, stale } = planTailscaleServeRepairs(serveStatus, listeners);
530
+ const lines: string[] = [];
531
+ for (const r of repair) {
532
+ const httpsPort = r.hostPort.split(/:(?=\d+$)/)[1] ?? "443";
533
+ const args = ["serve", "--bg", `--https=${httpsPort}`, `--set-path=${r.mount}`, r.proxy];
534
+ const proc = Bun.spawn([binary, ...args], { env: TAILSCALE_ENV, stdout: "ignore", stderr: "pipe", windowsHide: true });
535
+ const ok = await proc.exited === 0;
536
+ lines.push(ok
537
+ ? `tailscale serve: re-pointed ${r.hostPort}${r.mount} from 127.0.0.1:${r.from} to ${r.to}`
538
+ : `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(" ")}`);
539
+ }
540
+ for (const s of stale) {
541
+ lines.push(`tailscale serve: ${s.hostPort}${s.mount} proxies to 127.0.0.1:${s.port}, where no listener runs (clients get HTTP 502). Remove it: tailscale serve --https=${s.hostPort.split(/:(?=\d+$)/)[1] ?? "443"} --set-path=${s.mount} off`);
542
+ }
543
+ return lines;
544
+ }
545
+
448
546
  /** The same connection (key and profile) at another base URL, e.g. where a proxy exposes the listener. */
449
547
  export function rebaseConnectionUrl(routeUrl: string, localUrl: string): string {
450
548
  const local = new URL(localUrl);
@@ -1240,14 +1338,28 @@ export async function promptChoice<T>(
1240
1338
  } finally { rl.close(); }
1241
1339
  }
1242
1340
 
1243
- /** Registered profiles as choices, one per Chrome profile (data dir + folder; aliases collapse). */
1244
- function registeredProfileChoices(registry: Record<string, TokenEntry>): { label: string; value: string }[] {
1341
+ /**
1342
+ * One picker entry per Chrome profile: its Chrome name and signed-in email (from Local State,
1343
+ * for profiles in the default user data dir), then the folder, e.g. `Work · you@example.com [Profile 2]`.
1344
+ */
1345
+ export function registeredProfileChoices(registry: Record<string, TokenEntry>, cache: Record<string, ChromeProfileInfo> | null = null): { label: string; value: string }[] {
1346
+ const defaultDirs = CHROME_LOCAL_STATE_PATHS().map(path => dirname(path));
1245
1347
  return canonicalProfileKeys(registry).map(key => {
1246
- const dir = registry[key].profileDir;
1247
- return { label: key === dir ? key : `${key} [${dir}]`, value: key };
1348
+ const { profileDir: dir, userDataDir } = registry[key];
1349
+ const info = !userDataDir || defaultDirs.includes(userDataDir) ? cache?.[dir] : undefined;
1350
+ const names = [info?.name, info?.user_name, key].filter((n): n is string => !!n && n !== dir);
1351
+ const shown = [...new Set(names)];
1352
+ return { label: shown.length ? `${shown.join(" · ")} [${dir}]` : dir, value: key };
1248
1353
  });
1249
1354
  }
1250
1355
 
1356
+ /** Chrome profiles (default user data dir) that rech has not registered yet: `[folder, info]`. */
1357
+ export function unregisteredChromeProfiles(registry: Record<string, TokenEntry>, cache: Record<string, ChromeProfileInfo> | null): [string, ChromeProfileInfo][] {
1358
+ const defaultDirs = CHROME_LOCAL_STATE_PATHS().map(path => dirname(path));
1359
+ const registered = new Set(Object.values(registry).filter(e => !e.userDataDir || defaultDirs.includes(e.userDataDir)).map(e => e.profileDir));
1360
+ return Object.entries(cache ?? {}).filter(([dir]) => !registered.has(dir));
1361
+ }
1362
+
1251
1363
  /**
1252
1364
  * Which listener `rech share <profile>` uses when none is named: a scoped one that allows the
1253
1365
  * profile, preferring one with a public URL. Never the management listener, whose key gives
@@ -1394,7 +1506,7 @@ async function exposeWithTailscaleServe(listener: Listener): Promise<string | nu
1394
1506
  const mount = prefix === "/" ? "/" : prefix.slice(0, -1);
1395
1507
  const args = ["serve", "--bg", `--set-path=${mount}`, `http://127.0.0.1:${listener.port}${mount === "/" ? "" : mount}`];
1396
1508
  console.error(`Running: tailscale ${args.join(" ")}`);
1397
- const proc = Bun.spawn([binary, ...args], { stdout: "ignore", stderr: "inherit", windowsHide: true });
1509
+ const proc = Bun.spawn([binary, ...args], { env: TAILSCALE_ENV, stdout: "ignore", stderr: "inherit", windowsHide: true });
1398
1510
  if (await proc.exited !== 0) {
1399
1511
  console.error(`tailscale serve failed. Run it yourself, then rech share again:\n tailscale ${args.join(" ")}`);
1400
1512
  return null;
@@ -1427,6 +1539,26 @@ async function shareNewLink(profile: string, opts: { local?: boolean; save?: boo
1427
1539
  return shareSet([profile], { ...opts, host, tailscaleServe, defaultName: name, prefix: `/rechrome/${name}/`, again: `rech share ${JSON.stringify(profile)}` });
1428
1540
  }
1429
1541
 
1542
+ /** A scoped listener's full link (contains its key): its public URL when recorded, else its own address. */
1543
+ export function listenerLink(listener: Listener, local = false): string {
1544
+ const profiles = listener.profiles as string[];
1545
+ const only = profiles.length === 1 ? `?profile=${encodeURIComponent(profiles[0])}` : "";
1546
+ const direct = `http://${listener.key}@${listenerAddress(listener)}${normalizePrefix(listener.prefix)}${only}`;
1547
+ return listener.publicUrl && !local ? rebaseConnectionUrl(listener.publicUrl, direct) : registeredProfileUrl(direct);
1548
+ }
1549
+
1550
+ /** The links already handed out (stderr: stdout stays the one URL this command prints). */
1551
+ function printExistingLinks(listeners: Listener[], local = false): void {
1552
+ const scoped = listeners.filter(l => l.profiles !== "*" && l.profiles.length);
1553
+ if (!scoped.length) return;
1554
+ console.error("Already shared (secret links):");
1555
+ for (const l of scoped) {
1556
+ console.error(` ${l.name}: ${(l.profiles as string[]).join(", ")}${!l.publicUrl && isLoopback(l.host) ? " (this machine only: no proxy recorded)" : ""}`);
1557
+ console.error(` ${listenerLink(l, local)}`);
1558
+ }
1559
+ console.error("");
1560
+ }
1561
+
1430
1562
  async function printProfileUri(selector?: string, listener?: string, opts: { local?: boolean; save?: boolean; all?: boolean } = {}): Promise<void> {
1431
1563
  if (opts.all) {
1432
1564
  if (selector) throw new Error("Pass a profile or --all, not both.");
@@ -1437,14 +1569,41 @@ async function printProfileUri(selector?: string, listener?: string, opts: { loc
1437
1569
  const registry = await readTokenRegistry();
1438
1570
  const cache = await readChromeProfileCache();
1439
1571
  const cancelled = () => new Error("Cancelled; nothing shared.");
1572
+ let config = await readListeners();
1440
1573
  const current = resolveEffectiveProfile(url ? parseUrl(url).profileDirectory : undefined);
1441
- const pickProfile = async (question: string, choices = registeredProfileChoices(registry)) => {
1574
+ const sharedOn = (key: string) => ((config?.listeners ?? []).filter(l => l.profiles !== "*" && (l.profiles as string[]).includes(key))).map(l => l.name);
1575
+ // Chrome profiles rech doesn't manage yet are listed too; picking one runs setup for it first.
1576
+ const SETUP = "\0setup:";
1577
+ const pickProfile = async (question: string, choices = [
1578
+ ...registeredProfileChoices(registry, cache).map(c => {
1579
+ const on = sharedOn(c.value);
1580
+ return on.length ? { ...c, label: `${c.label} (shared: ${on.join(", ")})` } : c;
1581
+ }),
1582
+ ...unregisteredChromeProfiles(registry, cache).map(([dir, info]) => ({
1583
+ label: `${[...new Set([info.name, info.user_name].filter(Boolean))].join(" · ") || dir} [${dir}] (not set up: runs rech setup)`,
1584
+ value: `${SETUP}${dir}`,
1585
+ })),
1586
+ ]) => {
1442
1587
  const def = Math.max(0, choices.findIndex(c => c.value === current || registry[c.value]?.profileDir === current));
1443
- return (await promptChoice(question, choices, def)) ?? (() => { throw cancelled(); })();
1588
+ const picked = (await promptChoice(question, choices, def)) ?? (() => { throw cancelled(); })();
1589
+ if (!picked.startsWith(SETUP)) return picked;
1590
+ const dir = picked.slice(SETUP.length);
1591
+ console.error(`[rech] ${dir} isn't set up yet; running rech setup --profile ${JSON.stringify(dir)} first.`);
1592
+ await setup({ profile: dir });
1593
+ const after = await readTokenRegistry();
1594
+ const key = unregisteredChromeProfiles(after, { [dir]: {} }).length ? undefined : canonicalProfileKeys(after).find(k => after[k].profileDir === dir);
1595
+ if (process.exitCode || !key) throw new Error(`Setup didn't register ${dir}; nothing shared. Retry with: rech setup --profile ${JSON.stringify(dir)}`);
1596
+ Object.assign(registry, after);
1597
+ config = await readListeners(); // setup may have added a listener: never write back a stale copy
1598
+ return key;
1444
1599
  };
1445
1600
  if (!selector) {
1446
- // No profile given: ask, defaulting to the current one (?profile= in the URL, else PLAYWRIGHT_MCP_PROFILE_DIRECTORY).
1447
- if (interactive && Object.keys(registry).length) selector = await pickProfile("Share which profile?");
1601
+ // No profile given: show what is already shared, then ask, defaulting to the current one
1602
+ // (?profile= in the URL, else PLAYWRIGHT_MCP_PROFILE_DIRECTORY).
1603
+ if (interactive && Object.keys(registry).length) {
1604
+ printExistingLinks(config?.listeners ?? [], opts.local);
1605
+ selector = await pickProfile("Share which profile?");
1606
+ }
1448
1607
  else {
1449
1608
  selector = current;
1450
1609
  if (!selector) throw new Error("No current profile to share. Name one: rech share <profile> (see rech profile; rech share ls lists what is shared)");
@@ -1467,7 +1626,6 @@ async function printProfileUri(selector?: string, listener?: string, opts: { loc
1467
1626
  } else throw error;
1468
1627
  }
1469
1628
  }
1470
- const config = await readListeners();
1471
1629
  const listeners = config?.listeners ?? [];
1472
1630
  // On a host, share through a scoped listener by default, never the management key.
1473
1631
  if (!listener && config) {
@@ -1609,7 +1767,9 @@ async function callServe(
1609
1767
  return JSON.parse(text);
1610
1768
  } catch {
1611
1769
  // Not the daemon answering, e.g. a reverse proxy's 404 because the URL's path prefix is wrong.
1612
- const detail = `HTTP ${res.status} from ${serviceUrl(url, "run")} is not a rechrome daemon response: ${text.slice(0, 200).trim()}`;
1770
+ let detail = `HTTP ${res.status} from ${serviceUrl(url, "run")} is not a rechrome daemon response: ${text.slice(0, 200).trim()}`;
1771
+ if (res.status === 502 || res.status === 503 || res.status === 504)
1772
+ 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>\`.`;
1613
1773
  if (throwOnFailure) throw new Error(detail);
1614
1774
  console.error(`[rech] rech-client -> ${serviceUrl(url, "run")}\n -x: ${detail}`);
1615
1775
  process.exit(1);
@@ -2699,6 +2859,7 @@ async function removeListener(name: string): Promise<void> {
2699
2859
  config.listeners = config.listeners.filter(l => l !== listener);
2700
2860
  await writeListeners(config);
2701
2861
  console.log("Listener removed from configuration; daemon reloads automatically.");
2862
+ await removeTailscaleServeRoutes(listener);
2702
2863
  }
2703
2864
 
2704
2865
  async function resolveProfileKeys(selectors: string[]): Promise<string[]> {
@@ -2819,8 +2980,11 @@ async function connect(url: string): Promise<void> {
2819
2980
  throw new Error(`Connected, but listener "${body.listener}" does not allow profile "${parsed.profileDirectory}".`);
2820
2981
  const saved = await saveProjectUrl(url);
2821
2982
  console.log(`Connected to ${serviceUrl(url)}${body.listener ? ` (listener ${body.listener})` : ""}. Saved RECHROME_URL to ${saved}`);
2822
- if (Array.isArray(body.profiles) && body.profiles.length > 1 && !parsed.profileDirectory)
2823
- console.log(`This link shares ${body.profiles.length} profiles: ${body.profiles.join(", ")}.\nPick one per command: rech --profile <name> open https://example.com (see them again with rech profile)`);
2983
+ if (Array.isArray(body.profiles) && body.profiles.length > 1) {
2984
+ console.log(`This link shares ${body.profiles.length} profiles:`);
2985
+ for (const p of body.profiles) console.log(` ${p}${p === parsed.profileDirectory ? " (default)" : ""}`);
2986
+ console.log(`${parsed.profileDirectory ? "Use another" : "Pick one"} per command: rech --profile <name> open https://example.com (see them again with rech profile)`);
2987
+ }
2824
2988
  }
2825
2989
 
2826
2990
  export function detectSetupAgent(env: Record<string, string | undefined> = process.env): "Codex" | "Claude Code" | null {
package/rechrome.ts CHANGED
@@ -377,6 +377,9 @@ 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
385
  /** Run the Tailscale CLI read-only; null when it is missing, disconnected, or slow. */
@@ -384,7 +387,7 @@ async function runTailscale(args: string[]): Promise<string | null> {
384
387
  const binary = tailscaleBinary();
385
388
  if (!binary) return null;
386
389
  try {
387
- const proc = Bun.spawn([binary, ...args], { stdout: "pipe", stderr: "ignore" });
390
+ const proc = Bun.spawn([binary, ...args], { env: TAILSCALE_ENV, stdout: "pipe", stderr: "ignore" });
388
391
  const timeout = setTimeout(() => proc.kill(), 2000);
389
392
  try {
390
393
  const output = await new Response(proc.stdout).text();
@@ -445,6 +448,101 @@ export async function detectTailscaleServe(port: number, prefix: string): Promis
445
448
  return { dnsName, routeUrl, ...serveMountsAndFunnel(serveStatus, routeUrl ? new URL(routeUrl).hostname : dnsName) };
446
449
  }
447
450
 
451
+ type ServeHandler = { hostPort: string; mount: string; target: URL };
452
+
453
+ /** Every Serve route that proxies to a loopback HTTP port. */
454
+ function loopbackServeHandlers(serveStatus: unknown): ServeHandler[] {
455
+ const web = (serveStatus as { Web?: Record<string, { Handlers?: Record<string, { Proxy?: string }> }> } | null)?.Web ?? {};
456
+ const handlers: ServeHandler[] = [];
457
+ for (const [hostPort, config] of Object.entries(web)) {
458
+ for (const [path, handler] of Object.entries(config.Handlers ?? {})) {
459
+ if (!handler.Proxy) continue;
460
+ let target: URL;
461
+ try { target = new URL(/^[a-z]+:\/\//i.test(handler.Proxy) ? handler.Proxy : `http://${handler.Proxy}`); } catch { continue; }
462
+ if (!["127.0.0.1", "localhost", "[::1]"].includes(target.hostname) || !target.port) continue;
463
+ handlers.push({ hostPort, mount: path.replace(/\/+$/, "") || "/", target });
464
+ }
465
+ }
466
+ return handlers;
467
+ }
468
+
469
+ export type ServeRepair = { hostPort: string; mount: string; from: number; to: number; proxy: string };
470
+ export type ServeStale = { hostPort: string; mount: string; port: number };
471
+
472
+ /**
473
+ * Health check for Tailscale Serve against listeners.json. `repair`: a route on a listener's own
474
+ * mount that proxies to another port (the listener moved), re-pointed at the listener's port.
475
+ * `stale`: a route next to a listener's mount (same first path segment, e.g. /rechrome beside
476
+ * /rechrome/taku) whose port no listener uses — a removed listener's leftover, which answers 502.
477
+ * Root ("/") mounts are never touched: they could be anything.
478
+ */
479
+ export function planTailscaleServeRepairs(serveStatus: unknown, listeners: { port: number; prefix?: string }[]): { repair: ServeRepair[]; stale: ServeStale[] } {
480
+ const ports = new Set(listeners.map(l => l.port));
481
+ const mounts = new Map<string, number>();
482
+ for (const l of listeners) {
483
+ const mount = normalizePrefix(l.prefix).replace(/\/+$/, "");
484
+ if (mount) mounts.set(mount, l.port);
485
+ }
486
+ const roots = new Set([...mounts.keys()].map(m => m.split("/")[1]));
487
+ const repair: ServeRepair[] = [], stale: ServeStale[] = [];
488
+ for (const { hostPort, mount, target } of loopbackServeHandlers(serveStatus)) {
489
+ const port = Number(target.port), want = mounts.get(mount);
490
+ if (want !== undefined) {
491
+ if (port !== want) {
492
+ const proxy = new URL(target); proxy.port = String(want);
493
+ repair.push({ hostPort, mount, from: port, to: want, proxy: proxy.href.replace(/\/$/, mount === "/" ? "/" : "") });
494
+ }
495
+ } else if (mount !== "/" && roots.has(mount.split("/")[1]) && !ports.has(port)) {
496
+ stale.push({ hostPort, mount, port });
497
+ }
498
+ }
499
+ return { repair, stale };
500
+ }
501
+
502
+ /** Drop the Serve routes that proxy to a removed listener, so they don't linger answering 502. */
503
+ async function removeTailscaleServeRoutes(listener: { port: number; prefix?: string }): Promise<void> {
504
+ const binary = tailscaleBinary(), raw = binary && await runTailscale(["serve", "status", "--json"]);
505
+ if (!binary || !raw) return;
506
+ let serveStatus: unknown;
507
+ try { serveStatus = JSON.parse(raw); } catch { return; }
508
+ const mount = normalizePrefix(listener.prefix).replace(/\/+$/, "") || "/";
509
+ for (const h of loopbackServeHandlers(serveStatus)) {
510
+ if (Number(h.target.port) !== listener.port || h.mount !== mount) continue;
511
+ const args = ["serve", `--https=${h.hostPort.split(/:(?=\d+$)/)[1] ?? "443"}`, `--set-path=${h.mount}`, "off"];
512
+ const ok = await Bun.spawn([binary, ...args], { env: TAILSCALE_ENV, stdout: "ignore", stderr: "inherit", windowsHide: true }).exited === 0;
513
+ console.log(ok ? `Removed its Tailscale Serve route ${h.hostPort}${h.mount}.` : `Remove its Tailscale Serve route yourself: tailscale ${args.join(" ")}`);
514
+ }
515
+ }
516
+
517
+ /**
518
+ * Daemon health check: keep Tailscale Serve routes pointed at the listeners' current ports.
519
+ * Re-points a moved listener's route on this node; only reports stale routes (removing a
520
+ * route is the user's call). Returns log lines, empty when healthy or Tailscale is absent.
521
+ */
522
+ export async function checkTailscaleServe(listeners: { port: number; prefix?: string }[]): Promise<string[]> {
523
+ const binary = tailscaleBinary();
524
+ if (!binary) return [];
525
+ const raw = await runTailscale(["serve", "status", "--json"]);
526
+ if (!raw) return [];
527
+ let serveStatus: unknown;
528
+ try { serveStatus = JSON.parse(raw); } catch { return []; }
529
+ const { repair, stale } = planTailscaleServeRepairs(serveStatus, listeners);
530
+ const lines: string[] = [];
531
+ for (const r of repair) {
532
+ const httpsPort = r.hostPort.split(/:(?=\d+$)/)[1] ?? "443";
533
+ const args = ["serve", "--bg", `--https=${httpsPort}`, `--set-path=${r.mount}`, r.proxy];
534
+ const proc = Bun.spawn([binary, ...args], { env: TAILSCALE_ENV, stdout: "ignore", stderr: "pipe", windowsHide: true });
535
+ const ok = await proc.exited === 0;
536
+ lines.push(ok
537
+ ? `tailscale serve: re-pointed ${r.hostPort}${r.mount} from 127.0.0.1:${r.from} to ${r.to}`
538
+ : `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(" ")}`);
539
+ }
540
+ for (const s of stale) {
541
+ lines.push(`tailscale serve: ${s.hostPort}${s.mount} proxies to 127.0.0.1:${s.port}, where no listener runs (clients get HTTP 502). Remove it: tailscale serve --https=${s.hostPort.split(/:(?=\d+$)/)[1] ?? "443"} --set-path=${s.mount} off`);
542
+ }
543
+ return lines;
544
+ }
545
+
448
546
  /** The same connection (key and profile) at another base URL, e.g. where a proxy exposes the listener. */
449
547
  export function rebaseConnectionUrl(routeUrl: string, localUrl: string): string {
450
548
  const local = new URL(localUrl);
@@ -1240,14 +1338,28 @@ export async function promptChoice<T>(
1240
1338
  } finally { rl.close(); }
1241
1339
  }
1242
1340
 
1243
- /** Registered profiles as choices, one per Chrome profile (data dir + folder; aliases collapse). */
1244
- function registeredProfileChoices(registry: Record<string, TokenEntry>): { label: string; value: string }[] {
1341
+ /**
1342
+ * One picker entry per Chrome profile: its Chrome name and signed-in email (from Local State,
1343
+ * for profiles in the default user data dir), then the folder, e.g. `Work · you@example.com [Profile 2]`.
1344
+ */
1345
+ export function registeredProfileChoices(registry: Record<string, TokenEntry>, cache: Record<string, ChromeProfileInfo> | null = null): { label: string; value: string }[] {
1346
+ const defaultDirs = CHROME_LOCAL_STATE_PATHS().map(path => dirname(path));
1245
1347
  return canonicalProfileKeys(registry).map(key => {
1246
- const dir = registry[key].profileDir;
1247
- return { label: key === dir ? key : `${key} [${dir}]`, value: key };
1348
+ const { profileDir: dir, userDataDir } = registry[key];
1349
+ const info = !userDataDir || defaultDirs.includes(userDataDir) ? cache?.[dir] : undefined;
1350
+ const names = [info?.name, info?.user_name, key].filter((n): n is string => !!n && n !== dir);
1351
+ const shown = [...new Set(names)];
1352
+ return { label: shown.length ? `${shown.join(" · ")} [${dir}]` : dir, value: key };
1248
1353
  });
1249
1354
  }
1250
1355
 
1356
+ /** Chrome profiles (default user data dir) that rech has not registered yet: `[folder, info]`. */
1357
+ export function unregisteredChromeProfiles(registry: Record<string, TokenEntry>, cache: Record<string, ChromeProfileInfo> | null): [string, ChromeProfileInfo][] {
1358
+ const defaultDirs = CHROME_LOCAL_STATE_PATHS().map(path => dirname(path));
1359
+ const registered = new Set(Object.values(registry).filter(e => !e.userDataDir || defaultDirs.includes(e.userDataDir)).map(e => e.profileDir));
1360
+ return Object.entries(cache ?? {}).filter(([dir]) => !registered.has(dir));
1361
+ }
1362
+
1251
1363
  /**
1252
1364
  * Which listener `rech share <profile>` uses when none is named: a scoped one that allows the
1253
1365
  * profile, preferring one with a public URL. Never the management listener, whose key gives
@@ -1394,7 +1506,7 @@ async function exposeWithTailscaleServe(listener: Listener): Promise<string | nu
1394
1506
  const mount = prefix === "/" ? "/" : prefix.slice(0, -1);
1395
1507
  const args = ["serve", "--bg", `--set-path=${mount}`, `http://127.0.0.1:${listener.port}${mount === "/" ? "" : mount}`];
1396
1508
  console.error(`Running: tailscale ${args.join(" ")}`);
1397
- const proc = Bun.spawn([binary, ...args], { stdout: "ignore", stderr: "inherit", windowsHide: true });
1509
+ const proc = Bun.spawn([binary, ...args], { env: TAILSCALE_ENV, stdout: "ignore", stderr: "inherit", windowsHide: true });
1398
1510
  if (await proc.exited !== 0) {
1399
1511
  console.error(`tailscale serve failed. Run it yourself, then rech share again:\n tailscale ${args.join(" ")}`);
1400
1512
  return null;
@@ -1427,6 +1539,26 @@ async function shareNewLink(profile: string, opts: { local?: boolean; save?: boo
1427
1539
  return shareSet([profile], { ...opts, host, tailscaleServe, defaultName: name, prefix: `/rechrome/${name}/`, again: `rech share ${JSON.stringify(profile)}` });
1428
1540
  }
1429
1541
 
1542
+ /** A scoped listener's full link (contains its key): its public URL when recorded, else its own address. */
1543
+ export function listenerLink(listener: Listener, local = false): string {
1544
+ const profiles = listener.profiles as string[];
1545
+ const only = profiles.length === 1 ? `?profile=${encodeURIComponent(profiles[0])}` : "";
1546
+ const direct = `http://${listener.key}@${listenerAddress(listener)}${normalizePrefix(listener.prefix)}${only}`;
1547
+ return listener.publicUrl && !local ? rebaseConnectionUrl(listener.publicUrl, direct) : registeredProfileUrl(direct);
1548
+ }
1549
+
1550
+ /** The links already handed out (stderr: stdout stays the one URL this command prints). */
1551
+ function printExistingLinks(listeners: Listener[], local = false): void {
1552
+ const scoped = listeners.filter(l => l.profiles !== "*" && l.profiles.length);
1553
+ if (!scoped.length) return;
1554
+ console.error("Already shared (secret links):");
1555
+ for (const l of scoped) {
1556
+ console.error(` ${l.name}: ${(l.profiles as string[]).join(", ")}${!l.publicUrl && isLoopback(l.host) ? " (this machine only: no proxy recorded)" : ""}`);
1557
+ console.error(` ${listenerLink(l, local)}`);
1558
+ }
1559
+ console.error("");
1560
+ }
1561
+
1430
1562
  async function printProfileUri(selector?: string, listener?: string, opts: { local?: boolean; save?: boolean; all?: boolean } = {}): Promise<void> {
1431
1563
  if (opts.all) {
1432
1564
  if (selector) throw new Error("Pass a profile or --all, not both.");
@@ -1437,14 +1569,41 @@ async function printProfileUri(selector?: string, listener?: string, opts: { loc
1437
1569
  const registry = await readTokenRegistry();
1438
1570
  const cache = await readChromeProfileCache();
1439
1571
  const cancelled = () => new Error("Cancelled; nothing shared.");
1572
+ let config = await readListeners();
1440
1573
  const current = resolveEffectiveProfile(url ? parseUrl(url).profileDirectory : undefined);
1441
- const pickProfile = async (question: string, choices = registeredProfileChoices(registry)) => {
1574
+ const sharedOn = (key: string) => ((config?.listeners ?? []).filter(l => l.profiles !== "*" && (l.profiles as string[]).includes(key))).map(l => l.name);
1575
+ // Chrome profiles rech doesn't manage yet are listed too; picking one runs setup for it first.
1576
+ const SETUP = "\0setup:";
1577
+ const pickProfile = async (question: string, choices = [
1578
+ ...registeredProfileChoices(registry, cache).map(c => {
1579
+ const on = sharedOn(c.value);
1580
+ return on.length ? { ...c, label: `${c.label} (shared: ${on.join(", ")})` } : c;
1581
+ }),
1582
+ ...unregisteredChromeProfiles(registry, cache).map(([dir, info]) => ({
1583
+ label: `${[...new Set([info.name, info.user_name].filter(Boolean))].join(" · ") || dir} [${dir}] (not set up: runs rech setup)`,
1584
+ value: `${SETUP}${dir}`,
1585
+ })),
1586
+ ]) => {
1442
1587
  const def = Math.max(0, choices.findIndex(c => c.value === current || registry[c.value]?.profileDir === current));
1443
- return (await promptChoice(question, choices, def)) ?? (() => { throw cancelled(); })();
1588
+ const picked = (await promptChoice(question, choices, def)) ?? (() => { throw cancelled(); })();
1589
+ if (!picked.startsWith(SETUP)) return picked;
1590
+ const dir = picked.slice(SETUP.length);
1591
+ console.error(`[rech] ${dir} isn't set up yet; running rech setup --profile ${JSON.stringify(dir)} first.`);
1592
+ await setup({ profile: dir });
1593
+ const after = await readTokenRegistry();
1594
+ const key = unregisteredChromeProfiles(after, { [dir]: {} }).length ? undefined : canonicalProfileKeys(after).find(k => after[k].profileDir === dir);
1595
+ if (process.exitCode || !key) throw new Error(`Setup didn't register ${dir}; nothing shared. Retry with: rech setup --profile ${JSON.stringify(dir)}`);
1596
+ Object.assign(registry, after);
1597
+ config = await readListeners(); // setup may have added a listener: never write back a stale copy
1598
+ return key;
1444
1599
  };
1445
1600
  if (!selector) {
1446
- // No profile given: ask, defaulting to the current one (?profile= in the URL, else PLAYWRIGHT_MCP_PROFILE_DIRECTORY).
1447
- if (interactive && Object.keys(registry).length) selector = await pickProfile("Share which profile?");
1601
+ // No profile given: show what is already shared, then ask, defaulting to the current one
1602
+ // (?profile= in the URL, else PLAYWRIGHT_MCP_PROFILE_DIRECTORY).
1603
+ if (interactive && Object.keys(registry).length) {
1604
+ printExistingLinks(config?.listeners ?? [], opts.local);
1605
+ selector = await pickProfile("Share which profile?");
1606
+ }
1448
1607
  else {
1449
1608
  selector = current;
1450
1609
  if (!selector) throw new Error("No current profile to share. Name one: rech share <profile> (see rech profile; rech share ls lists what is shared)");
@@ -1467,7 +1626,6 @@ async function printProfileUri(selector?: string, listener?: string, opts: { loc
1467
1626
  } else throw error;
1468
1627
  }
1469
1628
  }
1470
- const config = await readListeners();
1471
1629
  const listeners = config?.listeners ?? [];
1472
1630
  // On a host, share through a scoped listener by default, never the management key.
1473
1631
  if (!listener && config) {
@@ -1609,7 +1767,9 @@ async function callServe(
1609
1767
  return JSON.parse(text);
1610
1768
  } catch {
1611
1769
  // Not the daemon answering, e.g. a reverse proxy's 404 because the URL's path prefix is wrong.
1612
- const detail = `HTTP ${res.status} from ${serviceUrl(url, "run")} is not a rechrome daemon response: ${text.slice(0, 200).trim()}`;
1770
+ let detail = `HTTP ${res.status} from ${serviceUrl(url, "run")} is not a rechrome daemon response: ${text.slice(0, 200).trim()}`;
1771
+ if (res.status === 502 || res.status === 503 || res.status === 504)
1772
+ 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>\`.`;
1613
1773
  if (throwOnFailure) throw new Error(detail);
1614
1774
  console.error(`[rech] rech-client -> ${serviceUrl(url, "run")}\n -x: ${detail}`);
1615
1775
  process.exit(1);
@@ -2699,6 +2859,7 @@ async function removeListener(name: string): Promise<void> {
2699
2859
  config.listeners = config.listeners.filter(l => l !== listener);
2700
2860
  await writeListeners(config);
2701
2861
  console.log("Listener removed from configuration; daemon reloads automatically.");
2862
+ await removeTailscaleServeRoutes(listener);
2702
2863
  }
2703
2864
 
2704
2865
  async function resolveProfileKeys(selectors: string[]): Promise<string[]> {
@@ -2819,8 +2980,11 @@ async function connect(url: string): Promise<void> {
2819
2980
  throw new Error(`Connected, but listener "${body.listener}" does not allow profile "${parsed.profileDirectory}".`);
2820
2981
  const saved = await saveProjectUrl(url);
2821
2982
  console.log(`Connected to ${serviceUrl(url)}${body.listener ? ` (listener ${body.listener})` : ""}. Saved RECHROME_URL to ${saved}`);
2822
- if (Array.isArray(body.profiles) && body.profiles.length > 1 && !parsed.profileDirectory)
2823
- console.log(`This link shares ${body.profiles.length} profiles: ${body.profiles.join(", ")}.\nPick one per command: rech --profile <name> open https://example.com (see them again with rech profile)`);
2983
+ if (Array.isArray(body.profiles) && body.profiles.length > 1) {
2984
+ console.log(`This link shares ${body.profiles.length} profiles:`);
2985
+ for (const p of body.profiles) console.log(` ${p}${p === parsed.profileDirectory ? " (default)" : ""}`);
2986
+ console.log(`${parsed.profileDirectory ? "Use another" : "Pick one"} per command: rech --profile <name> open https://example.com (see them again with rech profile)`);
2987
+ }
2824
2988
  }
2825
2989
 
2826
2990
  export function detectSetupAgent(env: Record<string, string | undefined> = process.env): "Codex" | "Claude Code" | null {
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";
@@ -411,12 +412,12 @@ export const LANDING_HEADERS = {
411
412
  "Cache-Control": "no-store",
412
413
  "Referrer-Policy": "no-referrer",
413
414
  "X-Content-Type-Options": "nosniff",
414
- "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
415
+ "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
415
416
  };
416
417
 
417
418
  /**
418
- * The page a shared connection URL shows in a browser: install-and-connect commands to copy,
419
- * one per shell. It is static; the script reads the full URL (with its #key) from the address
419
+ * The page a shared connection URL shows in a browser: two install-and-connect lines to copy,
420
+ * identical for every shell. It is static; the script reads the full URL (with its #key) from the address
420
421
  * bar, shows it with the key masked, and copies it whole.
421
422
  */
422
423
  export function landingPage(): string {
@@ -432,56 +433,82 @@ main { max-width: 760px; margin: 0 auto; padding: 40px 16px 56px; }
432
433
  h1 { font-size: 1.6rem; margin: 0 0 8px; }
433
434
  p { margin: 0 0 16px; color: var(--muted); }
434
435
  .row { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; margin: 0 0 12px; }
435
- .label { font-size: .8rem; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin-bottom: 6px; }
436
436
  .cmd { display: flex; gap: 10px; align-items: flex-start; }
437
437
  code { flex: 1; font: 13px/1.5 ui-monospace, "SF Mono", Menlo, Consolas, monospace; word-break: break-all; white-space: pre-wrap; }
438
438
  button { flex: none; font: inherit; font-size: .9rem; padding: 4px 12px; border-radius: 6px; border: 1px solid var(--line); background: var(--bg); color: var(--fg); cursor: pointer; }
439
439
  button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
440
440
  .warn { color: var(--warn); font-weight: 600; }
441
441
  .small { font-size: .85rem; }
442
+ #plist { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 16px; }
443
+ #plist button[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); font-weight: 600; }
442
444
  a { color: var(--accent); }
443
445
  </style></head>
444
446
  <body><main>
445
447
  <h1>Connect to a shared Chrome</h1>
446
448
  <p>Someone shared a Chrome profile with you through <a href="https://github.com/snomiao/rechrome">rechrome</a>.
447
- On your computer, inside the project folder that should use it, run:</p>
449
+ On your computer, inside the project folder that should use it, run these two lines
450
+ (the same in macOS/Linux terminals, PowerShell and cmd):</p>
448
451
  <p id="nokey" class="warn" hidden>This link is missing its key (the part after #key=). Ask for the full link from <code>rech share</code>.</p>
449
452
  <div id="cmds"></div>
453
+ <div id="profiles" hidden><p>Profiles this link can use (pick one to make it the default in the command above):</p><div id="plist"></div></div>
450
454
  <p class="small">Needs <a href="https://bun.sh">Bun</a>. Then try <code>rechrome open https://example.com</code>.
451
455
  This link contains a secret key: anyone with it can use this browser profile, so share it privately.</p>
452
456
  </main>
453
457
  <script>
454
458
  (() => {
455
- const url = location.href;
459
+ let url = location.href;
460
+ const key = (location.hash.match(/[#&]key=([^&]*)/) || [])[1];
456
461
  if (!/[#&?]key=/.test(location.hash)) document.getElementById("nokey").hidden = false;
457
- const posix = s => "'" + s.replace(/'/g, "'\\\\''") + "'";
458
- const pwsh = s => '"' + s.replace(/[\`"$]/g, c => "\`" + c) + '"';
459
- const cmd = s => '"' + s.replace(/"/g, "%22") + '"';
460
- const shells = [
461
- ["macOS / Linux", "bun i -g rechrome && rechrome connect " + posix(url)],
462
- ["Windows PowerShell", "bun i -g rechrome; rechrome connect " + pwsh(url)],
463
- ["Windows cmd", "bun i -g rechrome && rechrome connect " + cmd(url)],
464
- ];
465
- const mask = s => s.replace(/(key=)[^&"'\`]+/, "$1…");
462
+ // One command for every shell: two lines (no && vs ; split), one double-quoted URL. Characters a
463
+ // shell expands inside double quotes ($ \` " \\ !) are percent-encoded, which the URL reads the same;
464
+ // %40 goes back to a readable @ (valid in a query, and one less % for cmd to try to expand).
465
+ const quote = s => '"' + s.replace(/%40/g, "@").replace(/[$\`"\\\\!]/g, c => "%" + c.charCodeAt(0).toString(16).toUpperCase()) + '"';
466
+ const build = u => "bun i -g rechrome\\nrechrome connect " + quote(u);
467
+ const mask = s => s.replace(/(key=)[^&"\\n]+/, "$1…");
466
468
  const root = document.getElementById("cmds");
467
- for (const [label, text] of shells) {
468
- const row = document.createElement("div"); row.className = "row";
469
- const name = document.createElement("div"); name.className = "label"; name.textContent = label;
470
- const line = document.createElement("div"); line.className = "cmd";
471
- const code = document.createElement("code"); code.textContent = mask(text);
472
- const button = document.createElement("button"); button.type = "button"; button.textContent = "Copy";
473
- button.setAttribute("aria-label", "Copy the " + label + " command");
474
- button.addEventListener("click", async () => {
475
- try { await navigator.clipboard.writeText(text); }
476
- catch {
477
- // Plain-HTTP pages (e.g. a LAN address) have no clipboard API: copy via a hidden textarea.
478
- const area = document.createElement("textarea"); area.value = text; document.body.append(area);
479
- area.select(); document.execCommand("copy"); area.remove();
469
+ const row = document.createElement("div"); row.className = "row";
470
+ const line = document.createElement("div"); line.className = "cmd";
471
+ const code = document.createElement("code");
472
+ const entry = { text: "", update() { this.text = build(url); code.textContent = mask(this.text); } };
473
+ entry.update();
474
+ const button = document.createElement("button"); button.type = "button"; button.textContent = "Copy";
475
+ button.setAttribute("aria-label", "Copy the commands");
476
+ button.addEventListener("click", async () => {
477
+ const text = entry.text;
478
+ try { await navigator.clipboard.writeText(text); }
479
+ catch {
480
+ // Plain-HTTP pages (e.g. a LAN address) have no clipboard API: copy via a hidden textarea.
481
+ const area = document.createElement("textarea"); area.value = text; document.body.append(area);
482
+ area.select(); document.execCommand("copy"); area.remove();
483
+ }
484
+ button.textContent = "Copied"; setTimeout(() => { button.textContent = "Copy"; }, 1500);
485
+ });
486
+ line.append(code, button); row.append(line); root.append(row);
487
+ // The key never leaves the browser except to this listener's own /ping, which lists what it
488
+ // allows. Picking a profile only rewrites ?profile= in the command (the key works for all).
489
+ if (!key || typeof fetch !== "function") return;
490
+ const path = location.href.split("#")[0].split("?")[0], base = path.endsWith("/") ? path : path + "/";
491
+ fetch(base + "ping", { headers: { Authorization: "Bearer " + decodeURIComponent(key) } })
492
+ .then(r => r.ok ? r.json() : null)
493
+ .then(body => {
494
+ const profiles = body && Array.isArray(body.profiles) ? body.profiles : [];
495
+ if (!profiles.length) return;
496
+ const list = document.getElementById("plist"), buttons = [];
497
+ const select = p => {
498
+ const u = new URL(url); u.searchParams.set("profile", p); url = u.href;
499
+ for (const b of buttons) b.setAttribute("aria-pressed", String(b.textContent === p));
500
+ entry.update();
501
+ };
502
+ const current = new URL(url).searchParams.get("profile");
503
+ for (const p of profiles) {
504
+ const b = document.createElement("button"); b.type = "button"; b.textContent = p;
505
+ b.setAttribute("aria-pressed", String(p === current));
506
+ b.addEventListener("click", async () => select(p));
507
+ buttons.push(b); list.append(b);
480
508
  }
481
- button.textContent = "Copied"; setTimeout(() => { button.textContent = "Copy"; }, 1500);
482
- });
483
- line.append(code, button); row.append(name, line); root.append(row);
484
- }
509
+ document.getElementById("profiles").hidden = false;
510
+ })
511
+ .catch(() => {});
485
512
  })();
486
513
  </script>
487
514
  </body></html>
@@ -1015,4 +1042,19 @@ export async function serve() {
1015
1042
  } finally { reloading = false; }
1016
1043
  }, 1000);
1017
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);
1018
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";
@@ -411,12 +412,12 @@ export const LANDING_HEADERS = {
411
412
  "Cache-Control": "no-store",
412
413
  "Referrer-Policy": "no-referrer",
413
414
  "X-Content-Type-Options": "nosniff",
414
- "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
415
+ "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
415
416
  };
416
417
 
417
418
  /**
418
- * The page a shared connection URL shows in a browser: install-and-connect commands to copy,
419
- * one per shell. It is static; the script reads the full URL (with its #key) from the address
419
+ * The page a shared connection URL shows in a browser: two install-and-connect lines to copy,
420
+ * identical for every shell. It is static; the script reads the full URL (with its #key) from the address
420
421
  * bar, shows it with the key masked, and copies it whole.
421
422
  */
422
423
  export function landingPage(): string {
@@ -432,56 +433,82 @@ main { max-width: 760px; margin: 0 auto; padding: 40px 16px 56px; }
432
433
  h1 { font-size: 1.6rem; margin: 0 0 8px; }
433
434
  p { margin: 0 0 16px; color: var(--muted); }
434
435
  .row { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; margin: 0 0 12px; }
435
- .label { font-size: .8rem; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin-bottom: 6px; }
436
436
  .cmd { display: flex; gap: 10px; align-items: flex-start; }
437
437
  code { flex: 1; font: 13px/1.5 ui-monospace, "SF Mono", Menlo, Consolas, monospace; word-break: break-all; white-space: pre-wrap; }
438
438
  button { flex: none; font: inherit; font-size: .9rem; padding: 4px 12px; border-radius: 6px; border: 1px solid var(--line); background: var(--bg); color: var(--fg); cursor: pointer; }
439
439
  button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
440
440
  .warn { color: var(--warn); font-weight: 600; }
441
441
  .small { font-size: .85rem; }
442
+ #plist { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 16px; }
443
+ #plist button[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); font-weight: 600; }
442
444
  a { color: var(--accent); }
443
445
  </style></head>
444
446
  <body><main>
445
447
  <h1>Connect to a shared Chrome</h1>
446
448
  <p>Someone shared a Chrome profile with you through <a href="https://github.com/snomiao/rechrome">rechrome</a>.
447
- On your computer, inside the project folder that should use it, run:</p>
449
+ On your computer, inside the project folder that should use it, run these two lines
450
+ (the same in macOS/Linux terminals, PowerShell and cmd):</p>
448
451
  <p id="nokey" class="warn" hidden>This link is missing its key (the part after #key=). Ask for the full link from <code>rech share</code>.</p>
449
452
  <div id="cmds"></div>
453
+ <div id="profiles" hidden><p>Profiles this link can use (pick one to make it the default in the command above):</p><div id="plist"></div></div>
450
454
  <p class="small">Needs <a href="https://bun.sh">Bun</a>. Then try <code>rechrome open https://example.com</code>.
451
455
  This link contains a secret key: anyone with it can use this browser profile, so share it privately.</p>
452
456
  </main>
453
457
  <script>
454
458
  (() => {
455
- const url = location.href;
459
+ let url = location.href;
460
+ const key = (location.hash.match(/[#&]key=([^&]*)/) || [])[1];
456
461
  if (!/[#&?]key=/.test(location.hash)) document.getElementById("nokey").hidden = false;
457
- const posix = s => "'" + s.replace(/'/g, "'\\\\''") + "'";
458
- const pwsh = s => '"' + s.replace(/[\`"$]/g, c => "\`" + c) + '"';
459
- const cmd = s => '"' + s.replace(/"/g, "%22") + '"';
460
- const shells = [
461
- ["macOS / Linux", "bun i -g rechrome && rechrome connect " + posix(url)],
462
- ["Windows PowerShell", "bun i -g rechrome; rechrome connect " + pwsh(url)],
463
- ["Windows cmd", "bun i -g rechrome && rechrome connect " + cmd(url)],
464
- ];
465
- const mask = s => s.replace(/(key=)[^&"'\`]+/, "$1…");
462
+ // One command for every shell: two lines (no && vs ; split), one double-quoted URL. Characters a
463
+ // shell expands inside double quotes ($ \` " \\ !) are percent-encoded, which the URL reads the same;
464
+ // %40 goes back to a readable @ (valid in a query, and one less % for cmd to try to expand).
465
+ const quote = s => '"' + s.replace(/%40/g, "@").replace(/[$\`"\\\\!]/g, c => "%" + c.charCodeAt(0).toString(16).toUpperCase()) + '"';
466
+ const build = u => "bun i -g rechrome\\nrechrome connect " + quote(u);
467
+ const mask = s => s.replace(/(key=)[^&"\\n]+/, "$1…");
466
468
  const root = document.getElementById("cmds");
467
- for (const [label, text] of shells) {
468
- const row = document.createElement("div"); row.className = "row";
469
- const name = document.createElement("div"); name.className = "label"; name.textContent = label;
470
- const line = document.createElement("div"); line.className = "cmd";
471
- const code = document.createElement("code"); code.textContent = mask(text);
472
- const button = document.createElement("button"); button.type = "button"; button.textContent = "Copy";
473
- button.setAttribute("aria-label", "Copy the " + label + " command");
474
- button.addEventListener("click", async () => {
475
- try { await navigator.clipboard.writeText(text); }
476
- catch {
477
- // Plain-HTTP pages (e.g. a LAN address) have no clipboard API: copy via a hidden textarea.
478
- const area = document.createElement("textarea"); area.value = text; document.body.append(area);
479
- area.select(); document.execCommand("copy"); area.remove();
469
+ const row = document.createElement("div"); row.className = "row";
470
+ const line = document.createElement("div"); line.className = "cmd";
471
+ const code = document.createElement("code");
472
+ const entry = { text: "", update() { this.text = build(url); code.textContent = mask(this.text); } };
473
+ entry.update();
474
+ const button = document.createElement("button"); button.type = "button"; button.textContent = "Copy";
475
+ button.setAttribute("aria-label", "Copy the commands");
476
+ button.addEventListener("click", async () => {
477
+ const text = entry.text;
478
+ try { await navigator.clipboard.writeText(text); }
479
+ catch {
480
+ // Plain-HTTP pages (e.g. a LAN address) have no clipboard API: copy via a hidden textarea.
481
+ const area = document.createElement("textarea"); area.value = text; document.body.append(area);
482
+ area.select(); document.execCommand("copy"); area.remove();
483
+ }
484
+ button.textContent = "Copied"; setTimeout(() => { button.textContent = "Copy"; }, 1500);
485
+ });
486
+ line.append(code, button); row.append(line); root.append(row);
487
+ // The key never leaves the browser except to this listener's own /ping, which lists what it
488
+ // allows. Picking a profile only rewrites ?profile= in the command (the key works for all).
489
+ if (!key || typeof fetch !== "function") return;
490
+ const path = location.href.split("#")[0].split("?")[0], base = path.endsWith("/") ? path : path + "/";
491
+ fetch(base + "ping", { headers: { Authorization: "Bearer " + decodeURIComponent(key) } })
492
+ .then(r => r.ok ? r.json() : null)
493
+ .then(body => {
494
+ const profiles = body && Array.isArray(body.profiles) ? body.profiles : [];
495
+ if (!profiles.length) return;
496
+ const list = document.getElementById("plist"), buttons = [];
497
+ const select = p => {
498
+ const u = new URL(url); u.searchParams.set("profile", p); url = u.href;
499
+ for (const b of buttons) b.setAttribute("aria-pressed", String(b.textContent === p));
500
+ entry.update();
501
+ };
502
+ const current = new URL(url).searchParams.get("profile");
503
+ for (const p of profiles) {
504
+ const b = document.createElement("button"); b.type = "button"; b.textContent = p;
505
+ b.setAttribute("aria-pressed", String(p === current));
506
+ b.addEventListener("click", async () => select(p));
507
+ buttons.push(b); list.append(b);
480
508
  }
481
- button.textContent = "Copied"; setTimeout(() => { button.textContent = "Copy"; }, 1500);
482
- });
483
- line.append(code, button); row.append(name, line); root.append(row);
484
- }
509
+ document.getElementById("profiles").hidden = false;
510
+ })
511
+ .catch(() => {});
485
512
  })();
486
513
  </script>
487
514
  </body></html>
@@ -1015,4 +1042,19 @@ export async function serve() {
1015
1042
  } finally { reloading = false; }
1016
1043
  }, 1000);
1017
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);
1018
1060
  }