arn-browser 0.1.31 → 0.1.33

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";
@@ -70,7 +71,8 @@ function resolveProfilePath(nameOrPath, browserName) {
70
71
 
71
72
  let prefix = browserName.toLowerCase();
72
73
  if (prefix.includes("brave")) prefix = "brave";
73
- else if (prefix.includes("chrome") || prefix.includes("chromium")) prefix = "chromium";
74
+ else if (prefix.includes("chromium")) prefix = "chromium";
75
+ else if (prefix.includes("chrome")) prefix = "chrome";
74
76
  else if (prefix.includes("firefox")) prefix = "firefox";
75
77
  else if (prefix.includes("camoufox")) prefix = "camoufox";
76
78
 
@@ -78,70 +80,8 @@ function resolveProfilePath(nameOrPath, browserName) {
78
80
  return path.join(PERSISTENT_DIR, folderName);
79
81
  }
80
82
 
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
- }
83
+ // Browser path resolution now uses OS-installed browsers via findBrowserPath.
84
+ // See src/utility/findBrowserPath.js for detection logic.
145
85
 
146
86
  const PROFILE_META_FILE = "_profile_meta.json";
147
87
 
@@ -175,6 +115,31 @@ function writeProfileMeta(dirPath, type, cleanupMinutes) {
175
115
  }
176
116
  }
177
117
 
118
+ /**
119
+ * Writes per-host notification permissions into the Chrome Preferences object.
120
+ * Accepts plain hostnames (e.g. "web.whatsapp.com") and converts them
121
+ * to Chrome's internal format: "https://host:443,*" with setting=1 (Allow).
122
+ * If hosts array is empty, does nothing (Chrome default "Ask" behavior).
123
+ */
124
+ function writeNotificationPermissions(prefs, hosts) {
125
+ if (!hosts || hosts.length === 0) return;
126
+
127
+ if (!prefs.profile) prefs.profile = {};
128
+ if (!prefs.profile.content_settings) prefs.profile.content_settings = {};
129
+ if (!prefs.profile.content_settings.exceptions) prefs.profile.content_settings.exceptions = {};
130
+ if (!prefs.profile.content_settings.exceptions.notifications) prefs.profile.content_settings.exceptions.notifications = {};
131
+
132
+ const now = String(Date.now());
133
+ for (const host of hosts) {
134
+ const origin = host.startsWith("http") ? host : `https://${host}:443`;
135
+ const key = `${origin},*`;
136
+ prefs.profile.content_settings.exceptions.notifications[key] = {
137
+ last_modified: now,
138
+ setting: 1, // 1 = Allow
139
+ };
140
+ }
141
+ }
142
+
178
143
  /**
179
144
  * Scans both persistent and temp profile directories.
180
145
  * Deletes profiles whose `_profile_meta.json` indicates they are expired.
@@ -339,6 +304,10 @@ export async function pwLaunch({
339
304
  camoufox_options = {}, // { geoip, humanize, ... }
340
305
  multilogin_options = {}, // { profileId, os_type, canvas_noise, ... }
341
306
 
307
+ // Notifications — per-host allow list
308
+ // Pass plain hostnames: ["web.whatsapp.com", "messages.google.com"]
309
+ notification_hosts = [],
310
+
342
311
  // Logging
343
312
  launch_logs = false,
344
313
  cleanup_logs = false,
@@ -385,6 +354,8 @@ export async function pwLaunch({
385
354
  humanize_options: effectiveHumanizeOptions,
386
355
  spoof_fingerprint,
387
356
  cleanupMinutes: effectiveCleanupMinutes,
357
+ browserType: which_browser, // "chrome" or "chromium"
358
+ notification_hosts,
388
359
  });
389
360
  break;
390
361
  case "firefox":
@@ -407,6 +378,7 @@ export async function pwLaunch({
407
378
  humanize_options: effectiveHumanizeOptions,
408
379
  spoof_fingerprint,
409
380
  cleanupMinutes: effectiveCleanupMinutes,
381
+ notification_hosts,
410
382
  });
411
383
  break;
412
384
  case "camoufox":
@@ -442,7 +414,7 @@ export async function pwLaunch({
442
414
  // ==========================================================================
443
415
  // 4. ENGINE: CHROMIUM
444
416
  // ==========================================================================
445
- async function chromiumLauncher({ profilePath, proxy, timezoneId, CapSolver, humanize_options, spoof_fingerprint, cleanupMinutes }) {
417
+ async function chromiumLauncher({ profilePath, proxy, timezoneId, CapSolver, humanize_options, spoof_fingerprint, cleanupMinutes, browserType = "chromium", notification_hosts = [] }) {
446
418
  const isPersistent = !!profilePath;
447
419
 
448
420
  // 1. Determine Path (Temp needs it for fingerprint storage, Persistent needs it for data)
@@ -471,6 +443,8 @@ async function chromiumLauncher({ profilePath, proxy, timezoneId, CapSolver, hum
471
443
  // --- Silence & Networking ---
472
444
  "--disable-background-networking",
473
445
  "--disable-background-timer-throttling",
446
+ "--disable-backgrounding-occluded-windows",
447
+ "--disable-renderer-backgrounding",
474
448
  "--disable-breakpad",
475
449
  "--disable-crash-reporter",
476
450
  "--disable-component-update",
@@ -504,7 +478,7 @@ async function chromiumLauncher({ profilePath, proxy, timezoneId, CapSolver, hum
504
478
  // Launch standard browser (not persistent context)
505
479
  const browser = await chromium.launch({
506
480
  headless: false,
507
- executablePath: getPlaywrightExePath("chromium"),
481
+ executablePath: findBrowserPath(browserType),
508
482
  proxy: proxyObj,
509
483
  args: args,
510
484
  ignoreDefaultArgs: ignoreDefaultArgs,
@@ -558,6 +532,7 @@ async function chromiumLauncher({ profilePath, proxy, timezoneId, CapSolver, hum
558
532
 
559
533
  if (!prefs.profile) prefs.profile = {};
560
534
  prefs.profile.exit_type = "Normal";
535
+ writeNotificationPermissions(prefs, notification_hosts);
561
536
 
562
537
  if (!prefs.session) prefs.session = {};
563
538
  prefs.session.restore_on_startup = 4;
@@ -571,7 +546,7 @@ async function chromiumLauncher({ profilePath, proxy, timezoneId, CapSolver, hum
571
546
  // Logic: Native Persistent Launch. No fingerprint-injector.
572
547
  const context = await chromium.launchPersistentContext(activePath, {
573
548
  headless: false,
574
- executablePath: getPlaywrightExePath("chromium"),
549
+ executablePath: findBrowserPath(browserType),
575
550
  proxy: proxyObj,
576
551
  args: args,
577
552
  timezoneId: tz,
@@ -625,7 +600,7 @@ async function firefoxLauncher({ profilePath, proxy, timezoneId, humanize_option
625
600
 
626
601
  const browser = await firefox.launch({
627
602
  headless: false,
628
- executablePath: getPlaywrightExePath("firefox"),
603
+ executablePath: findBrowserPath("firefox"),
629
604
  proxy: proxyObj,
630
605
  ignoreDefaultArgs: ["--enable-automation"],
631
606
  firefoxUserPrefs: firefoxUserPrefs,
@@ -667,7 +642,7 @@ async function firefoxLauncher({ profilePath, proxy, timezoneId, humanize_option
667
642
 
668
643
  const context = await firefox.launchPersistentContext(activePath, {
669
644
  headless: false,
670
- executablePath: getPlaywrightExePath("firefox"),
645
+ executablePath: findBrowserPath("firefox"),
671
646
  proxy: proxyObj,
672
647
  timezoneId: tz,
673
648
  ignoreDefaultArgs: ["--enable-automation"],
@@ -691,7 +666,7 @@ async function firefoxLauncher({ profilePath, proxy, timezoneId, humanize_option
691
666
  // ==========================================================================
692
667
  // 6. ENGINE: BRAVE
693
668
  // ==========================================================================
694
- async function braveLauncher({ profilePath, proxy, CapSolver, timezoneId, humanize_options, spoof_fingerprint, cleanupMinutes }) {
669
+ async function braveLauncher({ profilePath, proxy, CapSolver, timezoneId, humanize_options, spoof_fingerprint, cleanupMinutes, notification_hosts = [] }) {
695
670
  const isPersistent = !!profilePath;
696
671
  const activePath = isPersistent ? profilePath : path.join(TEMP_DIR, crypto.randomUUID());
697
672
 
@@ -744,7 +719,7 @@ async function braveLauncher({ profilePath, proxy, CapSolver, timezoneId, humani
744
719
  }
745
720
  }
746
721
 
747
- const braveBin = getBinaryPath("brave");
722
+ const braveBin = findBrowserPath("brave");
748
723
 
749
724
  // ======================================================
750
725
  // Disable Brave Sidebar via Preferences
@@ -766,6 +741,7 @@ async function braveLauncher({ profilePath, proxy, CapSolver, timezoneId, humani
766
741
  // Prevent tab restore (saves proxy bandwidth)
767
742
  if (!prefs.profile) prefs.profile = {};
768
743
  prefs.profile.exit_type = "Normal";
744
+ writeNotificationPermissions(prefs, notification_hosts);
769
745
 
770
746
  if (!prefs.session) prefs.session = {};
771
747
  prefs.session.restore_on_startup = 4;
@@ -819,6 +795,8 @@ async function braveLauncher({ profilePath, proxy, CapSolver, timezoneId, humani
819
795
  // --- Silence & Networking ---
820
796
  "--disable-background-networking",
821
797
  "--disable-background-timer-throttling",
798
+ "--disable-backgrounding-occluded-windows",
799
+ "--disable-renderer-backgrounding",
822
800
  "--disable-breakpad",
823
801
  "--disable-crash-reporter",
824
802
  "--disable-component-update",
@@ -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
+ }
@@ -180,7 +180,24 @@ export interface PpLaunchOptions {
180
180
  };
181
181
 
182
182
  // ========================================================================
183
- // 8. LOGGING
183
+ // 8. NOTIFICATIONS
184
+ // ========================================================================
185
+
186
+ /**
187
+ * List of hostnames to auto-grant notification permissions for.
188
+ * Pass plain hostnames — they are auto-converted to `https://host:443` internally.
189
+ *
190
+ * If empty (default), Chrome's default "Ask" behavior is used.
191
+ *
192
+ * @example
193
+ * notification_hosts: ["web.whatsapp.com", "messages.google.com"]
194
+ *
195
+ * Default: []
196
+ */
197
+ notification_hosts?: string[];
198
+
199
+ // ========================================================================
200
+ // 9. LOGGING
184
201
  // ========================================================================
185
202
 
186
203
  /**