rechrome 1.31.7 → 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 +114 -69
- package/serve.ts +114 -69
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
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { readListeners, listenerAddress, authorizeProfileRequest, canReadProfileFile, profileOutputPrefix, normalizePrefix, resolveAllowedProfile, type Listener } from "./listeners.js";
|
|
2
2
|
import { file } from "bun";
|
|
3
3
|
import { createHash, X509Certificate } from "crypto";
|
|
4
|
-
import { mkdirSync, unlinkSync, accessSync, readdirSync, realpathSync, constants as fsConstants } from "fs";
|
|
4
|
+
import { mkdirSync, lstatSync, chmodSync, unlinkSync, accessSync, readdirSync, realpathSync, constants as fsConstants } from "fs";
|
|
5
5
|
import { join, resolve, relative, isAbsolute } from "path";
|
|
6
|
+
import { tmpdir } from "os";
|
|
6
7
|
import {
|
|
7
8
|
log,
|
|
8
9
|
parseUrl,
|
|
@@ -136,8 +137,42 @@ export function inferSilentExtensionFailure(options: {
|
|
|
136
137
|
return `Extension connection timeout after ${handshakeTimeoutMs}ms. Automatic recovery retry failed; reload the Playwright MCP Bridge extension at chrome://extensions and retry.\n`;
|
|
137
138
|
}
|
|
138
139
|
|
|
139
|
-
|
|
140
|
-
|
|
140
|
+
// Every CLI child must agree on its socket namespace, including probes and reapers.
|
|
141
|
+
export function playwrightCliEnv(
|
|
142
|
+
overrides: Record<string, string | undefined> = {},
|
|
143
|
+
runtime = { env: process.env, platform: process.platform, uid: process.getuid?.() },
|
|
144
|
+
): Record<string, string | undefined> {
|
|
145
|
+
const source = runtime.env;
|
|
146
|
+
const env: Record<string, string | undefined> = {
|
|
147
|
+
PATH: source.PATH,
|
|
148
|
+
HOME: source.HOME || HOME,
|
|
149
|
+
USERPROFILE: source.USERPROFILE,
|
|
150
|
+
TMPDIR: source.TMPDIR,
|
|
151
|
+
DISPLAY: source.DISPLAY,
|
|
152
|
+
XDG_RUNTIME_DIR: source.XDG_RUNTIME_DIR,
|
|
153
|
+
DEBUG: source.DEBUG,
|
|
154
|
+
PWDEBUG: source.PWDEBUG,
|
|
155
|
+
...overrides,
|
|
156
|
+
PLAYWRIGHT_SOCKETS_DIR: source.PLAYWRIGHT_SOCKETS_DIR,
|
|
157
|
+
};
|
|
158
|
+
if (runtime.platform === "darwin") {
|
|
159
|
+
if (runtime.uid === undefined) throw new Error("Cannot determine socket directory owner");
|
|
160
|
+
const dir = source.PLAYWRIGHT_SOCKETS_DIR || `/tmp/pw-${runtime.uid}`;
|
|
161
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
162
|
+
const stat = lstatSync(dir);
|
|
163
|
+
if (!stat.isDirectory() || stat.uid !== runtime.uid)
|
|
164
|
+
throw new Error(`Refusing unsafe Playwright socket directory: ${dir}`);
|
|
165
|
+
chmodSync(dir, 0o700);
|
|
166
|
+
env.PLAYWRIGHT_SOCKETS_DIR = dir;
|
|
167
|
+
}
|
|
168
|
+
return env;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function tmpSocketRoot(env = playwrightCliEnv()): string {
|
|
172
|
+
// Match the shipped coreBundle makeSocketPath('cli', session). CLI children do
|
|
173
|
+
// not inherit USER/USERNAME, so the vendor's fallback username is "default".
|
|
174
|
+
const userHash = createHash("sha1").update(env.USERNAME || env.USER || "default").digest("hex").slice(0, 8);
|
|
175
|
+
return join(env.PLAYWRIGHT_SOCKETS_DIR || join(env.TMPDIR || tmpdir(), `pw-${userHash}`), "cli");
|
|
141
176
|
}
|
|
142
177
|
|
|
143
178
|
// On startup, adopt any iso-* sessions a previous daemon left behind so they still get reaped
|
|
@@ -146,15 +181,11 @@ function tmpSocketRoot(): string {
|
|
|
146
181
|
function adoptOrphanedIsoSessions(): void {
|
|
147
182
|
try {
|
|
148
183
|
const root = tmpSocketRoot();
|
|
149
|
-
for (const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
if (m && !isoLastUsed.has(m[1])) {
|
|
155
|
-
isoLastUsed.set(m[1], Date.now());
|
|
156
|
-
log(`adopted orphaned isolated session for reaping: ${m[1]}`);
|
|
157
|
-
}
|
|
184
|
+
for (const f of readdirSync(root)) {
|
|
185
|
+
const sess = f.endsWith(".sock") ? f.slice(0, -5) : "";
|
|
186
|
+
if (isIsoSession(sess) && !isoLastUsed.has(sess)) {
|
|
187
|
+
isoLastUsed.set(sess, Date.now());
|
|
188
|
+
log(`adopted orphaned isolated session for reaping: ${sess}`);
|
|
158
189
|
}
|
|
159
190
|
}
|
|
160
191
|
} catch {
|
|
@@ -174,7 +205,7 @@ function reapIdleIsoSessions(bin: string, binArgs: string[], workDir: string): v
|
|
|
174
205
|
stdout: "ignore",
|
|
175
206
|
stderr: "ignore",
|
|
176
207
|
windowsHide: true, // hide the CLI child's console; the user's Chrome (a GUI grandchild) stays visible
|
|
177
|
-
env:
|
|
208
|
+
env: playwrightCliEnv(),
|
|
178
209
|
});
|
|
179
210
|
log(`reaped idle isolated session (idle ${Math.round((now - last) / 1000)}s): ${sess}`);
|
|
180
211
|
} catch (e) {
|
|
@@ -380,12 +411,12 @@ export const LANDING_HEADERS = {
|
|
|
380
411
|
"Cache-Control": "no-store",
|
|
381
412
|
"Referrer-Policy": "no-referrer",
|
|
382
413
|
"X-Content-Type-Options": "nosniff",
|
|
383
|
-
"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'",
|
|
384
415
|
};
|
|
385
416
|
|
|
386
417
|
/**
|
|
387
|
-
* The page a shared connection URL shows in a browser: install-and-connect
|
|
388
|
-
*
|
|
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
|
|
389
420
|
* bar, shows it with the key masked, and copies it whole.
|
|
390
421
|
*/
|
|
391
422
|
export function landingPage(): string {
|
|
@@ -401,56 +432,82 @@ main { max-width: 760px; margin: 0 auto; padding: 40px 16px 56px; }
|
|
|
401
432
|
h1 { font-size: 1.6rem; margin: 0 0 8px; }
|
|
402
433
|
p { margin: 0 0 16px; color: var(--muted); }
|
|
403
434
|
.row { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; margin: 0 0 12px; }
|
|
404
|
-
.label { font-size: .8rem; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin-bottom: 6px; }
|
|
405
435
|
.cmd { display: flex; gap: 10px; align-items: flex-start; }
|
|
406
436
|
code { flex: 1; font: 13px/1.5 ui-monospace, "SF Mono", Menlo, Consolas, monospace; word-break: break-all; white-space: pre-wrap; }
|
|
407
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; }
|
|
408
438
|
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
409
439
|
.warn { color: var(--warn); font-weight: 600; }
|
|
410
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; }
|
|
411
443
|
a { color: var(--accent); }
|
|
412
444
|
</style></head>
|
|
413
445
|
<body><main>
|
|
414
446
|
<h1>Connect to a shared Chrome</h1>
|
|
415
447
|
<p>Someone shared a Chrome profile with you through <a href="https://github.com/snomiao/rechrome">rechrome</a>.
|
|
416
|
-
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>
|
|
417
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>
|
|
418
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>
|
|
419
453
|
<p class="small">Needs <a href="https://bun.sh">Bun</a>. Then try <code>rechrome open https://example.com</code>.
|
|
420
454
|
This link contains a secret key: anyone with it can use this browser profile, so share it privately.</p>
|
|
421
455
|
</main>
|
|
422
456
|
<script>
|
|
423
457
|
(() => {
|
|
424
|
-
|
|
458
|
+
let url = location.href;
|
|
459
|
+
const key = (location.hash.match(/[#&]key=([^&]*)/) || [])[1];
|
|
425
460
|
if (!/[#&?]key=/.test(location.hash)) document.getElementById("nokey").hidden = false;
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
const
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
["Windows cmd", "bun i -g rechrome && rechrome connect " + cmd(url)],
|
|
433
|
-
];
|
|
434
|
-
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…");
|
|
435
467
|
const root = document.getElementById("cmds");
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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);
|
|
449
507
|
}
|
|
450
|
-
|
|
451
|
-
})
|
|
452
|
-
|
|
453
|
-
}
|
|
508
|
+
document.getElementById("profiles").hidden = false;
|
|
509
|
+
})
|
|
510
|
+
.catch(() => {});
|
|
454
511
|
})();
|
|
455
512
|
</script>
|
|
456
513
|
</body></html>
|
|
@@ -566,7 +623,7 @@ export async function serve() {
|
|
|
566
623
|
const probe = Bun.spawn([pbin, ...pbinArgs, "tab-list", `-s=${target}`], {
|
|
567
624
|
cwd: workDir, stdin: "ignore", stdout: "ignore", stderr: "ignore",
|
|
568
625
|
windowsHide: true,
|
|
569
|
-
env:
|
|
626
|
+
env: playwrightCliEnv(),
|
|
570
627
|
});
|
|
571
628
|
const probeStatus = await Promise.race([
|
|
572
629
|
probe.exited,
|
|
@@ -735,7 +792,7 @@ export async function serve() {
|
|
|
735
792
|
stdout: "pipe",
|
|
736
793
|
stderr: "pipe",
|
|
737
794
|
windowsHide: true, // hide the CLI child's console; the user's Chrome (a GUI grandchild) stays visible
|
|
738
|
-
env:
|
|
795
|
+
env: playwrightCliEnv(),
|
|
739
796
|
});
|
|
740
797
|
const [listStatus, listOut] = await Promise.race([
|
|
741
798
|
Promise.all([listProc.exited, new Response(listProc.stdout).text()]),
|
|
@@ -764,14 +821,6 @@ export async function serve() {
|
|
|
764
821
|
}
|
|
765
822
|
|
|
766
823
|
const childEnv: Record<string, string | undefined> = {
|
|
767
|
-
PATH: process.env.PATH,
|
|
768
|
-
HOME: HOME,
|
|
769
|
-
USERPROFILE: process.env.USERPROFILE,
|
|
770
|
-
TMPDIR: process.env.TMPDIR,
|
|
771
|
-
DISPLAY: process.env.DISPLAY,
|
|
772
|
-
XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR,
|
|
773
|
-
DEBUG: process.env.DEBUG, // forward debug namespaces (e.g. pw:mcp:relay) for diagnostics
|
|
774
|
-
PWDEBUG: process.env.PWDEBUG,
|
|
775
824
|
...(clientName ? { PLAYWRIGHT_MCP_CLIENT_NAME: shortClientLabel(clientName) } : {}),
|
|
776
825
|
...passthroughEnv,
|
|
777
826
|
// Enable extension bridge when credentials are present
|
|
@@ -779,19 +828,15 @@ export async function serve() {
|
|
|
779
828
|
? { PLAYWRIGHT_MCP_EXTENSION: "1" }
|
|
780
829
|
: {}),
|
|
781
830
|
};
|
|
782
|
-
//
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
const
|
|
831
|
+
// Only a remaining open needs cleanup; a successful probe converted it to
|
|
832
|
+
// goto and its live daemon socket must remain reachable.
|
|
833
|
+
if (filteredArgs[0] === "open") {
|
|
834
|
+
const socketRoot = tmpSocketRoot();
|
|
786
835
|
try {
|
|
787
|
-
const
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
if (f.startsWith(namespacedSession)) {
|
|
792
|
-
const sockPath = `${subDir}/${f}`;
|
|
793
|
-
try { unlinkSync(sockPath); log(`Removed stale socket: ${sockPath}`); } catch {}
|
|
794
|
-
}
|
|
836
|
+
for (const f of readdirSync(socketRoot)) {
|
|
837
|
+
if (f === `${namespacedSession}.sock`) {
|
|
838
|
+
const sockPath = join(socketRoot, f);
|
|
839
|
+
try { unlinkSync(sockPath); log(`Removed stale socket: ${sockPath}`); } catch {}
|
|
795
840
|
}
|
|
796
841
|
}
|
|
797
842
|
} catch {}
|
|
@@ -804,7 +849,7 @@ export async function serve() {
|
|
|
804
849
|
stdout: "pipe",
|
|
805
850
|
stderr: "pipe",
|
|
806
851
|
windowsHide: true, // hide the CLI child's console; the user's Chrome (a GUI grandchild) stays visible
|
|
807
|
-
env: childEnv,
|
|
852
|
+
env: playwrightCliEnv(childEnv),
|
|
808
853
|
});
|
|
809
854
|
|
|
810
855
|
const TIMEOUT = 60_000;
|
|
@@ -860,7 +905,7 @@ export async function serve() {
|
|
|
860
905
|
Bun.spawn([bin, ...binArgs, "close", `-s=${namespacedSession}`], {
|
|
861
906
|
cwd: runWorkDir, stdin: "ignore", stdout: "ignore", stderr: "ignore",
|
|
862
907
|
windowsHide: true,
|
|
863
|
-
env:
|
|
908
|
+
env: playwrightCliEnv(),
|
|
864
909
|
});
|
|
865
910
|
} catch {}
|
|
866
911
|
sessionTimeouts.delete(namespacedSession);
|
package/serve.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { readListeners, listenerAddress, authorizeProfileRequest, canReadProfileFile, profileOutputPrefix, normalizePrefix, resolveAllowedProfile, type Listener } from "./listeners.ts";
|
|
2
2
|
import { file } from "bun";
|
|
3
3
|
import { createHash, X509Certificate } from "crypto";
|
|
4
|
-
import { mkdirSync, unlinkSync, accessSync, readdirSync, realpathSync, constants as fsConstants } from "fs";
|
|
4
|
+
import { mkdirSync, lstatSync, chmodSync, unlinkSync, accessSync, readdirSync, realpathSync, constants as fsConstants } from "fs";
|
|
5
5
|
import { join, resolve, relative, isAbsolute } from "path";
|
|
6
|
+
import { tmpdir } from "os";
|
|
6
7
|
import {
|
|
7
8
|
log,
|
|
8
9
|
parseUrl,
|
|
@@ -136,8 +137,42 @@ export function inferSilentExtensionFailure(options: {
|
|
|
136
137
|
return `Extension connection timeout after ${handshakeTimeoutMs}ms. Automatic recovery retry failed; reload the Playwright MCP Bridge extension at chrome://extensions and retry.\n`;
|
|
137
138
|
}
|
|
138
139
|
|
|
139
|
-
|
|
140
|
-
|
|
140
|
+
// Every CLI child must agree on its socket namespace, including probes and reapers.
|
|
141
|
+
export function playwrightCliEnv(
|
|
142
|
+
overrides: Record<string, string | undefined> = {},
|
|
143
|
+
runtime = { env: process.env, platform: process.platform, uid: process.getuid?.() },
|
|
144
|
+
): Record<string, string | undefined> {
|
|
145
|
+
const source = runtime.env;
|
|
146
|
+
const env: Record<string, string | undefined> = {
|
|
147
|
+
PATH: source.PATH,
|
|
148
|
+
HOME: source.HOME || HOME,
|
|
149
|
+
USERPROFILE: source.USERPROFILE,
|
|
150
|
+
TMPDIR: source.TMPDIR,
|
|
151
|
+
DISPLAY: source.DISPLAY,
|
|
152
|
+
XDG_RUNTIME_DIR: source.XDG_RUNTIME_DIR,
|
|
153
|
+
DEBUG: source.DEBUG,
|
|
154
|
+
PWDEBUG: source.PWDEBUG,
|
|
155
|
+
...overrides,
|
|
156
|
+
PLAYWRIGHT_SOCKETS_DIR: source.PLAYWRIGHT_SOCKETS_DIR,
|
|
157
|
+
};
|
|
158
|
+
if (runtime.platform === "darwin") {
|
|
159
|
+
if (runtime.uid === undefined) throw new Error("Cannot determine socket directory owner");
|
|
160
|
+
const dir = source.PLAYWRIGHT_SOCKETS_DIR || `/tmp/pw-${runtime.uid}`;
|
|
161
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
162
|
+
const stat = lstatSync(dir);
|
|
163
|
+
if (!stat.isDirectory() || stat.uid !== runtime.uid)
|
|
164
|
+
throw new Error(`Refusing unsafe Playwright socket directory: ${dir}`);
|
|
165
|
+
chmodSync(dir, 0o700);
|
|
166
|
+
env.PLAYWRIGHT_SOCKETS_DIR = dir;
|
|
167
|
+
}
|
|
168
|
+
return env;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function tmpSocketRoot(env = playwrightCliEnv()): string {
|
|
172
|
+
// Match the shipped coreBundle makeSocketPath('cli', session). CLI children do
|
|
173
|
+
// not inherit USER/USERNAME, so the vendor's fallback username is "default".
|
|
174
|
+
const userHash = createHash("sha1").update(env.USERNAME || env.USER || "default").digest("hex").slice(0, 8);
|
|
175
|
+
return join(env.PLAYWRIGHT_SOCKETS_DIR || join(env.TMPDIR || tmpdir(), `pw-${userHash}`), "cli");
|
|
141
176
|
}
|
|
142
177
|
|
|
143
178
|
// On startup, adopt any iso-* sessions a previous daemon left behind so they still get reaped
|
|
@@ -146,15 +181,11 @@ function tmpSocketRoot(): string {
|
|
|
146
181
|
function adoptOrphanedIsoSessions(): void {
|
|
147
182
|
try {
|
|
148
183
|
const root = tmpSocketRoot();
|
|
149
|
-
for (const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
if (m && !isoLastUsed.has(m[1])) {
|
|
155
|
-
isoLastUsed.set(m[1], Date.now());
|
|
156
|
-
log(`adopted orphaned isolated session for reaping: ${m[1]}`);
|
|
157
|
-
}
|
|
184
|
+
for (const f of readdirSync(root)) {
|
|
185
|
+
const sess = f.endsWith(".sock") ? f.slice(0, -5) : "";
|
|
186
|
+
if (isIsoSession(sess) && !isoLastUsed.has(sess)) {
|
|
187
|
+
isoLastUsed.set(sess, Date.now());
|
|
188
|
+
log(`adopted orphaned isolated session for reaping: ${sess}`);
|
|
158
189
|
}
|
|
159
190
|
}
|
|
160
191
|
} catch {
|
|
@@ -174,7 +205,7 @@ function reapIdleIsoSessions(bin: string, binArgs: string[], workDir: string): v
|
|
|
174
205
|
stdout: "ignore",
|
|
175
206
|
stderr: "ignore",
|
|
176
207
|
windowsHide: true, // hide the CLI child's console; the user's Chrome (a GUI grandchild) stays visible
|
|
177
|
-
env:
|
|
208
|
+
env: playwrightCliEnv(),
|
|
178
209
|
});
|
|
179
210
|
log(`reaped idle isolated session (idle ${Math.round((now - last) / 1000)}s): ${sess}`);
|
|
180
211
|
} catch (e) {
|
|
@@ -380,12 +411,12 @@ export const LANDING_HEADERS = {
|
|
|
380
411
|
"Cache-Control": "no-store",
|
|
381
412
|
"Referrer-Policy": "no-referrer",
|
|
382
413
|
"X-Content-Type-Options": "nosniff",
|
|
383
|
-
"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'",
|
|
384
415
|
};
|
|
385
416
|
|
|
386
417
|
/**
|
|
387
|
-
* The page a shared connection URL shows in a browser: install-and-connect
|
|
388
|
-
*
|
|
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
|
|
389
420
|
* bar, shows it with the key masked, and copies it whole.
|
|
390
421
|
*/
|
|
391
422
|
export function landingPage(): string {
|
|
@@ -401,56 +432,82 @@ main { max-width: 760px; margin: 0 auto; padding: 40px 16px 56px; }
|
|
|
401
432
|
h1 { font-size: 1.6rem; margin: 0 0 8px; }
|
|
402
433
|
p { margin: 0 0 16px; color: var(--muted); }
|
|
403
434
|
.row { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; margin: 0 0 12px; }
|
|
404
|
-
.label { font-size: .8rem; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin-bottom: 6px; }
|
|
405
435
|
.cmd { display: flex; gap: 10px; align-items: flex-start; }
|
|
406
436
|
code { flex: 1; font: 13px/1.5 ui-monospace, "SF Mono", Menlo, Consolas, monospace; word-break: break-all; white-space: pre-wrap; }
|
|
407
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; }
|
|
408
438
|
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
409
439
|
.warn { color: var(--warn); font-weight: 600; }
|
|
410
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; }
|
|
411
443
|
a { color: var(--accent); }
|
|
412
444
|
</style></head>
|
|
413
445
|
<body><main>
|
|
414
446
|
<h1>Connect to a shared Chrome</h1>
|
|
415
447
|
<p>Someone shared a Chrome profile with you through <a href="https://github.com/snomiao/rechrome">rechrome</a>.
|
|
416
|
-
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>
|
|
417
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>
|
|
418
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>
|
|
419
453
|
<p class="small">Needs <a href="https://bun.sh">Bun</a>. Then try <code>rechrome open https://example.com</code>.
|
|
420
454
|
This link contains a secret key: anyone with it can use this browser profile, so share it privately.</p>
|
|
421
455
|
</main>
|
|
422
456
|
<script>
|
|
423
457
|
(() => {
|
|
424
|
-
|
|
458
|
+
let url = location.href;
|
|
459
|
+
const key = (location.hash.match(/[#&]key=([^&]*)/) || [])[1];
|
|
425
460
|
if (!/[#&?]key=/.test(location.hash)) document.getElementById("nokey").hidden = false;
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
const
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
["Windows cmd", "bun i -g rechrome && rechrome connect " + cmd(url)],
|
|
433
|
-
];
|
|
434
|
-
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…");
|
|
435
467
|
const root = document.getElementById("cmds");
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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);
|
|
449
507
|
}
|
|
450
|
-
|
|
451
|
-
})
|
|
452
|
-
|
|
453
|
-
}
|
|
508
|
+
document.getElementById("profiles").hidden = false;
|
|
509
|
+
})
|
|
510
|
+
.catch(() => {});
|
|
454
511
|
})();
|
|
455
512
|
</script>
|
|
456
513
|
</body></html>
|
|
@@ -566,7 +623,7 @@ export async function serve() {
|
|
|
566
623
|
const probe = Bun.spawn([pbin, ...pbinArgs, "tab-list", `-s=${target}`], {
|
|
567
624
|
cwd: workDir, stdin: "ignore", stdout: "ignore", stderr: "ignore",
|
|
568
625
|
windowsHide: true,
|
|
569
|
-
env:
|
|
626
|
+
env: playwrightCliEnv(),
|
|
570
627
|
});
|
|
571
628
|
const probeStatus = await Promise.race([
|
|
572
629
|
probe.exited,
|
|
@@ -735,7 +792,7 @@ export async function serve() {
|
|
|
735
792
|
stdout: "pipe",
|
|
736
793
|
stderr: "pipe",
|
|
737
794
|
windowsHide: true, // hide the CLI child's console; the user's Chrome (a GUI grandchild) stays visible
|
|
738
|
-
env:
|
|
795
|
+
env: playwrightCliEnv(),
|
|
739
796
|
});
|
|
740
797
|
const [listStatus, listOut] = await Promise.race([
|
|
741
798
|
Promise.all([listProc.exited, new Response(listProc.stdout).text()]),
|
|
@@ -764,14 +821,6 @@ export async function serve() {
|
|
|
764
821
|
}
|
|
765
822
|
|
|
766
823
|
const childEnv: Record<string, string | undefined> = {
|
|
767
|
-
PATH: process.env.PATH,
|
|
768
|
-
HOME: HOME,
|
|
769
|
-
USERPROFILE: process.env.USERPROFILE,
|
|
770
|
-
TMPDIR: process.env.TMPDIR,
|
|
771
|
-
DISPLAY: process.env.DISPLAY,
|
|
772
|
-
XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR,
|
|
773
|
-
DEBUG: process.env.DEBUG, // forward debug namespaces (e.g. pw:mcp:relay) for diagnostics
|
|
774
|
-
PWDEBUG: process.env.PWDEBUG,
|
|
775
824
|
...(clientName ? { PLAYWRIGHT_MCP_CLIENT_NAME: shortClientLabel(clientName) } : {}),
|
|
776
825
|
...passthroughEnv,
|
|
777
826
|
// Enable extension bridge when credentials are present
|
|
@@ -779,19 +828,15 @@ export async function serve() {
|
|
|
779
828
|
? { PLAYWRIGHT_MCP_EXTENSION: "1" }
|
|
780
829
|
: {}),
|
|
781
830
|
};
|
|
782
|
-
//
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
const
|
|
831
|
+
// Only a remaining open needs cleanup; a successful probe converted it to
|
|
832
|
+
// goto and its live daemon socket must remain reachable.
|
|
833
|
+
if (filteredArgs[0] === "open") {
|
|
834
|
+
const socketRoot = tmpSocketRoot();
|
|
786
835
|
try {
|
|
787
|
-
const
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
if (f.startsWith(namespacedSession)) {
|
|
792
|
-
const sockPath = `${subDir}/${f}`;
|
|
793
|
-
try { unlinkSync(sockPath); log(`Removed stale socket: ${sockPath}`); } catch {}
|
|
794
|
-
}
|
|
836
|
+
for (const f of readdirSync(socketRoot)) {
|
|
837
|
+
if (f === `${namespacedSession}.sock`) {
|
|
838
|
+
const sockPath = join(socketRoot, f);
|
|
839
|
+
try { unlinkSync(sockPath); log(`Removed stale socket: ${sockPath}`); } catch {}
|
|
795
840
|
}
|
|
796
841
|
}
|
|
797
842
|
} catch {}
|
|
@@ -804,7 +849,7 @@ export async function serve() {
|
|
|
804
849
|
stdout: "pipe",
|
|
805
850
|
stderr: "pipe",
|
|
806
851
|
windowsHide: true, // hide the CLI child's console; the user's Chrome (a GUI grandchild) stays visible
|
|
807
|
-
env: childEnv,
|
|
852
|
+
env: playwrightCliEnv(childEnv),
|
|
808
853
|
});
|
|
809
854
|
|
|
810
855
|
const TIMEOUT = 60_000;
|
|
@@ -860,7 +905,7 @@ export async function serve() {
|
|
|
860
905
|
Bun.spawn([bin, ...binArgs, "close", `-s=${namespacedSession}`], {
|
|
861
906
|
cwd: runWorkDir, stdin: "ignore", stdout: "ignore", stderr: "ignore",
|
|
862
907
|
windowsHide: true,
|
|
863
|
-
env:
|
|
908
|
+
env: playwrightCliEnv(),
|
|
864
909
|
});
|
|
865
910
|
} catch {}
|
|
866
911
|
sessionTimeouts.delete(namespacedSession);
|