()
| 338 | } |
| 339 | |
| 340 | async launch() { |
| 341 | // ─── Extension Support ──────────────────────────────────── |
| 342 | // BROWSE_EXTENSIONS_DIR points to an unpacked Chrome extension directory. |
| 343 | // Extensions only work in headed mode, so we use an off-screen window. |
| 344 | const extensionsDir = process.env.BROWSE_EXTENSIONS_DIR; |
| 345 | const { STEALTH_LAUNCH_ARGS, buildGStackLaunchArgs } = await import('./stealth'); |
| 346 | const launchArgs: string[] = [...STEALTH_LAUNCH_ARGS, ...buildGStackLaunchArgs()]; |
| 347 | let useHeadless = true; |
| 348 | |
| 349 | // Docker/CI/root: Chromium sandbox requires unprivileged user namespaces which |
| 350 | // are typically disabled in containers and are never available for the root |
| 351 | // user on Linux. Detect all three cases and add --no-sandbox automatically. |
| 352 | const isRoot = typeof process.getuid === 'function' && process.getuid() === 0; |
| 353 | if (process.env.CI || process.env.CONTAINER || isRoot) { |
| 354 | launchArgs.push('--no-sandbox'); |
| 355 | } |
| 356 | |
| 357 | if (extensionsDir) { |
| 358 | // Skip --load-extension when running against a custom Chromium build that |
| 359 | // already bakes the extension in (e.g., GBrowser / GStack Browser.app). |
| 360 | // Loading it twice causes a ServiceWorkerState::SetWorkerId DCHECK crash. |
| 361 | if (!isCustomChromium()) { |
| 362 | launchArgs.push( |
| 363 | `--disable-extensions-except=${extensionsDir}`, |
| 364 | `--load-extension=${extensionsDir}`, |
| 365 | ); |
| 366 | } |
| 367 | launchArgs.push('--window-position=-9999,-9999', '--window-size=1,1'); |
| 368 | useHeadless = false; // extensions require headed mode; off-screen window simulates headless |
| 369 | console.log(`[browse] Extensions loaded from: ${extensionsDir}`); |
| 370 | } |
| 371 | |
| 372 | this.browser = await chromium.launch({ |
| 373 | headless: useHeadless, |
| 374 | // On Windows, Chromium's sandbox fails when the server is spawned through |
| 375 | // the Bun→Node process chain (GitHub #276). Disable it — local daemon |
| 376 | // browsing user-specified URLs has marginal sandbox benefit. Also disabled |
| 377 | // on Linux root/CI/container, where the sandbox requires unprivileged user |
| 378 | // namespaces that aren't available. |
| 379 | chromiumSandbox: shouldEnableChromiumSandbox(), |
| 380 | ...(launchArgs.length > 0 ? { args: launchArgs } : {}), |
| 381 | ...(this.proxyConfig ? { proxy: this.proxyConfig } : {}), |
| 382 | }); |
| 383 | |
| 384 | // Chromium disconnect → distinguish clean user-quit from crash. Both |
| 385 | // events look identical to Playwright (one 'disconnected' fires), but |
| 386 | // the underlying ChildProcess exit code separates them: |
| 387 | // exitCode === 0 → clean quit (user Cmd+Q on macOS, normal shutdown) |
| 388 | // exitCode !== 0 → crash, signal-kill, or OOM |
| 389 | // Process supervisors (gbrowser's gbd) consume our exit code: code 0 |
| 390 | // means "user wanted this, don't restart"; non-zero means "crash, please |
| 391 | // bring me back." Without this distinction every Cmd+Q gets treated as |
| 392 | // a crash and the user-visible window keeps respawning. |
| 393 | this.browser.on('disconnected', () => { |
| 394 | void handleChromiumDisconnect(this.browser); |
| 395 | }); |
| 396 | |
| 397 | const contextOptions: BrowserContextOptions = { |
no test coverage detected