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 +1 -1
- package/rechrome.js +74 -11
- package/rechrome.ts +74 -11
- package/serve.js +58 -32
- package/serve.ts +58 -32
package/package.json
CHANGED
package/rechrome.js
CHANGED
|
@@ -1240,14 +1240,28 @@ export async function promptChoice<T>(
|
|
|
1240
1240
|
} finally { rl.close(); }
|
|
1241
1241
|
}
|
|
1242
1242
|
|
|
1243
|
-
/**
|
|
1244
|
-
|
|
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]
|
|
1247
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1447
|
-
|
|
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
|
|
2823
|
-
console.log(`This link shares ${body.profiles.length} profiles
|
|
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
|
-
/**
|
|
1244
|
-
|
|
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]
|
|
1247
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1447
|
-
|
|
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
|
|
2823
|
-
console.log(`This link shares ${body.profiles.length} profiles
|
|
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
|
|
419
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
const
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
|
|
482
|
-
})
|
|
483
|
-
|
|
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
|
|
419
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
const
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
|
|
482
|
-
})
|
|
483
|
-
|
|
484
|
-
}
|
|
508
|
+
document.getElementById("profiles").hidden = false;
|
|
509
|
+
})
|
|
510
|
+
.catch(() => {});
|
|
485
511
|
})();
|
|
486
512
|
</script>
|
|
487
513
|
</body></html>
|