arn-browser 0.1.30 → 0.1.32

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.
@@ -18,7 +18,8 @@ import { FingerprintGenerator } from "fingerprint-generator";
18
18
 
19
19
  // Internal Utilities
20
20
  import { getMultiloginToken } from "../mlx_token.js";
21
- import { deleteDirectoryWithRetries } from "../deleteDirectory.js";
21
+ import { deleteDirectoryWithRetries } from "./deleteDirectory.js";
22
+ import { findBrowserPath } from "./findBrowserPath.js";
22
23
 
23
24
  // Human Cursor - for human-like mouse movements
24
25
  import { createCursor } from "./human-cursor/index.js";
@@ -78,70 +79,8 @@ function resolveProfilePath(nameOrPath, browserName) {
78
79
  return path.join(PERSISTENT_DIR, folderName);
79
80
  }
80
81
 
81
- /**
82
- * Locates binaries for manual browsers (Brave).
83
- * Uses ~/.arn-browser/browsers/{browser}/{executable}
84
- */
85
- function getBinaryPath(browserName) {
86
- const isWindows = process.platform === "win32";
87
- const browsersDir = path.join(ARN_BROWSER_DIR, "browsers");
88
-
89
- const binaryMap = {
90
- brave: isWindows
91
- ? path.join(browsersDir, "brave", "brave.exe")
92
- : path.join(browsersDir, "brave", "brave"),
93
- };
94
-
95
- const binaryPath = binaryMap[browserName];
96
- if (!binaryPath) {
97
- throw new Error(
98
- `░░░░░ [LaunchBrowser] Unknown browser for getBinaryPath: ${browserName}`
99
- );
100
- }
101
-
102
- if (!fs.existsSync(binaryPath)) {
103
- throw new Error(
104
- `░░░░░ [LaunchBrowser] ${browserName} binary not found at: ${binaryPath}\n` +
105
- ` Run "node bin/cli.js install" to install browsers.`
106
- );
107
- }
108
-
109
- return binaryPath;
110
- }
111
-
112
- /**
113
- * Gets the executable path for Playwright-managed browsers (Chromium, Firefox).
114
- * Uses ~/.arn-browser/browsers/{browser}/{executable}
115
- */
116
- function getPlaywrightExePath(browserName) {
117
- const isWindows = process.platform === "win32";
118
- const browsersDir = path.join(ARN_BROWSER_DIR, "browsers");
119
-
120
- const binaryMap = {
121
- chromium: isWindows
122
- ? path.join(browsersDir, "chromium", "chrome.exe")
123
- : path.join(browsersDir, "chromium", "chrome"),
124
- firefox: isWindows
125
- ? path.join(browsersDir, "firefox", "firefox.exe")
126
- : path.join(browsersDir, "firefox", "firefox"),
127
- };
128
-
129
- const binaryPath = binaryMap[browserName];
130
- if (!binaryPath) {
131
- throw new Error(
132
- `░░░░░ [LaunchBrowser] Unknown browser: ${browserName}`
133
- );
134
- }
135
-
136
- if (!fs.existsSync(binaryPath)) {
137
- throw new Error(
138
- `░░░░░ [LaunchBrowser] ${browserName} binary not found at: ${binaryPath}\n` +
139
- ` Run "node bin/cli.js install" to install browsers.`
140
- );
141
- }
142
-
143
- return binaryPath;
144
- }
82
+ // Browser path resolution now uses OS-installed browsers via findBrowserPath.
83
+ // See src/utility/findBrowserPath.js for detection logic.
145
84
 
146
85
  const PROFILE_META_FILE = "_profile_meta.json";
147
86
 
@@ -385,6 +324,7 @@ export async function pwLaunch({
385
324
  humanize_options: effectiveHumanizeOptions,
386
325
  spoof_fingerprint,
387
326
  cleanupMinutes: effectiveCleanupMinutes,
327
+ browserType: which_browser, // "chrome" or "chromium"
388
328
  });
389
329
  break;
390
330
  case "firefox":
@@ -442,7 +382,7 @@ export async function pwLaunch({
442
382
  // ==========================================================================
443
383
  // 4. ENGINE: CHROMIUM
444
384
  // ==========================================================================
445
- async function chromiumLauncher({ profilePath, proxy, timezoneId, CapSolver, humanize_options, spoof_fingerprint, cleanupMinutes }) {
385
+ async function chromiumLauncher({ profilePath, proxy, timezoneId, CapSolver, humanize_options, spoof_fingerprint, cleanupMinutes, browserType = "chromium" }) {
446
386
  const isPersistent = !!profilePath;
447
387
 
448
388
  // 1. Determine Path (Temp needs it for fingerprint storage, Persistent needs it for data)
@@ -504,7 +444,7 @@ async function chromiumLauncher({ profilePath, proxy, timezoneId, CapSolver, hum
504
444
  // Launch standard browser (not persistent context)
505
445
  const browser = await chromium.launch({
506
446
  headless: false,
507
- executablePath: getPlaywrightExePath("chromium"),
447
+ executablePath: findBrowserPath(browserType),
508
448
  proxy: proxyObj,
509
449
  args: args,
510
450
  ignoreDefaultArgs: ignoreDefaultArgs,
@@ -571,7 +511,7 @@ async function chromiumLauncher({ profilePath, proxy, timezoneId, CapSolver, hum
571
511
  // Logic: Native Persistent Launch. No fingerprint-injector.
572
512
  const context = await chromium.launchPersistentContext(activePath, {
573
513
  headless: false,
574
- executablePath: getPlaywrightExePath("chromium"),
514
+ executablePath: findBrowserPath(browserType),
575
515
  proxy: proxyObj,
576
516
  args: args,
577
517
  timezoneId: tz,
@@ -625,7 +565,7 @@ async function firefoxLauncher({ profilePath, proxy, timezoneId, humanize_option
625
565
 
626
566
  const browser = await firefox.launch({
627
567
  headless: false,
628
- executablePath: getPlaywrightExePath("firefox"),
568
+ executablePath: findBrowserPath("firefox"),
629
569
  proxy: proxyObj,
630
570
  ignoreDefaultArgs: ["--enable-automation"],
631
571
  firefoxUserPrefs: firefoxUserPrefs,
@@ -667,7 +607,7 @@ async function firefoxLauncher({ profilePath, proxy, timezoneId, humanize_option
667
607
 
668
608
  const context = await firefox.launchPersistentContext(activePath, {
669
609
  headless: false,
670
- executablePath: getPlaywrightExePath("firefox"),
610
+ executablePath: findBrowserPath("firefox"),
671
611
  proxy: proxyObj,
672
612
  timezoneId: tz,
673
613
  ignoreDefaultArgs: ["--enable-automation"],
@@ -744,7 +684,7 @@ async function braveLauncher({ profilePath, proxy, CapSolver, timezoneId, humani
744
684
  }
745
685
  }
746
686
 
747
- const braveBin = getBinaryPath("brave");
687
+ const braveBin = findBrowserPath("brave");
748
688
 
749
689
  // ======================================================
750
690
  // Disable Brave Sidebar via Preferences
@@ -208,14 +208,7 @@ export async function startProxyServer({
208
208
  proxy2: createHostMatcher([...PROXY_2_HOSTS, "proxy.multilogin.com", "multilogin.com"]),
209
209
  };
210
210
 
211
- // 2. Port
212
- const selectedPort = await findAvailablePort(50001, 50010);
213
- if (!selectedPort) {
214
- console.error("░░ Critical Error: No available ports.");
215
- return null;
216
- }
217
-
218
- // 3. Build URLs
211
+ // 2. Build URLs
219
212
  const buildURL = (data) => {
220
213
  if (!data) return null; // Returns null if no config
221
214
  const { type = "http", host, port, user, pass } = data;
@@ -230,7 +223,7 @@ export async function startProxyServer({
230
223
  p2: buildURL(PROXY_2_DATA),
231
224
  };
232
225
 
233
- // 4. Fetch Details (Simplified Logic)
226
+ // 3. Fetch Details (Simplified Logic)
234
227
  // We pass the URL (or null). The function handles the "Local" logic.
235
228
  const [defaultDetails, p1Details, p2Details] = await Promise.all([
236
229
  fetchProxyDetails(upstreamProxies.default, ip2LocationKey, retryDelayMs),
@@ -243,7 +236,7 @@ export async function startProxyServer({
243
236
  if (upstreamProxies.p1 && !p1Details) { console.warn("░░ Warning: PROXY_1 configured but unreachable."); return null; }
244
237
  if (upstreamProxies.p2 && !p2Details) { console.warn("░░ Warning: PROXY_2 configured but unreachable."); return null; }
245
238
 
246
- // 5. Stats
239
+ // 4. Stats
247
240
  const stats = {
248
241
  DEFAULT_PROXY: { request: 0, srcTx: 0, srcRx: 0, trgTx: 0, trgRx: 0 },
249
242
  NO_PROXY: { request: 0, srcTx: 0, srcRx: 0, trgTx: 0, trgRx: 0 },
@@ -261,72 +254,105 @@ export async function startProxyServer({
261
254
  const connectionMap = {}; // Maps connectionId -> { type: "..." }
262
255
  let serverRunning = false;
263
256
 
264
- // 6. Server
265
- const server = new ProxyChain.Server({
266
- port: selectedPort,
267
- host: "127.0.0.1",
268
- verbose: debug,
269
- prepareRequestFunction: ({ hostname, connectionId }) => {
270
- let proxyType = "DEFAULT_PROXY";
271
- let upstreamUrl = upstreamProxies.default;
272
- let isCustomResponse = false;
273
- let customResponseData = null;
274
-
275
- // Logic to determine Proxy Type
276
- if (matchers.noProxy(hostname)) {
277
- // A. Direct
278
- proxyType = "NO_PROXY";
279
- upstreamUrl = null;
280
- } else if (matchers.proxy1(hostname) && upstreamProxies.p1) {
281
- // C1. Proxy 1
282
- proxyType = "PROXY_1";
283
- upstreamUrl = upstreamProxies.p1;
284
- } else if (matchers.proxy2(hostname) && upstreamProxies.p2) {
285
- // C2. Proxy 2
286
- proxyType = "PROXY_2";
287
- upstreamUrl = upstreamProxies.p2;
288
- }
257
+ // 5. Create server and bind with retry (handles race conditions between concurrent processes)
258
+ const PORT_RANGE_START = 50001;
259
+ const PORT_RANGE_END = 50200;
260
+ const MAX_BIND_RETRIES = 10;
289
261
 
290
- // B. IP Check Interception (Overrules standard routing for specific domain)
291
- if (hostname === "ip.bablosoft.com") {
292
- isCustomResponse = true;
293
- // Inherit the proxyType determined above to fetch the correct IP details
294
- // (e.g. if it matched PROXY_1 matchers, we show PROXY_1 IP)
295
- let displayedIP;
296
- if (proxyType === "PROXY_1") displayedIP = p1Details?.ip;
297
- else if (proxyType === "PROXY_2") displayedIP = p2Details?.ip;
298
- else if (proxyType === "NO_PROXY") displayedIP = "127.0.0.1";
299
- else displayedIP = defaultDetails?.ip;
300
-
301
- customResponseData = {
302
- statusCode: 200,
303
- headers: { "Content-Type": "text/plain", Connection: "close" },
304
- body: displayedIP || "Unknown IP",
305
- };
306
- }
262
+ let selectedPort = null;
263
+ let server = null;
264
+
265
+ for (let attempt = 1; attempt <= MAX_BIND_RETRIES; attempt++) {
266
+ const candidatePort = await findAvailablePort(PORT_RANGE_START, PORT_RANGE_END);
267
+ if (!candidatePort) {
268
+ console.error("░░ Critical Error: No available ports in range " + PORT_RANGE_START + "-" + PORT_RANGE_END);
269
+ return null;
270
+ }
307
271
 
308
- // Record Stats
309
- connectionMap[connectionId] = { type: proxyType, hostname: hostname };
310
- if (host_stats && hostname) {
311
- // Ensure the type exists in map (it should, but safety first)
312
- if (!hostStatsMap[proxyType]) hostStatsMap[proxyType] = {};
313
- if (!hostStatsMap[proxyType][hostname]) {
314
- hostStatsMap[proxyType][hostname] = { req: 0, srcTx: 0, srcRx: 0, trgTx: 0, trgRx: 0 };
272
+ server = new ProxyChain.Server({
273
+ port: candidatePort,
274
+ host: "127.0.0.1",
275
+ verbose: debug,
276
+ prepareRequestFunction: ({ hostname, connectionId }) => {
277
+ let proxyType = "DEFAULT_PROXY";
278
+ let upstreamUrl = upstreamProxies.default;
279
+ let isCustomResponse = false;
280
+ let customResponseData = null;
281
+
282
+ // Logic to determine Proxy Type
283
+ if (matchers.noProxy(hostname)) {
284
+ // A. Direct
285
+ proxyType = "NO_PROXY";
286
+ upstreamUrl = null;
287
+ } else if (matchers.proxy1(hostname) && upstreamProxies.p1) {
288
+ // C1. Proxy 1
289
+ proxyType = "PROXY_1";
290
+ upstreamUrl = upstreamProxies.p1;
291
+ } else if (matchers.proxy2(hostname) && upstreamProxies.p2) {
292
+ // C2. Proxy 2
293
+ proxyType = "PROXY_2";
294
+ upstreamUrl = upstreamProxies.p2;
295
+ }
296
+
297
+ // B. IP Check Interception (Overrules standard routing for specific domain)
298
+ if (hostname === "ip.bablosoft.com") {
299
+ isCustomResponse = true;
300
+ let displayedIP;
301
+ if (proxyType === "PROXY_1") displayedIP = p1Details?.ip;
302
+ else if (proxyType === "PROXY_2") displayedIP = p2Details?.ip;
303
+ else if (proxyType === "NO_PROXY") displayedIP = "127.0.0.1";
304
+ else displayedIP = defaultDetails?.ip;
305
+
306
+ customResponseData = {
307
+ statusCode: 200,
308
+ headers: { "Content-Type": "text/plain", Connection: "close" },
309
+ body: displayedIP || "Unknown IP",
310
+ };
315
311
  }
316
- hostStatsMap[proxyType][hostname].req++;
317
- }
318
312
 
319
- // Return Decision
320
- if (isCustomResponse) {
321
- return { customResponseFunction: () => customResponseData };
313
+ // Record Stats
314
+ connectionMap[connectionId] = { type: proxyType, hostname: hostname };
315
+ if (host_stats && hostname) {
316
+ if (!hostStatsMap[proxyType]) hostStatsMap[proxyType] = {};
317
+ if (!hostStatsMap[proxyType][hostname]) {
318
+ hostStatsMap[proxyType][hostname] = { req: 0, srcTx: 0, srcRx: 0, trgTx: 0, trgRx: 0 };
319
+ }
320
+ hostStatsMap[proxyType][hostname].req++;
321
+ }
322
+
323
+ // Return Decision
324
+ if (isCustomResponse) {
325
+ return { customResponseFunction: () => customResponseData };
326
+ }
327
+
328
+ return {
329
+ upstreamProxyUrl: upstreamUrl,
330
+ requestAuthentication: false,
331
+ };
332
+ },
333
+ });
334
+
335
+ try {
336
+ await server.listen();
337
+ selectedPort = candidatePort;
338
+ serverRunning = true;
339
+ console.log(`░░ Local Proxy Started: http://127.0.0.1:${selectedPort}`);
340
+ break; // Successfully bound — exit retry loop
341
+ } catch (err) {
342
+ if (err.code === "EADDRINUSE" && attempt < MAX_BIND_RETRIES) {
343
+ console.warn(`░░ Port ${candidatePort} taken (race), retrying... (${attempt}/${MAX_BIND_RETRIES})`);
344
+ await sleep(50 + Math.random() * 100); // Small random delay to de-sync concurrent processes
345
+ continue;
322
346
  }
347
+ console.error("░░ Failed to start proxy server:", err);
348
+ return null;
349
+ }
350
+ }
323
351
 
324
- return {
325
- upstreamProxyUrl: upstreamUrl,
326
- requestAuthentication: false, // Auto-handle upstream auth via URL
327
- };
328
- },
329
- });
352
+ if (!selectedPort || !server) {
353
+ console.error("░░ Critical Error: Could not bind to any port after " + MAX_BIND_RETRIES + " attempts.");
354
+ return null;
355
+ }
330
356
 
331
357
  server.on("connectionClosed", ({ connectionId, stats: connStats }) => {
332
358
  const connectionInfo = connectionMap[connectionId];
@@ -355,15 +381,6 @@ export async function startProxyServer({
355
381
  delete connectionMap[connectionId];
356
382
  });
357
383
 
358
- try {
359
- await server.listen();
360
- serverRunning = true;
361
- console.log(`░░ Local Proxy Started: http://127.0.0.1:${selectedPort}`);
362
- } catch (err) {
363
- console.error("░░ Failed to start proxy server:", err);
364
- return null;
365
- }
366
-
367
384
  const formatBytes = (bytes) => {
368
385
  if (bytes < 1024) return bytes + " B";
369
386
  if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + " KB";
@@ -0,0 +1,104 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { setTimeout as sleep } from "timers/promises";
4
+
5
+ /**
6
+ * Deletes a directory with built-in retry logic.
7
+ * Useful for browser profiles where files might still be locked by the OS process.
8
+ * * @param {string} targetPath - The absolute path to delete.
9
+ * @param {number} maxRetries - How many times to retry (default: 5).
10
+ * @param {number} retryDelayMs - Time to wait between retries (default: 2000ms).
11
+ */
12
+ export async function deleteDirectoryWithRetries(targetPath, maxRetries = 5, retryDelayMs = 2000) {
13
+ if (!fs.existsSync(targetPath)) {
14
+ return true; // Already gone
15
+ }
16
+
17
+ let attempt = 0;
18
+
19
+ while (attempt < maxRetries) {
20
+ try {
21
+ // Force: true allows deleting files even if read-only
22
+ // Recursive: true deletes inner folders
23
+ await fs.promises.rm(targetPath, { recursive: true, force: true });
24
+
25
+ // Double check if it's actually gone
26
+ if (!fs.existsSync(targetPath)) {
27
+ return true;
28
+ }
29
+ } catch (error) {
30
+ const isLastAttempt = attempt === maxRetries - 1;
31
+
32
+ // If it's the last attempt, log error
33
+ if (isLastAttempt) {
34
+ console.error(`░░░░░ Failed to delete directory after ${maxRetries} attempts: ${targetPath}`);
35
+ console.error(` Error: ${error.message}`);
36
+ return false;
37
+ }
38
+
39
+ // Log warning and wait
40
+ console.warn(
41
+ `░░░░░ Delete failed (Attempt ${attempt + 1}/${maxRetries}). File might be locked. Retrying in ${retryDelayMs / 1000
42
+ }s...`
43
+ );
44
+ await sleep(retryDelayMs);
45
+ }
46
+ attempt++;
47
+ }
48
+
49
+ return false;
50
+ }
51
+
52
+ /**
53
+ * Deletes specific contents inside a directory but keeps the parent folder.
54
+ * (Optional utility if you need to clear cache without removing the profile folder itself)
55
+ */
56
+ export async function deleteAllItemsInDirectory(directoryPath) {
57
+ try {
58
+ if (!fs.existsSync(directoryPath)) return;
59
+
60
+ const files = await fs.promises.readdir(directoryPath);
61
+ for (const file of files) {
62
+ const curPath = path.join(directoryPath, file);
63
+ await deleteDirectoryWithRetries(curPath);
64
+ }
65
+ } catch (error) {
66
+ console.error(`Error emptying directory ${directoryPath}:`, error);
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Legacy utility: Scan a directory and delete folders older than X minutes.
72
+ * (Note: launchBrowser.js now has its own internal version of this, but keeping this
73
+ * here is useful if you want to run cleanup scripts independently).
74
+ */
75
+ export function deleteOldDirectories({ directoryPath, ageLimitMinutes }) {
76
+ if (!fs.existsSync(directoryPath)) return;
77
+
78
+ const now = Date.now();
79
+ const limit = ageLimitMinutes * 60 * 1000;
80
+
81
+ try {
82
+ const files = fs.readdirSync(directoryPath);
83
+
84
+ files.forEach((file) => {
85
+ const curPath = path.join(directoryPath, file);
86
+ try {
87
+ const stats = fs.statSync(curPath);
88
+ if (stats.isDirectory()) {
89
+ const age = now - stats.mtimeMs;
90
+ if (age > limit) {
91
+ console.log(`Cleaning up old directory: ${file}`);
92
+ // We use the sync version of retry logic or just fire-and-forget async here
93
+ // For simplicity in a sync loop, we often just do a force removal:
94
+ fs.rmSync(curPath, { recursive: true, force: true });
95
+ }
96
+ }
97
+ } catch (e) {
98
+ // Ignore access errors on specific files
99
+ }
100
+ });
101
+ } catch (err) {
102
+ console.error("Error during old directory cleanup:", err);
103
+ }
104
+ }
@@ -0,0 +1,211 @@
1
+ /**
2
+ * @file findBrowserPath.js
3
+ * @description Cross-platform utility to find OS-installed browser executables.
4
+ * Supports: chrome, chromium, firefox, brave
5
+ * Works on: Linux (x64/arm64), macOS (arm64), Windows (x64)
6
+ */
7
+
8
+ import { execSync } from "child_process";
9
+ import fs from "fs";
10
+ import path from "path";
11
+
12
+ // ==========================================================================
13
+ // KNOWN BINARY PATHS PER PLATFORM
14
+ // ==========================================================================
15
+
16
+ const BROWSER_PATHS = {
17
+ chrome: {
18
+ linux: [
19
+ "google-chrome-stable",
20
+ "google-chrome",
21
+ "/opt/google/chrome/google-chrome",
22
+ "/usr/bin/google-chrome-stable",
23
+ "/usr/bin/google-chrome",
24
+ ],
25
+ darwin: [
26
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
27
+ ],
28
+ win32: [
29
+ path.join(process.env.PROGRAMFILES || "C:\\Program Files", "Google", "Chrome", "Application", "chrome.exe"),
30
+ path.join(process.env["PROGRAMFILES(X86)"] || "C:\\Program Files (x86)", "Google", "Chrome", "Application", "chrome.exe"),
31
+ path.join(process.env.LOCALAPPDATA || "", "Google", "Chrome", "Application", "chrome.exe"),
32
+ ],
33
+ },
34
+ chromium: {
35
+ linux: [
36
+ "chromium-browser",
37
+ "chromium",
38
+ "/usr/bin/chromium-browser",
39
+ "/usr/bin/chromium",
40
+ "/snap/bin/chromium",
41
+ ],
42
+ darwin: [
43
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
44
+ ],
45
+ win32: [
46
+ path.join(process.env.PROGRAMFILES || "C:\\Program Files", "Chromium", "Application", "chrome.exe"),
47
+ path.join(process.env["PROGRAMFILES(X86)"] || "C:\\Program Files (x86)", "Chromium", "Application", "chrome.exe"),
48
+ path.join(process.env.LOCALAPPDATA || "", "Chromium", "Application", "chrome.exe"),
49
+ ],
50
+ },
51
+ firefox: {
52
+ linux: [
53
+ "firefox",
54
+ "/usr/bin/firefox",
55
+ "/snap/bin/firefox",
56
+ "/usr/lib/firefox/firefox",
57
+ ],
58
+ darwin: [
59
+ "/Applications/Firefox.app/Contents/MacOS/firefox",
60
+ ],
61
+ win32: [
62
+ path.join(process.env.PROGRAMFILES || "C:\\Program Files", "Mozilla Firefox", "firefox.exe"),
63
+ path.join(process.env["PROGRAMFILES(X86)"] || "C:\\Program Files (x86)", "Mozilla Firefox", "firefox.exe"),
64
+ ],
65
+ },
66
+ brave: {
67
+ linux: [
68
+ "brave-browser",
69
+ "brave-browser-stable",
70
+ "/opt/brave.com/brave/brave-browser",
71
+ "/usr/bin/brave-browser",
72
+ "/usr/bin/brave-browser-stable",
73
+ "/snap/bin/brave",
74
+ ],
75
+ darwin: [
76
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
77
+ ],
78
+ win32: [
79
+ path.join(process.env.PROGRAMFILES || "C:\\Program Files", "BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
80
+ path.join(process.env["PROGRAMFILES(X86)"] || "C:\\Program Files (x86)", "BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
81
+ path.join(process.env.LOCALAPPDATA || "", "BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
82
+ ],
83
+ },
84
+ };
85
+
86
+ // Friendly display names for error messages
87
+ const BROWSER_DISPLAY_NAMES = {
88
+ chrome: "Google Chrome",
89
+ chromium: "Chromium",
90
+ firefox: "Mozilla Firefox",
91
+ brave: "Brave Browser",
92
+ };
93
+
94
+ // Install hints per platform
95
+ const INSTALL_HINTS = {
96
+ chrome: {
97
+ linux: "sudo apt install google-chrome-stable OR download from https://www.google.com/chrome/",
98
+ darwin: "Download from https://www.google.com/chrome/",
99
+ win32: "Download from https://www.google.com/chrome/",
100
+ },
101
+ chromium: {
102
+ linux: "sudo apt install chromium-browser OR sudo snap install chromium",
103
+ darwin: "brew install --cask chromium OR download from https://www.chromium.org/",
104
+ win32: "Download from https://www.chromium.org/getting-involved/download-chromium/",
105
+ },
106
+ firefox: {
107
+ linux: "sudo apt install firefox OR sudo snap install firefox",
108
+ darwin: "brew install --cask firefox OR download from https://www.mozilla.org/firefox/",
109
+ win32: "Download from https://www.mozilla.org/firefox/",
110
+ },
111
+ brave: {
112
+ linux: "See https://brave.com/linux/ OR sudo apt install brave-browser",
113
+ darwin: "brew install --cask brave-browser OR download from https://brave.com/",
114
+ win32: "Download from https://brave.com/",
115
+ },
116
+ };
117
+
118
+ // ==========================================================================
119
+ // DETECTION LOGIC
120
+ // ==========================================================================
121
+
122
+ /**
123
+ * Try to resolve a command name using `which` (Unix) or `where` (Windows).
124
+ * Returns the absolute path if found, null otherwise.
125
+ */
126
+ function resolveFromPath(command) {
127
+ const isWindows = process.platform === "win32";
128
+ try {
129
+ const cmd = isWindows ? `where "${command}"` : `which "${command}"`;
130
+ const result = execSync(cmd, { stdio: ["pipe", "pipe", "pipe"], encoding: "utf-8" }).trim();
131
+ // `where` on Windows may return multiple lines — take the first
132
+ const firstLine = result.split("\n")[0].trim();
133
+ if (firstLine && fs.existsSync(firstLine)) {
134
+ return firstLine;
135
+ }
136
+ } catch {
137
+ // Command not found in PATH
138
+ }
139
+ return null;
140
+ }
141
+
142
+ /**
143
+ * Find the OS-installed executable path for a given browser.
144
+ *
145
+ * @param {string} browserName - One of: "chrome", "chromium", "firefox", "brave"
146
+ * @returns {string} Absolute path to the browser executable
147
+ * @throws {Error} If the browser is not found on the system
148
+ */
149
+ export function findBrowserPath(browserName) {
150
+ const name = browserName.toLowerCase();
151
+ const candidates = BROWSER_PATHS[name];
152
+
153
+ if (!candidates) {
154
+ throw new Error(
155
+ `❌ [findBrowserPath] Unknown browser: "${browserName}"\n` +
156
+ ` Supported: chrome, chromium, firefox, brave`
157
+ );
158
+ }
159
+
160
+ const platform = process.platform; // 'linux', 'darwin', 'win32'
161
+ const paths = candidates[platform];
162
+
163
+ if (!paths || paths.length === 0) {
164
+ throw new Error(
165
+ `❌ [findBrowserPath] ${BROWSER_DISPLAY_NAMES[name]} is not supported on platform: ${platform}`
166
+ );
167
+ }
168
+
169
+ // Strategy 1: Try PATH lookup first (works for short names like "google-chrome", "firefox")
170
+ for (const candidate of paths) {
171
+ // If it's not an absolute path, try resolving from PATH
172
+ if (!path.isAbsolute(candidate)) {
173
+ const resolved = resolveFromPath(candidate);
174
+ if (resolved) return resolved;
175
+ }
176
+ }
177
+
178
+ // Strategy 2: Check known absolute paths
179
+ for (const candidate of paths) {
180
+ if (path.isAbsolute(candidate) && fs.existsSync(candidate)) {
181
+ return candidate;
182
+ }
183
+ }
184
+
185
+ // Not found — throw descriptive error
186
+ const displayName = BROWSER_DISPLAY_NAMES[name];
187
+ const hint = INSTALL_HINTS[name]?.[platform] || `Install ${displayName} from its official website.`;
188
+
189
+ throw new Error(
190
+ `❌ [findBrowserPath] ${displayName} not found on this system.\n` +
191
+ ` Platform: ${platform}\n` +
192
+ ` Searched: ${paths.filter(p => path.isAbsolute(p)).join(", ") || "(PATH lookup only)"}\n\n` +
193
+ ` To install:\n` +
194
+ ` ${hint}`
195
+ );
196
+ }
197
+
198
+ /**
199
+ * Check if a browser is available without throwing.
200
+ *
201
+ * @param {string} browserName - One of: "chrome", "chromium", "firefox", "brave"
202
+ * @returns {{ found: boolean, path: string|null, error: string|null }}
203
+ */
204
+ export function checkBrowserAvailability(browserName) {
205
+ try {
206
+ const p = findBrowserPath(browserName);
207
+ return { found: true, path: p, error: null };
208
+ } catch (err) {
209
+ return { found: false, path: null, error: err.message };
210
+ }
211
+ }