pi-browser-harness 0.8.3 → 0.10.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/CHANGELOG.md +50 -0
- package/README.md +242 -181
- package/package.json +1 -1
- package/skills/deep-research/SKILL.md +66 -0
- package/skills/pi-browser-harness/SKILL.md +23 -0
- package/src/cdp/discovery.ts +183 -37
- package/src/cdp/ownership.ts +19 -1
- package/src/cdp/session.ts +74 -0
- package/src/cdp/target-factory.ts +223 -0
- package/src/cdp/window-binding.ts +15 -0
- package/src/client.ts +114 -27
- package/src/daemon/protocol.ts +12 -2
- package/src/daemon/spawn.ts +46 -13
- package/src/deep-research.ts +41 -0
- package/src/domains/forms.ts +355 -0
- package/src/domains/isolated-tab.ts +120 -0
- package/src/domains/navigate.ts +21 -13
- package/src/domains/readpage/capture.ts +46 -0
- package/src/domains/readpage/read-page.ts +119 -0
- package/src/domains/readpage/readability.ts +89 -0
- package/src/domains/render.ts +64 -0
- package/src/domains/search/extract.ts +44 -0
- package/src/domains/search/google-serp.ts +114 -0
- package/src/domains/search/web-search.ts +116 -0
- package/src/domains/setup.ts +4 -2
- package/src/index.ts +33 -7
- package/src/profile/bind.ts +138 -0
- package/src/profile/browser-process.ts +371 -0
- package/src/profile/command.ts +168 -0
- package/src/profile/launch.ts +90 -0
- package/src/profile/list.ts +171 -0
- package/src/profile/paths.ts +166 -0
- package/src/profile/picker.ts +73 -0
- package/src/profile/store.ts +105 -0
- package/src/prompt.ts +92 -116
- package/src/registry.ts +6 -0
- package/src/setup.ts +28 -44
- package/src/state.ts +1 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opening a browser window for a chosen profile.
|
|
3
|
+
*
|
|
4
|
+
* CDP cannot do this: `Target.createTarget` rejects another profile's
|
|
5
|
+
* browserContextId ("Failed to find browser context with id"), and a bare
|
|
6
|
+
* createTarget lands in `defaultBrowserContextId`, which follows window focus.
|
|
7
|
+
* The only way in is the browser's own command line — Chromium's
|
|
8
|
+
* ProcessSingleton hands a second launch's argv to the running browser, which
|
|
9
|
+
* "does what it would have done", i.e. opens a window in `--profile-directory`.
|
|
10
|
+
*
|
|
11
|
+
* The window is then identified by a unique `file://` sentinel page. That
|
|
12
|
+
* matters: a bare `about:blank#token` argument is discarded by Chrome (it opens
|
|
13
|
+
* chrome://newtab instead), so the token has to ride on a real URL. Polling for
|
|
14
|
+
* the sentinel URL — rather than diffing the target list — also keeps
|
|
15
|
+
* identification correct when the user opens tabs at the same moment.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { spawn } from "node:child_process";
|
|
19
|
+
import { randomUUID } from "node:crypto";
|
|
20
|
+
import { rm, writeFile } from "node:fs/promises";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { pathToFileURL } from "node:url";
|
|
24
|
+
import { type Result, err, ok } from "../util/result";
|
|
25
|
+
|
|
26
|
+
export type ProfileWindowRequest = {
|
|
27
|
+
/** Browser binary to invoke. */
|
|
28
|
+
readonly exePath: string;
|
|
29
|
+
/** Profile subdirectory, e.g. "Profile 1". */
|
|
30
|
+
readonly profileDir: string;
|
|
31
|
+
/**
|
|
32
|
+
* Only set when the RUNNING browser was launched with an explicit
|
|
33
|
+
* --user-data-dir. ProcessSingleton keys on this path, so passing one that
|
|
34
|
+
* differs by case or normalisation would start a second browser instead of
|
|
35
|
+
* delegating to the running one.
|
|
36
|
+
*/
|
|
37
|
+
readonly explicitUserDataDir?: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type ProfileWindowHandle = {
|
|
41
|
+
/** file:// URL that will appear as the new window's page target. */
|
|
42
|
+
readonly sentinelUrl: string;
|
|
43
|
+
/** Remove the sentinel file. Safe to call more than once. */
|
|
44
|
+
readonly cleanup: () => Promise<void>;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const SENTINEL_HTML = (token: string): string =>
|
|
48
|
+
`<!doctype html><meta charset="utf-8"><title>${token}</title>` +
|
|
49
|
+
`<body style="font:14px system-ui;padding:2rem;color:#555">pi browser harness — opening this profile…</body>`;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Ask the running browser to open a window in `profileDir`, marked with a
|
|
53
|
+
* unique sentinel page the caller can then find over CDP.
|
|
54
|
+
*/
|
|
55
|
+
export const openProfileWindow = async (
|
|
56
|
+
req: ProfileWindowRequest,
|
|
57
|
+
): Promise<Result<ProfileWindowHandle, string>> => {
|
|
58
|
+
const token = `pi-harness-${randomUUID()}`;
|
|
59
|
+
const file = join(tmpdir(), `${token}.html`);
|
|
60
|
+
try {
|
|
61
|
+
await writeFile(file, SENTINEL_HTML(token), "utf8");
|
|
62
|
+
} catch (e) {
|
|
63
|
+
return err(`could not write the profile handshake page: ${e instanceof Error ? e.message : String(e)}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const sentinelUrl = pathToFileURL(file).href;
|
|
67
|
+
const args: string[] = [];
|
|
68
|
+
if (req.explicitUserDataDir) args.push(`--user-data-dir=${req.explicitUserDataDir}`);
|
|
69
|
+
args.push(`--profile-directory=${req.profileDir}`, "--new-window", sentinelUrl);
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
// No shell: arguments are passed as an array, so profile names and paths
|
|
73
|
+
// containing spaces survive intact on every platform.
|
|
74
|
+
const child = spawn(req.exePath, args, { detached: true, stdio: "ignore" });
|
|
75
|
+
child.unref();
|
|
76
|
+
// The launcher process exits as soon as the running browser takes over the
|
|
77
|
+
// command line; an error here means the binary itself could not be started.
|
|
78
|
+
child.on("error", () => {});
|
|
79
|
+
} catch (e) {
|
|
80
|
+
await rm(file, { force: true }).catch(() => {});
|
|
81
|
+
return err(`could not launch ${req.exePath}: ${e instanceof Error ? e.message : String(e)}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return ok({
|
|
85
|
+
sentinelUrl,
|
|
86
|
+
cleanup: async () => {
|
|
87
|
+
await rm(file, { force: true }).catch(() => {});
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
};
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser profile enumeration.
|
|
3
|
+
*
|
|
4
|
+
* A Chromium user-data-dir registers its profiles in `Local State` under the
|
|
5
|
+
* JSON key `profile.info_cache`, with `profile.profiles_order` giving the
|
|
6
|
+
* user's own ordering and `profile.last_used` naming the active one. Those
|
|
7
|
+
* keys are declared in Chromium's pref_names.h with no platform BUILDFLAG, so
|
|
8
|
+
* the layout is identical on Linux, macOS, and Windows (the C++ constant is
|
|
9
|
+
* now `kProfileAttributes`; the on-disk key never changed).
|
|
10
|
+
*
|
|
11
|
+
* Enumeration is two-tier so a Chromium fork or a future schema change cannot
|
|
12
|
+
* blank the list:
|
|
13
|
+
* tier 1 — `Local State` → `profile.info_cache` (authoritative: display
|
|
14
|
+
* names, accounts, ordering)
|
|
15
|
+
* tier 2 — scan subdirectories holding a `Preferences` file and read
|
|
16
|
+
* `profile.name` + `account_info[0].email`
|
|
17
|
+
*
|
|
18
|
+
* Nothing here throws: an unreadable or malformed file yields an empty list so
|
|
19
|
+
* callers degrade to the harness's pre-existing behavior.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
|
|
25
|
+
export type BrowserProfile = {
|
|
26
|
+
/** Profile subdirectory name, e.g. "Default" or "Profile 1". */
|
|
27
|
+
readonly dir: string;
|
|
28
|
+
/** Display name as shown in the browser's profile menu. */
|
|
29
|
+
readonly name: string;
|
|
30
|
+
/** Signed-in account address, when the profile has one. */
|
|
31
|
+
readonly email?: string;
|
|
32
|
+
/** Picker label — "Name (email)", or "Name (dir)" with no account. */
|
|
33
|
+
readonly label: string;
|
|
34
|
+
/** True for the profile the browser reports as last used. */
|
|
35
|
+
readonly lastUsed: boolean;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Directories inside a user-data-dir that are not user profiles. */
|
|
39
|
+
const NON_PROFILE_DIRS: ReadonlySet<string> = new Set([
|
|
40
|
+
"System Profile",
|
|
41
|
+
"Guest Profile",
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Picker label. The bracketed half is the account address when there is one,
|
|
46
|
+
* and the profile directory otherwise, so every row keeps the same shape.
|
|
47
|
+
*/
|
|
48
|
+
export const formatProfileLabel = (name: string, email: string | undefined, dir: string): string =>
|
|
49
|
+
`${name} (${email && email.length > 0 ? email : dir})`;
|
|
50
|
+
|
|
51
|
+
const readJsonFile = async (path: string): Promise<Record<string, unknown> | null> => {
|
|
52
|
+
try {
|
|
53
|
+
const raw = await readFile(path, "utf8");
|
|
54
|
+
const parsed: unknown = JSON.parse(raw);
|
|
55
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
|
56
|
+
return parsed as Record<string, unknown>;
|
|
57
|
+
} catch {
|
|
58
|
+
// Missing file, permission error, or malformed JSON — the caller falls
|
|
59
|
+
// back to the next tier, and ultimately to an empty list.
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const asRecord = (v: unknown): Record<string, unknown> | null =>
|
|
65
|
+
typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
|
|
66
|
+
|
|
67
|
+
const asString = (v: unknown): string | undefined =>
|
|
68
|
+
typeof v === "string" && v.length > 0 ? v : undefined;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Order profiles the way the browser does: `profiles_order` first (in its own
|
|
72
|
+
* sequence), then anything unlisted, alphabetically.
|
|
73
|
+
*/
|
|
74
|
+
const orderDirs = (dirs: ReadonlyArray<string>, order: ReadonlyArray<string>): ReadonlyArray<string> => {
|
|
75
|
+
const known = order.filter((d) => dirs.includes(d));
|
|
76
|
+
const rest = dirs.filter((d) => !known.includes(d)).sort((a, b) => a.localeCompare(b));
|
|
77
|
+
return [...known, ...rest];
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const buildProfile = (
|
|
81
|
+
dir: string,
|
|
82
|
+
name: string | undefined,
|
|
83
|
+
email: string | undefined,
|
|
84
|
+
lastUsed: boolean,
|
|
85
|
+
): BrowserProfile => {
|
|
86
|
+
const displayName = name ?? dir;
|
|
87
|
+
return {
|
|
88
|
+
dir,
|
|
89
|
+
name: displayName,
|
|
90
|
+
...(email !== undefined ? { email } : {}),
|
|
91
|
+
label: formatProfileLabel(displayName, email, dir),
|
|
92
|
+
lastUsed,
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** Tier 1 — `Local State` → `profile.info_cache`. */
|
|
97
|
+
const listFromLocalState = async (userDataDir: string): Promise<ReadonlyArray<BrowserProfile>> => {
|
|
98
|
+
const localState = await readJsonFile(join(userDataDir, "Local State"));
|
|
99
|
+
if (!localState) return [];
|
|
100
|
+
const profileNode = asRecord(localState["profile"]);
|
|
101
|
+
if (!profileNode) return [];
|
|
102
|
+
const infoCache = asRecord(profileNode["info_cache"]);
|
|
103
|
+
if (!infoCache) return [];
|
|
104
|
+
|
|
105
|
+
const rawOrder = profileNode["profiles_order"];
|
|
106
|
+
const order = Array.isArray(rawOrder) ? rawOrder.filter((v): v is string => typeof v === "string") : [];
|
|
107
|
+
const lastUsed = asString(profileNode["last_used"]);
|
|
108
|
+
|
|
109
|
+
const entries: Array<{ dir: string; entry: Record<string, unknown> }> = [];
|
|
110
|
+
for (const [dir, value] of Object.entries(infoCache)) {
|
|
111
|
+
if (NON_PROFILE_DIRS.has(dir)) continue;
|
|
112
|
+
const entry = asRecord(value);
|
|
113
|
+
if (!entry) continue;
|
|
114
|
+
// Ephemeral (guest-like) profiles vanish when their window closes — never
|
|
115
|
+
// offer one as a durable pin.
|
|
116
|
+
if (entry["is_ephemeral"] === true) continue;
|
|
117
|
+
entries.push({ dir, entry });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const ordered = orderDirs(entries.map((e) => e.dir), order);
|
|
121
|
+
const byDir = new Map(entries.map((e) => [e.dir, e.entry]));
|
|
122
|
+
|
|
123
|
+
return ordered.flatMap((dir) => {
|
|
124
|
+
const entry = byDir.get(dir);
|
|
125
|
+
if (!entry) return [];
|
|
126
|
+
// `name` is the user-visible profile name; `gaia_name` is the Google
|
|
127
|
+
// account's own display name and is the better fallback when a profile
|
|
128
|
+
// still carries an auto-generated name like "Person 1".
|
|
129
|
+
const name = asString(entry["name"]) ?? asString(entry["gaia_name"]);
|
|
130
|
+
const email = asString(entry["user_name"]);
|
|
131
|
+
return [buildProfile(dir, name, email, dir === lastUsed)];
|
|
132
|
+
});
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/** Tier 2 — directory scan, reading each profile's own `Preferences`. */
|
|
136
|
+
const listFromPreferencesScan = async (userDataDir: string): Promise<ReadonlyArray<BrowserProfile>> => {
|
|
137
|
+
let dirents;
|
|
138
|
+
try {
|
|
139
|
+
dirents = await readdir(userDataDir, { withFileTypes: true });
|
|
140
|
+
} catch {
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const candidates = dirents
|
|
145
|
+
.filter((d) => d.isDirectory() && !NON_PROFILE_DIRS.has(d.name))
|
|
146
|
+
.map((d) => d.name)
|
|
147
|
+
.sort((a, b) => a.localeCompare(b));
|
|
148
|
+
|
|
149
|
+
const profiles: BrowserProfile[] = [];
|
|
150
|
+
for (const dir of candidates) {
|
|
151
|
+
const prefs = await readJsonFile(join(userDataDir, dir, "Preferences"));
|
|
152
|
+
if (!prefs) continue; // not a profile directory
|
|
153
|
+
const profileNode = asRecord(prefs["profile"]);
|
|
154
|
+
const name = profileNode ? asString(profileNode["name"]) : undefined;
|
|
155
|
+
const accounts = prefs["account_info"];
|
|
156
|
+
const firstAccount = Array.isArray(accounts) ? asRecord(accounts[0]) : null;
|
|
157
|
+
const email = firstAccount ? asString(firstAccount["email"]) : undefined;
|
|
158
|
+
profiles.push(buildProfile(dir, name, email, false));
|
|
159
|
+
}
|
|
160
|
+
return profiles;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Every selectable profile in a user-data-dir, best-effort. Returns an empty
|
|
165
|
+
* array when the directory is unreadable or holds no profiles.
|
|
166
|
+
*/
|
|
167
|
+
export const listProfiles = async (userDataDir: string): Promise<ReadonlyArray<BrowserProfile>> => {
|
|
168
|
+
const fromLocalState = await listFromLocalState(userDataDir);
|
|
169
|
+
if (fromLocalState.length > 0) return fromLocalState;
|
|
170
|
+
return listFromPreferencesScan(userDataDir);
|
|
171
|
+
};
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filesystem locations for browser profile discovery and pin persistence.
|
|
3
|
+
*
|
|
4
|
+
* Two independent path families live here:
|
|
5
|
+
*
|
|
6
|
+
* 1. Chromium user-data-dir candidates, per platform and channel. A
|
|
7
|
+
* user-data-dir holds `Local State` (the profile registry) and
|
|
8
|
+
* `DevToolsActivePort`; individual profiles are subdirectories of it
|
|
9
|
+
* (`Default`, `Profile 1`, …).
|
|
10
|
+
* Reference: https://chromium.googlesource.com/chromium/src/+/HEAD/docs/user_data_dir.md
|
|
11
|
+
*
|
|
12
|
+
* 2. pi's agent config dir, where the profile pin is stored.
|
|
13
|
+
*
|
|
14
|
+
* On the agent dir: pi exports `getAgentDir()` from
|
|
15
|
+
* `@mariozechner/pi-coding-agent`, but importing it would be a RUNTIME import
|
|
16
|
+
* of the host package. Extensions installed via `pi install npm:...` do not
|
|
17
|
+
* get pi resolvable from their own node_modules (verified: the installed
|
|
18
|
+
* tree's `@mariozechner/` directory is empty), which is why every other import
|
|
19
|
+
* in this codebase is `import type` and erased at build time. So the three
|
|
20
|
+
* lines of `getAgentDir()` are replicated here instead — env var first, then
|
|
21
|
+
* `~/.pi/agent` (pi's dist/config.js: `PI_CODING_AGENT_DIR`, CONFIG_DIR_NAME
|
|
22
|
+
* `.pi`).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { homedir } from "node:os";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
|
|
28
|
+
// ── pi agent config dir ────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
/** pi's env override for the agent dir (APP_NAME.toUpperCase() + "_CODING_AGENT_DIR"). */
|
|
31
|
+
const ENV_AGENT_DIR = "PI_CODING_AGENT_DIR";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Expand a leading `~` exactly as pi's expandTildePath does — `~` and `~/…`
|
|
35
|
+
* only, never `~\…`, on every platform. This deliberately mirrors pi rather
|
|
36
|
+
* than improving on it: the pin has to land in the directory pi itself resolves
|
|
37
|
+
* from the same variable, so being more lenient here would put the file
|
|
38
|
+
* somewhere pi does not look.
|
|
39
|
+
*/
|
|
40
|
+
const expandTildePiCompatible = (path: string): string => {
|
|
41
|
+
if (path === "~") return homedir();
|
|
42
|
+
if (path.startsWith("~/")) return homedir() + path.slice(1);
|
|
43
|
+
return path;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Tilde expansion for values this package interprets on its own. Windows users
|
|
48
|
+
* write `~\dir` as naturally as `~/dir`, and no compatibility constraint
|
|
49
|
+
* applies, so both are accepted.
|
|
50
|
+
*/
|
|
51
|
+
const expandTildeLenient = (path: string): string => {
|
|
52
|
+
if (path === "~") return homedir();
|
|
53
|
+
if (path.startsWith("~/") || path.startsWith("~\\")) return join(homedir(), path.slice(2));
|
|
54
|
+
return path;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** pi's agent config directory — `$PI_CODING_AGENT_DIR` or `~/.pi/agent`. */
|
|
58
|
+
export const agentDir = (): string => {
|
|
59
|
+
const fromEnv = process.env[ENV_AGENT_DIR];
|
|
60
|
+
if (fromEnv) return expandTildePiCompatible(fromEnv);
|
|
61
|
+
return join(homedir(), ".pi", "agent");
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Where the selected-profile pin is persisted. */
|
|
65
|
+
export const pinFilePath = (): string => join(agentDir(), "browser-harness.json");
|
|
66
|
+
|
|
67
|
+
// ── Chromium user-data-dir candidates ──────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Windows resolves its per-user data root from %LOCALAPPDATA%, which can be
|
|
71
|
+
* redirected on roaming / managed accounts. Fall back to the conventional
|
|
72
|
+
* layout only when the variable is absent.
|
|
73
|
+
*/
|
|
74
|
+
const localAppData = (): string => process.env["LOCALAPPDATA"] ?? join(homedir(), "AppData", "Local");
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Every known default user-data-dir for Chromium-family browsers on the
|
|
78
|
+
* current platform. Order is stable but carries no priority — callers probe
|
|
79
|
+
* all of them.
|
|
80
|
+
*
|
|
81
|
+
* Includes paths the harness previously missed: snap Chromium (the default
|
|
82
|
+
* Chromium on Ubuntu) and the Linux-only `$CHROME_USER_DATA_DIR` override
|
|
83
|
+
* documented in user_data_dir.md.
|
|
84
|
+
*/
|
|
85
|
+
export const userDataDirCandidates = (): ReadonlyArray<string> => {
|
|
86
|
+
const home = homedir();
|
|
87
|
+
const dirs: string[] = [];
|
|
88
|
+
|
|
89
|
+
// A user-set override wins a spot at the front regardless of platform.
|
|
90
|
+
const envOverride = process.env["CHROME_USER_DATA_DIR"];
|
|
91
|
+
if (envOverride) dirs.push(expandTildeLenient(envOverride));
|
|
92
|
+
|
|
93
|
+
if (process.platform === "darwin") {
|
|
94
|
+
const support = join(home, "Library", "Application Support");
|
|
95
|
+
dirs.push(
|
|
96
|
+
join(support, "Google/Chrome"),
|
|
97
|
+
join(support, "Google/Chrome Beta"),
|
|
98
|
+
join(support, "Google/Chrome Dev"),
|
|
99
|
+
join(support, "Google/Chrome Canary"),
|
|
100
|
+
join(support, "Chromium"),
|
|
101
|
+
join(support, "BraveSoftware/Brave-Browser"),
|
|
102
|
+
join(support, "BraveSoftware/Brave-Browser-Beta"),
|
|
103
|
+
join(support, "BraveSoftware/Brave-Browser-Nightly"),
|
|
104
|
+
join(support, "BraveSoftware/Brave-Browser-Dev"),
|
|
105
|
+
join(support, "Microsoft Edge"),
|
|
106
|
+
join(support, "Microsoft Edge Beta"),
|
|
107
|
+
join(support, "Microsoft Edge Dev"),
|
|
108
|
+
join(support, "Microsoft Edge Canary"),
|
|
109
|
+
);
|
|
110
|
+
} else if (process.platform === "win32") {
|
|
111
|
+
const lad = localAppData();
|
|
112
|
+
dirs.push(
|
|
113
|
+
join(lad, "Google/Chrome/User Data"),
|
|
114
|
+
join(lad, "Google/Chrome Beta/User Data"),
|
|
115
|
+
join(lad, "Google/Chrome Dev/User Data"),
|
|
116
|
+
join(lad, "Google/Chrome SxS/User Data"),
|
|
117
|
+
join(lad, "Chromium/User Data"),
|
|
118
|
+
join(lad, "BraveSoftware/Brave-Browser/User Data"),
|
|
119
|
+
join(lad, "BraveSoftware/Brave-Browser-Beta/User Data"),
|
|
120
|
+
join(lad, "Microsoft/Edge/User Data"),
|
|
121
|
+
join(lad, "Microsoft/Edge Beta/User Data"),
|
|
122
|
+
join(lad, "Microsoft/Edge Dev/User Data"),
|
|
123
|
+
join(lad, "Microsoft/Edge SxS/User Data"),
|
|
124
|
+
);
|
|
125
|
+
} else {
|
|
126
|
+
dirs.push(
|
|
127
|
+
join(home, ".config/google-chrome"),
|
|
128
|
+
join(home, ".config/google-chrome-beta"),
|
|
129
|
+
join(home, ".config/google-chrome-unstable"),
|
|
130
|
+
join(home, ".config/chromium"),
|
|
131
|
+
join(home, ".config/chromium-browser"),
|
|
132
|
+
join(home, ".config/BraveSoftware/Brave-Browser"),
|
|
133
|
+
join(home, ".config/BraveSoftware/Brave-Browser-Beta"),
|
|
134
|
+
join(home, ".config/BraveSoftware/Brave-Browser-Nightly"),
|
|
135
|
+
join(home, ".config/microsoft-edge"),
|
|
136
|
+
join(home, ".config/microsoft-edge-beta"),
|
|
137
|
+
join(home, ".config/microsoft-edge-dev"),
|
|
138
|
+
// snap (Ubuntu's default Chromium) — confined to ~/snap/<pkg>/common
|
|
139
|
+
join(home, "snap/chromium/common/chromium"),
|
|
140
|
+
// flatpak
|
|
141
|
+
join(home, ".var/app/org.chromium.Chromium/config/chromium"),
|
|
142
|
+
join(home, ".var/app/com.google.Chrome/config/google-chrome"),
|
|
143
|
+
join(home, ".var/app/com.brave.Browser/config/BraveSoftware/Brave-Browser"),
|
|
144
|
+
join(home, ".var/app/com.microsoft.Edge/config/microsoft-edge"),
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// De-duplicate while preserving order — an env override may repeat a default.
|
|
149
|
+
return Array.from(new Set(dirs));
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Human-readable browser name for a user-data-dir, used to disambiguate the
|
|
154
|
+
* picker when more than one browser is installed. Falls back to the directory
|
|
155
|
+
* name, which is already descriptive for every path we generate.
|
|
156
|
+
*/
|
|
157
|
+
export const browserNameForUserDataDir = (userDataDir: string): string => {
|
|
158
|
+
const p = userDataDir.replace(/\\/g, "/");
|
|
159
|
+
const has = (needle: string): boolean => p.toLowerCase().includes(needle);
|
|
160
|
+
if (has("brave")) return "Brave";
|
|
161
|
+
if (has("edge")) return "Edge";
|
|
162
|
+
if (has("chromium")) return "Chromium";
|
|
163
|
+
if (has("chrome")) return "Chrome";
|
|
164
|
+
const last = p.split("/").filter(Boolean).pop();
|
|
165
|
+
return last ?? userDataDir;
|
|
166
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The profile picker, shared by /browser-profile and first-run setup so both
|
|
3
|
+
* show the user exactly the same list.
|
|
4
|
+
*
|
|
5
|
+
* Rendering rules:
|
|
6
|
+
* - one row per profile, labelled "Name (email)" — or "Name (Profile 3)" when
|
|
7
|
+
* the profile has no signed-in account, so every row keeps its shape
|
|
8
|
+
* - the pinned profile is marked with a check
|
|
9
|
+
* - a final row clears the selection, since a pin must be undoable
|
|
10
|
+
*
|
|
11
|
+
* `ctx.ui.select` resolves to undefined when the user presses Escape AND in
|
|
12
|
+
* non-interactive modes (pi's print/RPC UI context returns undefined for every
|
|
13
|
+
* dialog), so callers must treat "no answer" as "change nothing".
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
17
|
+
import type { BrowserProfile } from "./list";
|
|
18
|
+
import type { ProfilePin } from "./store";
|
|
19
|
+
|
|
20
|
+
export const CLEAR_SELECTION_LABEL = "— Clear selection (use whichever window is focused) —";
|
|
21
|
+
|
|
22
|
+
export type ProfileChoice =
|
|
23
|
+
| { readonly kind: "profile"; readonly profile: BrowserProfile }
|
|
24
|
+
| { readonly kind: "clear" }
|
|
25
|
+
| { readonly kind: "cancelled" };
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Two profiles can legitimately share a name and account (the same person
|
|
29
|
+
* signed into two profiles). Labels must stay distinct or the selection cannot
|
|
30
|
+
* be mapped back, so a repeated label gains its directory.
|
|
31
|
+
*/
|
|
32
|
+
const disambiguate = (profiles: ReadonlyArray<BrowserProfile>): ReadonlyArray<string> => {
|
|
33
|
+
const counts = new Map<string, number>();
|
|
34
|
+
for (const p of profiles) counts.set(p.label, (counts.get(p.label) ?? 0) + 1);
|
|
35
|
+
return profiles.map((p) => ((counts.get(p.label) ?? 0) > 1 ? `${p.label} [${p.dir}]` : p.label));
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Show the picker and report what the user chose. Returns "cancelled" when
|
|
40
|
+
* there is no UI or the user dismissed the dialog.
|
|
41
|
+
*/
|
|
42
|
+
export const promptForProfile = async (
|
|
43
|
+
ctx: ExtensionContext,
|
|
44
|
+
profiles: ReadonlyArray<BrowserProfile>,
|
|
45
|
+
currentPin: ProfilePin | null,
|
|
46
|
+
title = "Select browser profile",
|
|
47
|
+
): Promise<ProfileChoice> => {
|
|
48
|
+
if (!ctx.hasUI || profiles.length === 0) return { kind: "cancelled" };
|
|
49
|
+
|
|
50
|
+
const labels = disambiguate(profiles);
|
|
51
|
+
const rows = labels.map((label, i) => {
|
|
52
|
+
const isPinned = currentPin?.profileDir === profiles[i]?.dir;
|
|
53
|
+
return isPinned ? `✓ ${label}` : ` ${label}`;
|
|
54
|
+
});
|
|
55
|
+
const options = [...rows, CLEAR_SELECTION_LABEL];
|
|
56
|
+
|
|
57
|
+
const answer = await ctx.ui.select(title, options);
|
|
58
|
+
if (answer === undefined) return { kind: "cancelled" };
|
|
59
|
+
if (answer === CLEAR_SELECTION_LABEL) return { kind: "clear" };
|
|
60
|
+
|
|
61
|
+
const index = options.indexOf(answer);
|
|
62
|
+
const profile = index >= 0 ? profiles[index] : undefined;
|
|
63
|
+
if (!profile) return { kind: "cancelled" };
|
|
64
|
+
return { kind: "profile", profile };
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** Build the durable pin for a chosen profile. */
|
|
68
|
+
export const pinFor = (userDataDir: string, profile: BrowserProfile): ProfilePin => ({
|
|
69
|
+
userDataDir,
|
|
70
|
+
profileDir: profile.dir,
|
|
71
|
+
label: profile.label,
|
|
72
|
+
savedAt: new Date().toISOString(),
|
|
73
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable persistence for the selected browser profile.
|
|
3
|
+
*
|
|
4
|
+
* The pin must outlive session termination, so it cannot live in pi's session
|
|
5
|
+
* entries (`pi.appendEntry`, see src/state.ts) — those die with the session.
|
|
6
|
+
* It is a small JSON file in pi's agent config dir instead, shared by every
|
|
7
|
+
* project on the machine.
|
|
8
|
+
*
|
|
9
|
+
* The pin deliberately stores only `{ userDataDir, profileDir }`. A
|
|
10
|
+
* `browserContextId` is NOT persisted: Chrome mints fresh context ids on every
|
|
11
|
+
* browser run, so a stored one would be a stale pointer at best and a pointer
|
|
12
|
+
* into the wrong profile at worst. The context is re-resolved on each connect.
|
|
13
|
+
*
|
|
14
|
+
* Every read failure degrades to "no pin" rather than throwing — a corrupt
|
|
15
|
+
* file must never break browser control.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
19
|
+
import { randomUUID } from "node:crypto";
|
|
20
|
+
import { dirname } from "node:path";
|
|
21
|
+
import { type Result, err, ok } from "../util/result";
|
|
22
|
+
import { pinFilePath } from "./paths";
|
|
23
|
+
|
|
24
|
+
export type ProfilePin = {
|
|
25
|
+
/** User-data-dir the profile belongs to — guards against browser switches. */
|
|
26
|
+
readonly userDataDir: string;
|
|
27
|
+
/** Profile subdirectory, e.g. "Profile 1". */
|
|
28
|
+
readonly profileDir: string;
|
|
29
|
+
/** Label captured at selection time, for display without a disk re-read. */
|
|
30
|
+
readonly label: string;
|
|
31
|
+
/** ISO timestamp of the selection. */
|
|
32
|
+
readonly savedAt: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type PinFile = {
|
|
36
|
+
readonly version: 1;
|
|
37
|
+
readonly profile: ProfilePin | null;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const CURRENT_VERSION = 1;
|
|
41
|
+
|
|
42
|
+
const parsePin = (value: unknown): ProfilePin | null => {
|
|
43
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
44
|
+
const o = value as Record<string, unknown>;
|
|
45
|
+
const userDataDir = o["userDataDir"];
|
|
46
|
+
const profileDir = o["profileDir"];
|
|
47
|
+
const label = o["label"];
|
|
48
|
+
const savedAt = o["savedAt"];
|
|
49
|
+
if (typeof userDataDir !== "string" || userDataDir.length === 0) return null;
|
|
50
|
+
if (typeof profileDir !== "string" || profileDir.length === 0) return null;
|
|
51
|
+
return {
|
|
52
|
+
userDataDir,
|
|
53
|
+
profileDir,
|
|
54
|
+
label: typeof label === "string" && label.length > 0 ? label : profileDir,
|
|
55
|
+
savedAt: typeof savedAt === "string" ? savedAt : "",
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The persisted pin, or null when nothing is pinned, the file is missing, or
|
|
61
|
+
* it cannot be parsed. A file written by a newer version is treated as "no
|
|
62
|
+
* pin" rather than guessed at.
|
|
63
|
+
*/
|
|
64
|
+
export const readPin = async (): Promise<ProfilePin | null> => {
|
|
65
|
+
let raw: string;
|
|
66
|
+
try {
|
|
67
|
+
raw = await readFile(pinFilePath(), "utf8");
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
const parsed: unknown = JSON.parse(raw);
|
|
73
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
74
|
+
const file = parsed as Record<string, unknown>;
|
|
75
|
+
if (file["version"] !== CURRENT_VERSION) return null;
|
|
76
|
+
return parsePin(file["profile"]);
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Replace the pin atomically: a sibling temp file plus rename, so a crash
|
|
84
|
+
* mid-write can never leave a half-written pin behind.
|
|
85
|
+
*/
|
|
86
|
+
const writePinFile = async (profile: ProfilePin | null): Promise<Result<void, string>> => {
|
|
87
|
+
const target = pinFilePath();
|
|
88
|
+
const tmp = `${target}.${randomUUID()}.tmp`;
|
|
89
|
+
const payload: PinFile = { version: CURRENT_VERSION, profile };
|
|
90
|
+
try {
|
|
91
|
+
await mkdir(dirname(target), { recursive: true });
|
|
92
|
+
await writeFile(tmp, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
93
|
+
await rename(tmp, target);
|
|
94
|
+
return ok(undefined);
|
|
95
|
+
} catch (e) {
|
|
96
|
+
await unlink(tmp).catch(() => {});
|
|
97
|
+
return err(`could not save profile selection to ${target}: ${e instanceof Error ? e.message : String(e)}`);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** Persist the selected profile. */
|
|
102
|
+
export const writePin = (pin: ProfilePin): Promise<Result<void, string>> => writePinFile(pin);
|
|
103
|
+
|
|
104
|
+
/** Clear the selection, restoring the harness's unpinned behavior. */
|
|
105
|
+
export const clearPin = (): Promise<Result<void, string>> => writePinFile(null);
|