rechrome 1.31.8 → 1.32.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rechrome",
3
- "version": "1.31.8",
3
+ "version": "1.32.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/rechrome.git"
package/rechrome.js CHANGED
@@ -1240,14 +1240,28 @@ export async function promptChoice<T>(
1240
1240
  } finally { rl.close(); }
1241
1241
  }
1242
1242
 
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 }[] {
1243
+ /**
1244
+ * One picker entry per Chrome profile: its Chrome name and signed-in email (from Local State,
1245
+ * for profiles in the default user data dir), then the folder, e.g. `Work · you@example.com [Profile 2]`.
1246
+ */
1247
+ export function registeredProfileChoices(registry: Record<string, TokenEntry>, cache: Record<string, ChromeProfileInfo> | null = null): { label: string; value: string }[] {
1248
+ const defaultDirs = CHROME_LOCAL_STATE_PATHS().map(path => dirname(path));
1245
1249
  return canonicalProfileKeys(registry).map(key => {
1246
- const dir = registry[key].profileDir;
1247
- return { label: key === dir ? key : `${key} [${dir}]`, value: key };
1250
+ const { profileDir: dir, userDataDir } = registry[key];
1251
+ const info = !userDataDir || defaultDirs.includes(userDataDir) ? cache?.[dir] : undefined;
1252
+ const names = [info?.name, info?.user_name, key].filter((n): n is string => !!n && n !== dir);
1253
+ const shown = [...new Set(names)];
1254
+ return { label: shown.length ? `${shown.join(" · ")} [${dir}]` : dir, value: key };
1248
1255
  });
1249
1256
  }
1250
1257
 
1258
+ /** Chrome profiles (default user data dir) that rech has not registered yet: `[folder, info]`. */
1259
+ export function unregisteredChromeProfiles(registry: Record<string, TokenEntry>, cache: Record<string, ChromeProfileInfo> | null): [string, ChromeProfileInfo][] {
1260
+ const defaultDirs = CHROME_LOCAL_STATE_PATHS().map(path => dirname(path));
1261
+ const registered = new Set(Object.values(registry).filter(e => !e.userDataDir || defaultDirs.includes(e.userDataDir)).map(e => e.profileDir));
1262
+ return Object.entries(cache ?? {}).filter(([dir]) => !registered.has(dir));
1263
+ }
1264
+
1251
1265
  /**
1252
1266
  * Which listener `rech share <profile>` uses when none is named: a scoped one that allows the
1253
1267
  * profile, preferring one with a public URL. Never the management listener, whose key gives
@@ -1427,6 +1441,26 @@ async function shareNewLink(profile: string, opts: { local?: boolean; save?: boo
1427
1441
  return shareSet([profile], { ...opts, host, tailscaleServe, defaultName: name, prefix: `/rechrome/${name}/`, again: `rech share ${JSON.stringify(profile)}` });
1428
1442
  }
1429
1443
 
1444
+ /** A scoped listener's full link (contains its key): its public URL when recorded, else its own address. */
1445
+ export function listenerLink(listener: Listener, local = false): string {
1446
+ const profiles = listener.profiles as string[];
1447
+ const only = profiles.length === 1 ? `?profile=${encodeURIComponent(profiles[0])}` : "";
1448
+ const direct = `http://${listener.key}@${listenerAddress(listener)}${normalizePrefix(listener.prefix)}${only}`;
1449
+ return listener.publicUrl && !local ? rebaseConnectionUrl(listener.publicUrl, direct) : registeredProfileUrl(direct);
1450
+ }
1451
+
1452
+ /** The links already handed out (stderr: stdout stays the one URL this command prints). */
1453
+ function printExistingLinks(listeners: Listener[], local = false): void {
1454
+ const scoped = listeners.filter(l => l.profiles !== "*" && l.profiles.length);
1455
+ if (!scoped.length) return;
1456
+ console.error("Already shared (secret links):");
1457
+ for (const l of scoped) {
1458
+ console.error(` ${l.name}: ${(l.profiles as string[]).join(", ")}${!l.publicUrl && isLoopback(l.host) ? " (this machine only: no proxy recorded)" : ""}`);
1459
+ console.error(` ${listenerLink(l, local)}`);
1460
+ }
1461
+ console.error("");
1462
+ }
1463
+
1430
1464
  async function printProfileUri(selector?: string, listener?: string, opts: { local?: boolean; save?: boolean; all?: boolean } = {}): Promise<void> {
1431
1465
  if (opts.all) {
1432
1466
  if (selector) throw new Error("Pass a profile or --all, not both.");
@@ -1437,14 +1471,41 @@ async function printProfileUri(selector?: string, listener?: string, opts: { loc
1437
1471
  const registry = await readTokenRegistry();
1438
1472
  const cache = await readChromeProfileCache();
1439
1473
  const cancelled = () => new Error("Cancelled; nothing shared.");
1474
+ let config = await readListeners();
1440
1475
  const current = resolveEffectiveProfile(url ? parseUrl(url).profileDirectory : undefined);
1441
- const pickProfile = async (question: string, choices = registeredProfileChoices(registry)) => {
1476
+ const sharedOn = (key: string) => ((config?.listeners ?? []).filter(l => l.profiles !== "*" && (l.profiles as string[]).includes(key))).map(l => l.name);
1477
+ // Chrome profiles rech doesn't manage yet are listed too; picking one runs setup for it first.
1478
+ const SETUP = "\0setup:";
1479
+ const pickProfile = async (question: string, choices = [
1480
+ ...registeredProfileChoices(registry, cache).map(c => {
1481
+ const on = sharedOn(c.value);
1482
+ return on.length ? { ...c, label: `${c.label} (shared: ${on.join(", ")})` } : c;
1483
+ }),
1484
+ ...unregisteredChromeProfiles(registry, cache).map(([dir, info]) => ({
1485
+ label: `${[...new Set([info.name, info.user_name].filter(Boolean))].join(" · ") || dir} [${dir}] (not set up: runs rech setup)`,
1486
+ value: `${SETUP}${dir}`,
1487
+ })),
1488
+ ]) => {
1442
1489
  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(); })();
1490
+ const picked = (await promptChoice(question, choices, def)) ?? (() => { throw cancelled(); })();
1491
+ if (!picked.startsWith(SETUP)) return picked;
1492
+ const dir = picked.slice(SETUP.length);
1493
+ console.error(`[rech] ${dir} isn't set up yet; running rech setup --profile ${JSON.stringify(dir)} first.`);
1494
+ await setup({ profile: dir });
1495
+ const after = await readTokenRegistry();
1496
+ const key = unregisteredChromeProfiles(after, { [dir]: {} }).length ? undefined : canonicalProfileKeys(after).find(k => after[k].profileDir === dir);
1497
+ if (process.exitCode || !key) throw new Error(`Setup didn't register ${dir}; nothing shared. Retry with: rech setup --profile ${JSON.stringify(dir)}`);
1498
+ Object.assign(registry, after);
1499
+ config = await readListeners(); // setup may have added a listener: never write back a stale copy
1500
+ return key;
1444
1501
  };
1445
1502
  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?");
1503
+ // No profile given: show what is already shared, then ask, defaulting to the current one
1504
+ // (?profile= in the URL, else PLAYWRIGHT_MCP_PROFILE_DIRECTORY).
1505
+ if (interactive && Object.keys(registry).length) {
1506
+ printExistingLinks(config?.listeners ?? [], opts.local);
1507
+ selector = await pickProfile("Share which profile?");
1508
+ }
1448
1509
  else {
1449
1510
  selector = current;
1450
1511
  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 +1528,6 @@ async function printProfileUri(selector?: string, listener?: string, opts: { loc
1467
1528
  } else throw error;
1468
1529
  }
1469
1530
  }
1470
- const config = await readListeners();
1471
1531
  const listeners = config?.listeners ?? [];
1472
1532
  // On a host, share through a scoped listener by default, never the management key.
1473
1533
  if (!listener && config) {
@@ -2819,8 +2879,11 @@ async function connect(url: string): Promise<void> {
2819
2879
  throw new Error(`Connected, but listener "${body.listener}" does not allow profile "${parsed.profileDirectory}".`);
2820
2880
  const saved = await saveProjectUrl(url);
2821
2881
  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)`);
2882
+ if (Array.isArray(body.profiles) && body.profiles.length > 1) {
2883
+ console.log(`This link shares ${body.profiles.length} profiles:`);
2884
+ for (const p of body.profiles) console.log(` ${p}${p === parsed.profileDirectory ? " (default)" : ""}`);
2885
+ console.log(`${parsed.profileDirectory ? "Use another" : "Pick one"} per command: rech --profile <name> open https://example.com (see them again with rech profile)`);
2886
+ }
2824
2887
  }
2825
2888
 
2826
2889
  export function detectSetupAgent(env: Record<string, string | undefined> = process.env): "Codex" | "Claude Code" | null {
package/rechrome.ts CHANGED
@@ -1240,14 +1240,28 @@ export async function promptChoice<T>(
1240
1240
  } finally { rl.close(); }
1241
1241
  }
1242
1242
 
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 }[] {
1243
+ /**
1244
+ * One picker entry per Chrome profile: its Chrome name and signed-in email (from Local State,
1245
+ * for profiles in the default user data dir), then the folder, e.g. `Work · you@example.com [Profile 2]`.
1246
+ */
1247
+ export function registeredProfileChoices(registry: Record<string, TokenEntry>, cache: Record<string, ChromeProfileInfo> | null = null): { label: string; value: string }[] {
1248
+ const defaultDirs = CHROME_LOCAL_STATE_PATHS().map(path => dirname(path));
1245
1249
  return canonicalProfileKeys(registry).map(key => {
1246
- const dir = registry[key].profileDir;
1247
- return { label: key === dir ? key : `${key} [${dir}]`, value: key };
1250
+ const { profileDir: dir, userDataDir } = registry[key];
1251
+ const info = !userDataDir || defaultDirs.includes(userDataDir) ? cache?.[dir] : undefined;
1252
+ const names = [info?.name, info?.user_name, key].filter((n): n is string => !!n && n !== dir);
1253
+ const shown = [...new Set(names)];
1254
+ return { label: shown.length ? `${shown.join(" · ")} [${dir}]` : dir, value: key };
1248
1255
  });
1249
1256
  }
1250
1257
 
1258
+ /** Chrome profiles (default user data dir) that rech has not registered yet: `[folder, info]`. */
1259
+ export function unregisteredChromeProfiles(registry: Record<string, TokenEntry>, cache: Record<string, ChromeProfileInfo> | null): [string, ChromeProfileInfo][] {
1260
+ const defaultDirs = CHROME_LOCAL_STATE_PATHS().map(path => dirname(path));
1261
+ const registered = new Set(Object.values(registry).filter(e => !e.userDataDir || defaultDirs.includes(e.userDataDir)).map(e => e.profileDir));
1262
+ return Object.entries(cache ?? {}).filter(([dir]) => !registered.has(dir));
1263
+ }
1264
+
1251
1265
  /**
1252
1266
  * Which listener `rech share <profile>` uses when none is named: a scoped one that allows the
1253
1267
  * profile, preferring one with a public URL. Never the management listener, whose key gives
@@ -1427,6 +1441,26 @@ async function shareNewLink(profile: string, opts: { local?: boolean; save?: boo
1427
1441
  return shareSet([profile], { ...opts, host, tailscaleServe, defaultName: name, prefix: `/rechrome/${name}/`, again: `rech share ${JSON.stringify(profile)}` });
1428
1442
  }
1429
1443
 
1444
+ /** A scoped listener's full link (contains its key): its public URL when recorded, else its own address. */
1445
+ export function listenerLink(listener: Listener, local = false): string {
1446
+ const profiles = listener.profiles as string[];
1447
+ const only = profiles.length === 1 ? `?profile=${encodeURIComponent(profiles[0])}` : "";
1448
+ const direct = `http://${listener.key}@${listenerAddress(listener)}${normalizePrefix(listener.prefix)}${only}`;
1449
+ return listener.publicUrl && !local ? rebaseConnectionUrl(listener.publicUrl, direct) : registeredProfileUrl(direct);
1450
+ }
1451
+
1452
+ /** The links already handed out (stderr: stdout stays the one URL this command prints). */
1453
+ function printExistingLinks(listeners: Listener[], local = false): void {
1454
+ const scoped = listeners.filter(l => l.profiles !== "*" && l.profiles.length);
1455
+ if (!scoped.length) return;
1456
+ console.error("Already shared (secret links):");
1457
+ for (const l of scoped) {
1458
+ console.error(` ${l.name}: ${(l.profiles as string[]).join(", ")}${!l.publicUrl && isLoopback(l.host) ? " (this machine only: no proxy recorded)" : ""}`);
1459
+ console.error(` ${listenerLink(l, local)}`);
1460
+ }
1461
+ console.error("");
1462
+ }
1463
+
1430
1464
  async function printProfileUri(selector?: string, listener?: string, opts: { local?: boolean; save?: boolean; all?: boolean } = {}): Promise<void> {
1431
1465
  if (opts.all) {
1432
1466
  if (selector) throw new Error("Pass a profile or --all, not both.");
@@ -1437,14 +1471,41 @@ async function printProfileUri(selector?: string, listener?: string, opts: { loc
1437
1471
  const registry = await readTokenRegistry();
1438
1472
  const cache = await readChromeProfileCache();
1439
1473
  const cancelled = () => new Error("Cancelled; nothing shared.");
1474
+ let config = await readListeners();
1440
1475
  const current = resolveEffectiveProfile(url ? parseUrl(url).profileDirectory : undefined);
1441
- const pickProfile = async (question: string, choices = registeredProfileChoices(registry)) => {
1476
+ const sharedOn = (key: string) => ((config?.listeners ?? []).filter(l => l.profiles !== "*" && (l.profiles as string[]).includes(key))).map(l => l.name);
1477
+ // Chrome profiles rech doesn't manage yet are listed too; picking one runs setup for it first.
1478
+ const SETUP = "\0setup:";
1479
+ const pickProfile = async (question: string, choices = [
1480
+ ...registeredProfileChoices(registry, cache).map(c => {
1481
+ const on = sharedOn(c.value);
1482
+ return on.length ? { ...c, label: `${c.label} (shared: ${on.join(", ")})` } : c;
1483
+ }),
1484
+ ...unregisteredChromeProfiles(registry, cache).map(([dir, info]) => ({
1485
+ label: `${[...new Set([info.name, info.user_name].filter(Boolean))].join(" · ") || dir} [${dir}] (not set up: runs rech setup)`,
1486
+ value: `${SETUP}${dir}`,
1487
+ })),
1488
+ ]) => {
1442
1489
  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(); })();
1490
+ const picked = (await promptChoice(question, choices, def)) ?? (() => { throw cancelled(); })();
1491
+ if (!picked.startsWith(SETUP)) return picked;
1492
+ const dir = picked.slice(SETUP.length);
1493
+ console.error(`[rech] ${dir} isn't set up yet; running rech setup --profile ${JSON.stringify(dir)} first.`);
1494
+ await setup({ profile: dir });
1495
+ const after = await readTokenRegistry();
1496
+ const key = unregisteredChromeProfiles(after, { [dir]: {} }).length ? undefined : canonicalProfileKeys(after).find(k => after[k].profileDir === dir);
1497
+ if (process.exitCode || !key) throw new Error(`Setup didn't register ${dir}; nothing shared. Retry with: rech setup --profile ${JSON.stringify(dir)}`);
1498
+ Object.assign(registry, after);
1499
+ config = await readListeners(); // setup may have added a listener: never write back a stale copy
1500
+ return key;
1444
1501
  };
1445
1502
  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?");
1503
+ // No profile given: show what is already shared, then ask, defaulting to the current one
1504
+ // (?profile= in the URL, else PLAYWRIGHT_MCP_PROFILE_DIRECTORY).
1505
+ if (interactive && Object.keys(registry).length) {
1506
+ printExistingLinks(config?.listeners ?? [], opts.local);
1507
+ selector = await pickProfile("Share which profile?");
1508
+ }
1448
1509
  else {
1449
1510
  selector = current;
1450
1511
  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 +1528,6 @@ async function printProfileUri(selector?: string, listener?: string, opts: { loc
1467
1528
  } else throw error;
1468
1529
  }
1469
1530
  }
1470
- const config = await readListeners();
1471
1531
  const listeners = config?.listeners ?? [];
1472
1532
  // On a host, share through a scoped listener by default, never the management key.
1473
1533
  if (!listener && config) {
@@ -2819,8 +2879,11 @@ async function connect(url: string): Promise<void> {
2819
2879
  throw new Error(`Connected, but listener "${body.listener}" does not allow profile "${parsed.profileDirectory}".`);
2820
2880
  const saved = await saveProjectUrl(url);
2821
2881
  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)`);
2882
+ if (Array.isArray(body.profiles) && body.profiles.length > 1) {
2883
+ console.log(`This link shares ${body.profiles.length} profiles:`);
2884
+ for (const p of body.profiles) console.log(` ${p}${p === parsed.profileDirectory ? " (default)" : ""}`);
2885
+ console.log(`${parsed.profileDirectory ? "Use another" : "Pick one"} per command: rech --profile <name> open https://example.com (see them again with rech profile)`);
2886
+ }
2824
2887
  }
2825
2888
 
2826
2889
  export function detectSetupAgent(env: Record<string, string | undefined> = process.env): "Codex" | "Claude Code" | null {
package/serve.js CHANGED
@@ -411,12 +411,12 @@ export const LANDING_HEADERS = {
411
411
  "Cache-Control": "no-store",
412
412
  "Referrer-Policy": "no-referrer",
413
413
  "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'",
414
+ "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
415
  };
416
416
 
417
417
  /**
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
418
+ * The page a shared connection URL shows in a browser: two install-and-connect lines to copy,
419
+ * identical for every shell. It is static; the script reads the full URL (with its #key) from the address
420
420
  * bar, shows it with the key masked, and copies it whole.
421
421
  */
422
422
  export function landingPage(): string {
@@ -432,56 +432,82 @@ main { max-width: 760px; margin: 0 auto; padding: 40px 16px 56px; }
432
432
  h1 { font-size: 1.6rem; margin: 0 0 8px; }
433
433
  p { margin: 0 0 16px; color: var(--muted); }
434
434
  .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
435
  .cmd { display: flex; gap: 10px; align-items: flex-start; }
437
436
  code { flex: 1; font: 13px/1.5 ui-monospace, "SF Mono", Menlo, Consolas, monospace; word-break: break-all; white-space: pre-wrap; }
438
437
  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
438
  button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
440
439
  .warn { color: var(--warn); font-weight: 600; }
441
440
  .small { font-size: .85rem; }
441
+ #plist { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 16px; }
442
+ #plist button[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); font-weight: 600; }
442
443
  a { color: var(--accent); }
443
444
  </style></head>
444
445
  <body><main>
445
446
  <h1>Connect to a shared Chrome</h1>
446
447
  <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>
448
+ On your computer, inside the project folder that should use it, run these two lines
449
+ (the same in macOS/Linux terminals, PowerShell and cmd):</p>
448
450
  <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
451
  <div id="cmds"></div>
452
+ <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
453
  <p class="small">Needs <a href="https://bun.sh">Bun</a>. Then try <code>rechrome open https://example.com</code>.
451
454
  This link contains a secret key: anyone with it can use this browser profile, so share it privately.</p>
452
455
  </main>
453
456
  <script>
454
457
  (() => {
455
- const url = location.href;
458
+ let url = location.href;
459
+ const key = (location.hash.match(/[#&]key=([^&]*)/) || [])[1];
456
460
  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…");
461
+ // One command for every shell: two lines (no && vs ; split), one double-quoted URL. Characters a
462
+ // shell expands inside double quotes ($ \` " \\ !) are percent-encoded, which the URL reads the same;
463
+ // %40 goes back to a readable @ (valid in a query, and one less % for cmd to try to expand).
464
+ const quote = s => '"' + s.replace(/%40/g, "@").replace(/[$\`"\\\\!]/g, c => "%" + c.charCodeAt(0).toString(16).toUpperCase()) + '"';
465
+ const build = u => "bun i -g rechrome\\nrechrome connect " + quote(u);
466
+ const mask = s => s.replace(/(key=)[^&"\\n]+/, "$1…");
466
467
  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();
468
+ const row = document.createElement("div"); row.className = "row";
469
+ const line = document.createElement("div"); line.className = "cmd";
470
+ const code = document.createElement("code");
471
+ const entry = { text: "", update() { this.text = build(url); code.textContent = mask(this.text); } };
472
+ entry.update();
473
+ const button = document.createElement("button"); button.type = "button"; button.textContent = "Copy";
474
+ button.setAttribute("aria-label", "Copy the commands");
475
+ button.addEventListener("click", async () => {
476
+ const text = entry.text;
477
+ try { await navigator.clipboard.writeText(text); }
478
+ catch {
479
+ // Plain-HTTP pages (e.g. a LAN address) have no clipboard API: copy via a hidden textarea.
480
+ const area = document.createElement("textarea"); area.value = text; document.body.append(area);
481
+ area.select(); document.execCommand("copy"); area.remove();
482
+ }
483
+ button.textContent = "Copied"; setTimeout(() => { button.textContent = "Copy"; }, 1500);
484
+ });
485
+ line.append(code, button); row.append(line); root.append(row);
486
+ // The key never leaves the browser except to this listener's own /ping, which lists what it
487
+ // allows. Picking a profile only rewrites ?profile= in the command (the key works for all).
488
+ if (!key || typeof fetch !== "function") return;
489
+ const path = location.href.split("#")[0].split("?")[0], base = path.endsWith("/") ? path : path + "/";
490
+ fetch(base + "ping", { headers: { Authorization: "Bearer " + decodeURIComponent(key) } })
491
+ .then(r => r.ok ? r.json() : null)
492
+ .then(body => {
493
+ const profiles = body && Array.isArray(body.profiles) ? body.profiles : [];
494
+ if (!profiles.length) return;
495
+ const list = document.getElementById("plist"), buttons = [];
496
+ const select = p => {
497
+ const u = new URL(url); u.searchParams.set("profile", p); url = u.href;
498
+ for (const b of buttons) b.setAttribute("aria-pressed", String(b.textContent === p));
499
+ entry.update();
500
+ };
501
+ const current = new URL(url).searchParams.get("profile");
502
+ for (const p of profiles) {
503
+ const b = document.createElement("button"); b.type = "button"; b.textContent = p;
504
+ b.setAttribute("aria-pressed", String(p === current));
505
+ b.addEventListener("click", async () => select(p));
506
+ buttons.push(b); list.append(b);
480
507
  }
481
- button.textContent = "Copied"; setTimeout(() => { button.textContent = "Copy"; }, 1500);
482
- });
483
- line.append(code, button); row.append(name, line); root.append(row);
484
- }
508
+ document.getElementById("profiles").hidden = false;
509
+ })
510
+ .catch(() => {});
485
511
  })();
486
512
  </script>
487
513
  </body></html>
package/serve.ts CHANGED
@@ -411,12 +411,12 @@ export const LANDING_HEADERS = {
411
411
  "Cache-Control": "no-store",
412
412
  "Referrer-Policy": "no-referrer",
413
413
  "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'",
414
+ "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
415
  };
416
416
 
417
417
  /**
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
418
+ * The page a shared connection URL shows in a browser: two install-and-connect lines to copy,
419
+ * identical for every shell. It is static; the script reads the full URL (with its #key) from the address
420
420
  * bar, shows it with the key masked, and copies it whole.
421
421
  */
422
422
  export function landingPage(): string {
@@ -432,56 +432,82 @@ main { max-width: 760px; margin: 0 auto; padding: 40px 16px 56px; }
432
432
  h1 { font-size: 1.6rem; margin: 0 0 8px; }
433
433
  p { margin: 0 0 16px; color: var(--muted); }
434
434
  .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
435
  .cmd { display: flex; gap: 10px; align-items: flex-start; }
437
436
  code { flex: 1; font: 13px/1.5 ui-monospace, "SF Mono", Menlo, Consolas, monospace; word-break: break-all; white-space: pre-wrap; }
438
437
  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
438
  button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
440
439
  .warn { color: var(--warn); font-weight: 600; }
441
440
  .small { font-size: .85rem; }
441
+ #plist { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 16px; }
442
+ #plist button[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); font-weight: 600; }
442
443
  a { color: var(--accent); }
443
444
  </style></head>
444
445
  <body><main>
445
446
  <h1>Connect to a shared Chrome</h1>
446
447
  <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>
448
+ On your computer, inside the project folder that should use it, run these two lines
449
+ (the same in macOS/Linux terminals, PowerShell and cmd):</p>
448
450
  <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
451
  <div id="cmds"></div>
452
+ <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
453
  <p class="small">Needs <a href="https://bun.sh">Bun</a>. Then try <code>rechrome open https://example.com</code>.
451
454
  This link contains a secret key: anyone with it can use this browser profile, so share it privately.</p>
452
455
  </main>
453
456
  <script>
454
457
  (() => {
455
- const url = location.href;
458
+ let url = location.href;
459
+ const key = (location.hash.match(/[#&]key=([^&]*)/) || [])[1];
456
460
  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…");
461
+ // One command for every shell: two lines (no && vs ; split), one double-quoted URL. Characters a
462
+ // shell expands inside double quotes ($ \` " \\ !) are percent-encoded, which the URL reads the same;
463
+ // %40 goes back to a readable @ (valid in a query, and one less % for cmd to try to expand).
464
+ const quote = s => '"' + s.replace(/%40/g, "@").replace(/[$\`"\\\\!]/g, c => "%" + c.charCodeAt(0).toString(16).toUpperCase()) + '"';
465
+ const build = u => "bun i -g rechrome\\nrechrome connect " + quote(u);
466
+ const mask = s => s.replace(/(key=)[^&"\\n]+/, "$1…");
466
467
  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();
468
+ const row = document.createElement("div"); row.className = "row";
469
+ const line = document.createElement("div"); line.className = "cmd";
470
+ const code = document.createElement("code");
471
+ const entry = { text: "", update() { this.text = build(url); code.textContent = mask(this.text); } };
472
+ entry.update();
473
+ const button = document.createElement("button"); button.type = "button"; button.textContent = "Copy";
474
+ button.setAttribute("aria-label", "Copy the commands");
475
+ button.addEventListener("click", async () => {
476
+ const text = entry.text;
477
+ try { await navigator.clipboard.writeText(text); }
478
+ catch {
479
+ // Plain-HTTP pages (e.g. a LAN address) have no clipboard API: copy via a hidden textarea.
480
+ const area = document.createElement("textarea"); area.value = text; document.body.append(area);
481
+ area.select(); document.execCommand("copy"); area.remove();
482
+ }
483
+ button.textContent = "Copied"; setTimeout(() => { button.textContent = "Copy"; }, 1500);
484
+ });
485
+ line.append(code, button); row.append(line); root.append(row);
486
+ // The key never leaves the browser except to this listener's own /ping, which lists what it
487
+ // allows. Picking a profile only rewrites ?profile= in the command (the key works for all).
488
+ if (!key || typeof fetch !== "function") return;
489
+ const path = location.href.split("#")[0].split("?")[0], base = path.endsWith("/") ? path : path + "/";
490
+ fetch(base + "ping", { headers: { Authorization: "Bearer " + decodeURIComponent(key) } })
491
+ .then(r => r.ok ? r.json() : null)
492
+ .then(body => {
493
+ const profiles = body && Array.isArray(body.profiles) ? body.profiles : [];
494
+ if (!profiles.length) return;
495
+ const list = document.getElementById("plist"), buttons = [];
496
+ const select = p => {
497
+ const u = new URL(url); u.searchParams.set("profile", p); url = u.href;
498
+ for (const b of buttons) b.setAttribute("aria-pressed", String(b.textContent === p));
499
+ entry.update();
500
+ };
501
+ const current = new URL(url).searchParams.get("profile");
502
+ for (const p of profiles) {
503
+ const b = document.createElement("button"); b.type = "button"; b.textContent = p;
504
+ b.setAttribute("aria-pressed", String(p === current));
505
+ b.addEventListener("click", async () => select(p));
506
+ buttons.push(b); list.append(b);
480
507
  }
481
- button.textContent = "Copied"; setTimeout(() => { button.textContent = "Copy"; }, 1500);
482
- });
483
- line.append(code, button); row.append(name, line); root.append(row);
484
- }
508
+ document.getElementById("profiles").hidden = false;
509
+ })
510
+ .catch(() => {});
485
511
  })();
486
512
  </script>
487
513
  </body></html>