aboutsummaryrefslogtreecommitdiff
path: root/src/profile/browser-process.ts
blob: ac31d4d6fe744efa5036ca0031d2531df2829127 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
/**
 * Identifying the browser process the harness is attached to.
 *
 * Two questions, deliberately kept apart:
 *
 *   1. Is a Chromium-family browser RUNNING? (`detectRunningBrowser().running`)
 *      Only live-process evidence may answer this — an installed-but-closed
 *      browser must read as "not running", which is why the registry lookup
 *      below is never consulted here.
 *
 *   2. Which binary do we invoke to open a window for a chosen profile?
 *      (`resolveBrowserExecutable()`) Chromium's ProcessSingleton hands a second
 *      launch's argv to the already-running browser instead of starting another
 *      one — Windows via WM_COPYDATA (process_singleton_win.cc), Linux and macOS
 *      via the socket in the user-data-dir (process_singleton_posix.cc, which is
 *      compiled on macOS too). Here an installed path is a fine fallback.
 *
 * Whether the running browser carries an explicit `--user-data-dir` matters:
 * ProcessSingleton keys on that directory, so passing a path that differs from
 * the running browser's by case or normalisation would start a SECOND browser
 * rather than delegating. The flag is forwarded only when the running process
 * already has one.
 *
 * Every probe is best-effort; when nothing can be determined, profile launching
 * degrades to a clear message rather than guessing.
 *
 * Windows note: `wmic` is deliberately unused — Microsoft removed it by default
 * in Windows 11 24H2 and entirely in 25H2. PowerShell CIM is the supported
 * replacement, with `tasklist` for liveness and the documented App Paths
 * registry key for install location.
 */

import { execFile } from "node:child_process";
import { access, readFile, readlink } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import { browserNameForUserDataDir } from "./paths";

// execFile, not exec: arguments are passed as an array, with no shell involved.
const run = promisify(execFile);
const EXEC_TIMEOUT_MS = 5_000;

export type RunningBrowser = {
  /** True when a Chromium-family browser process is live. */
  readonly running: boolean;
  /** Absolute path to the running browser's binary, when determinable. */
  readonly exePath?: string;
  /** Value of an explicit --user-data-dir on the running process, if any. */
  readonly explicitUserDataDir?: string;
};

/** Chromium-family process names, lowercase, as they appear per platform. */
const LINUX_COMMS: ReadonlyArray<string> = [
  "chrome", "chromium", "chromium-browser", "msedge", "microsoft-edge",
  "google-chrome", "brave", "brave-browser",
];
const MAC_BROWSER_NAMES: ReadonlyArray<string> = [
  "google chrome", "chromium", "microsoft edge", "brave browser",
];
const WIN_IMAGE_NAMES: ReadonlyArray<string> = ["chrome.exe", "msedge.exe", "brave.exe"];

/**
 * Chromium tags every child process with `--type=<role>`; only the browser
 * process omits it or carries `--type=browser`. Matching the flag (rather than
 * words like "renderer" anywhere in the string) keeps a user whose install path
 * contains such a word from being filtered out.
 */
const hasChildProcessType = (commandLine: string): boolean => {
  const lower = commandLine.toLowerCase();
  return lower.includes("--type=") && !lower.includes("--type=browser");
};

/**
 * macOS names its child processes in the executable path itself ("Google Chrome
 * Helper (Renderer)", crashpad, updaters), so the process NAME must be filtered
 * too — the same exclusions the previous setup.ts check applied.
 */
const isMacChildProcessName = (comm: string): boolean => {
  const lower = comm.toLowerCase();
  return (
    lower.includes("helper") ||
    lower.includes("renderer") ||
    lower.includes("crashpad") ||
    lower.includes(" gpu") ||
    lower.includes("updater")
  );
};

/** One running browser process, as much as the platform will tell us. */
export type BrowserCandidate = {
  readonly exePath?: string;
  readonly explicitUserDataDir?: string;
};

/**
 * Linux marks a replaced binary in `/proc/<pid>/exe`: once the file behind a
 * running process is unlinked — which is exactly what a `google-chrome` package
 * upgrade does while the browser keeps running — the symlink reads
 * "/opt/google/chrome/chrome (deleted)". Spawning that string fails with
 * ENOENT, so the marker has to come off.
 *
 * It is stripped only as a fallback, and only when the stripped path exists: a
 * binary genuinely named "… (deleted)" must still win.
 */
const DELETED_SUFFIX = " (deleted)";

/** The path without Linux's deleted-binary marker, or undefined if unmarked. */
export const stripDeletedSuffix = (path: string): string | undefined =>
  path.endsWith(DELETED_SUFFIX) && path.length > DELETED_SUFFIX.length
    ? path.slice(0, -DELETED_SUFFIX.length)
    : undefined;

const exists = async (path: string): Promise<boolean> => {
  try {
    await access(path);
    return true;
  } catch {
    return false;
  }
};

/**
 * A spawnable executable path for a raw readlink/argv value: the value itself
 * when it exists, else the same value with the deleted-binary marker removed.
 * Undefined when neither form is on disk — a path we cannot spawn must never be
 * handed on as if it were usable.
 */
export const spawnableExePath = async (raw: string): Promise<string | undefined> => {
  if (raw.length === 0) return undefined;
  if (await exists(raw)) return raw;
  const stripped = stripDeletedSuffix(raw);
  if (stripped && (await exists(stripped))) return stripped;
  return undefined;
};

/** Path comparison that tolerates separator and case differences per platform. */
const samePath = (a: string, b: string): boolean => {
  const norm = (p: string): string => {
    const unified = p.replace(/\\/g, "/").replace(/\/+$/, "");
    return process.platform === "linux" ? unified : unified.toLowerCase();
  };
  return norm(a) === norm(b);
};

/**
 * Rank a candidate against the user-data-dir the harness is actually connected
 * to. Several Chromium browsers can run at once (Chrome plus Brave, or a second
 * instance on a custom dir), and launching a profile window into the wrong one
 * would open a window the harness cannot see — while disturbing a browser the
 * user never pointed us at.
 */
export const rankBrowserCandidate = (candidate: BrowserCandidate, preferUserDataDir?: string): number => {
  if (!preferUserDataDir) return 1;
  if (candidate.explicitUserDataDir) {
    // An explicit dir is decisive in both directions: exact match wins, and a
    // different one means this is definitively another browser instance.
    return samePath(candidate.explicitUserDataDir, preferUserDataDir) ? 4 : 0;
  }
  // No flag: the process uses its default dir. Prefer the one whose executable
  // belongs to the same browser family as the target directory.
  const family = browserNameForUserDataDir(preferUserDataDir).toLowerCase();
  const exe = (candidate.exePath ?? "").toLowerCase();
  if (!exe) return 1;
  if (family === "brave" && exe.includes("brave")) return 3;
  if (family === "edge" && (exe.includes("edge") || exe.includes("msedge"))) return 3;
  if (family === "chromium" && exe.includes("chromium")) return 3;
  if (family === "chrome" && exe.includes("chrome") && !exe.includes("chromium")) return 3;
  return 1;
};

/** Extract `--user-data-dir=<path>` from a command line, quoted or bare. */
export const parseUserDataDirFlag = (commandLine: string): string | undefined => {
  const match = /--user-data-dir=(?:"([^"]*)"|'([^']*)'|(\S+))/.exec(commandLine);
  if (!match) return undefined;
  const value = match[1] ?? match[2] ?? match[3];
  return value && value.length > 0 ? value : undefined;
};

// ── Linux ──────────────────────────────────────────────────────────────────

const collectLinux = async (): Promise<ReadonlyArray<BrowserCandidate>> => {
  let pids: string[];
  try {
    const { stdout } = await run("ps", ["-A", "-o", "pid=,comm="], { timeout: EXEC_TIMEOUT_MS });
    pids = stdout
      .split("\n")
      .map((line) => line.trim())
      .flatMap((line) => {
        const [pid, comm] = line.split(/\s+/, 2);
        if (!pid || !comm) return [];
        return LINUX_COMMS.includes(comm.toLowerCase()) ? [pid] : [];
      });
  } catch {
    return [];
  }

  const candidates: BrowserCandidate[] = [];
  for (const pid of pids) {
    let cmdline: string;
    try {
      // /proc/<pid>/cmdline is NUL-separated argv.
      cmdline = (await readFile(`/proc/${pid}/cmdline`, "utf8")).replace(/\0/g, " ").trim();
    } catch {
      continue;
    }
    if (!cmdline || hasChildProcessType(cmdline)) continue;
    let link: string | undefined;
    try {
      link = await readlink(`/proc/${pid}/exe`);
    } catch {
      // /proc/<pid>/exe is unreadable for processes we don't own.
    }
    // argv[0] backs up the symlink in both directions: unreadable for a process
    // we don't own, and pointing at an unlinked binary after an in-place
    // upgrade. Chrome's browser process rewrites its argv to the bare
    // executable path, so on Linux argv[0] is the live install location.
    const exePath =
      (link ? await spawnableExePath(link) : undefined) ??
      (await spawnableExePath(cmdline.split(" ")[0] ?? ""));
    const explicit = parseUserDataDirFlag(cmdline);
    candidates.push({
      ...(exePath ? { exePath } : {}),
      ...(explicit ? { explicitUserDataDir: explicit } : {}),
    });
  }
  return candidates;
};

// ── macOS ──────────────────────────────────────────────────────────────────

const collectDarwin = async (): Promise<ReadonlyArray<BrowserCandidate>> => {
  let lines: string[];
  try {
    // BSD ps prints the full executable path for `comm`, unlike Linux.
    const { stdout } = await run("ps", ["-A", "-ww", "-o", "pid=,comm="], { timeout: EXEC_TIMEOUT_MS });
    lines = stdout.split("\n").map((l) => l.trim()).filter(Boolean);
  } catch {
    return [];
  }

  const candidates: BrowserCandidate[] = [];
  for (const line of lines) {
    const spaceAt = line.indexOf(" ");
    if (spaceAt < 0) continue;
    const pid = line.slice(0, spaceAt);
    const comm = line.slice(spaceAt + 1).trim();
    const lower = comm.toLowerCase();
    if (!MAC_BROWSER_NAMES.some((name) => lower.includes(name))) continue;
    if (isMacChildProcessName(comm)) continue;

    let args = "";
    try {
      const { stdout } = await run("ps", ["-ww", "-o", "args=", "-p", pid], { timeout: EXEC_TIMEOUT_MS });
      args = stdout.trim();
    } catch {
      // args are optional — only the --user-data-dir probe needs them
    }
    if (args && hasChildProcessType(args)) continue;
    const explicit = args ? parseUserDataDirFlag(args) : undefined;
    candidates.push({
      exePath: comm,
      ...(explicit ? { explicitUserDataDir: explicit } : {}),
    });
  }
  return candidates;
};

// ── Windows ────────────────────────────────────────────────────────────────

const POWERSHELL_QUERY = [
  "$ErrorActionPreference='SilentlyContinue';",
  "Get-CimInstance Win32_Process",
  "-Filter \"Name='chrome.exe' or Name='msedge.exe' or Name='brave.exe'\"",
  "| Select-Object ExecutablePath,CommandLine",
  "| ConvertTo-Json -Compress",
].join(" ");

type WinProcess = { ExecutablePath?: string | null; CommandLine?: string | null };

const collectWin32 = async (): Promise<ReadonlyArray<BrowserCandidate>> => {
  try {
    const { stdout } = await run(
      "powershell.exe",
      ["-NoProfile", "-NonInteractive", "-Command", POWERSHELL_QUERY],
      { timeout: EXEC_TIMEOUT_MS, windowsHide: true },
    );
    const trimmed = stdout.trim();
    if (trimmed) {
      const parsed: unknown = JSON.parse(trimmed);
      // ConvertTo-Json emits an object for a single row, an array for many.
      const rows: WinProcess[] = Array.isArray(parsed) ? (parsed as WinProcess[]) : [parsed as WinProcess];
      const candidates = rows
        .filter((row) => !hasChildProcessType(row.CommandLine ?? ""))
        .map((row): BrowserCandidate => {
          const explicit = parseUserDataDirFlag(row.CommandLine ?? "");
          return {
            ...(row.ExecutablePath ? { exePath: row.ExecutablePath } : {}),
            ...(explicit ? { explicitUserDataDir: explicit } : {}),
          };
        });
      if (candidates.length > 0) return candidates;
    }
  } catch {
    // PowerShell missing or blocked by policy — fall through to tasklist.
  }

  // Liveness-only fallback: tasklist still ships on every supported Windows.
  // It reveals no path, so the candidate is a bare "something is running".
  try {
    const { stdout } = await run("tasklist.exe", [], { timeout: EXEC_TIMEOUT_MS, windowsHide: true });
    const lower = stdout.toLowerCase();
    if (WIN_IMAGE_NAMES.some((n) => lower.includes(n))) return [{}];
  } catch {
    // best-effort
  }
  return [];
};

/** Installed-browser locations to try when the live process reveals no path. */
const winInstallCandidates = (): ReadonlyArray<string> => {
  const roots = [
    process.env["PROGRAMFILES"],
    process.env["PROGRAMFILES(X86)"],
    process.env["LOCALAPPDATA"],
  ].filter((r): r is string => typeof r === "string" && r.length > 0);
  const suffixes = [
    "Google\\Chrome\\Application\\chrome.exe",
    "Google\\Chrome Beta\\Application\\chrome.exe",
    "Google\\Chrome SxS\\Application\\chrome.exe",
    "Chromium\\Application\\chrome.exe",
    "BraveSoftware\\Brave-Browser\\Application\\brave.exe",
    "Microsoft\\Edge\\Application\\msedge.exe",
  ];
  return roots.flatMap((root) => suffixes.map((suffix) => join(root, suffix)));
};

/** Query the Shell's App Paths registration for a browser's install location. */
const winRegistryExecutable = async (): Promise<string | undefined> => {
  for (const image of WIN_IMAGE_NAMES) {
    for (const hive of ["HKLM", "HKCU"]) {
      try {
        const { stdout } = await run(
          "reg.exe",
          ["query", `${hive}\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\${image}`, "/ve"],
          { timeout: EXEC_TIMEOUT_MS, windowsHide: true },
        );
        const match = /REG_[A-Z_]+\s+(.+)$/m.exec(stdout.trim());
        const path = match?.[1]?.trim();
        if (path) return path;
      } catch {
        // key absent for this browser/hive — try the next
      }
    }
  }
  return undefined;
};

// ── Public API ─────────────────────────────────────────────────────────────

/**
 * Live-process facts about the running Chromium-family browser. `running` is
 * derived only from process evidence, never from an install location.
 *
 * `preferUserDataDir` should be the dir the harness is connected to; with more
 * than one browser running it decides which process is described.
 */
export const detectRunningBrowser = async (preferUserDataDir?: string): Promise<RunningBrowser> => {
  const candidates =
    process.platform === "darwin"
      ? await collectDarwin()
      : process.platform === "win32"
        ? await collectWin32()
        : await collectLinux();
  if (candidates.length === 0) return { running: false };

  const [first, ...rest] = candidates;
  if (!first) return { running: false };
  let best = first;
  let bestRank = rankBrowserCandidate(best, preferUserDataDir);
  for (const candidate of rest) {
    const rank = rankBrowserCandidate(candidate, preferUserDataDir);
    if (rank > bestRank) {
      best = candidate;
      bestRank = rank;
    }
  }

  return {
    running: true,
    ...(best.exePath ? { exePath: best.exePath } : {}),
    ...(best.explicitUserDataDir ? { explicitUserDataDir: best.explicitUserDataDir } : {}),
  };
};

/** True when a Chromium-family browser process is running. */
export const isBrowserRunning = async (): Promise<boolean> => (await detectRunningBrowser()).running;

/**
 * The binary to invoke when opening a window for a chosen profile: the running
 * browser's own executable when known, otherwise a documented install location.
 * Undefined when nothing usable is found.
 */
export const resolveBrowserExecutable = async (running?: RunningBrowser): Promise<string | undefined> => {
  const detected = running ?? (await detectRunningBrowser());
  // Re-check spawnability here too: this function is the single gate every
  // launch passes through, and a caller may hand us a RunningBrowser collected
  // before the binary was replaced on disk.
  const usable = detected.exePath ? await spawnableExePath(detected.exePath) : undefined;
  if (usable) return usable;
  if (process.platform !== "win32") return undefined;

  const fromRegistry = await winRegistryExecutable();
  if (fromRegistry) return fromRegistry;
  for (const candidate of winInstallCandidates()) {
    try {
      await access(candidate);
      return candidate;
    } catch {
      // not installed here
    }
  }
  return undefined;
};