Skip to content

Commit a09e9dd

Browse files
fix(browse): load extensions via --headless=new so no window and extensions run
BROWSE_EXTENSIONS_DIR launched headed with --window-position=-9999,-9999 + browser.newContext(). On macOS the off-screen window is still shown and the isolated context leaves the loaded extension inert (issue #432). Route the extension path through launchPersistentContext + Chrome's new headless mode (--headless=new), reusing the pattern launchHeaded()/handoff() already use. Guard close() and isHealthy() for the null context.browser() that persistent contexts return, so an extension session tears down cleanly instead of leaking an orphan Chromium. Fixes #432
1 parent a325940 commit a09e9dd

2 files changed

Lines changed: 96 additions & 34 deletions

File tree

browse/src/browser-manager.ts

Lines changed: 72 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -340,11 +340,14 @@ export class BrowserManager {
340340
async launch() {
341341
// ─── Extension Support ────────────────────────────────────
342342
// BROWSE_EXTENSIONS_DIR points to an unpacked Chrome extension directory.
343-
// Extensions only work in headed mode, so we use an off-screen window.
343+
// Extensions load through launchPersistentContext() + Chrome's new headless
344+
// mode (--headless=new, set below). launch() + newContext() would isolate the
345+
// extension so it never runs, and the old off-screen-window hack still showed a
346+
// real window on macOS (issue #432).
344347
const extensionsDir = process.env.BROWSE_EXTENSIONS_DIR;
348+
const useExtensionProfile = Boolean(extensionsDir);
345349
const { STEALTH_LAUNCH_ARGS, buildGStackLaunchArgs } = await import('./stealth');
346350
const launchArgs: string[] = [...STEALTH_LAUNCH_ARGS, ...buildGStackLaunchArgs()];
347-
let useHeadless = true;
348351

349352
// Docker/CI/root: Chromium sandbox requires unprivileged user namespaces which
350353
// are typically disabled in containers and are never available for the root
@@ -354,7 +357,7 @@ export class BrowserManager {
354357
launchArgs.push('--no-sandbox');
355358
}
356359

357-
if (extensionsDir) {
360+
if (useExtensionProfile) {
358361
// Skip --load-extension when running against a custom Chromium build that
359362
// already bakes the extension in (e.g., GBrowser / GStack Browser.app).
360363
// Loading it twice causes a ServiceWorkerState::SetWorkerId DCHECK crash.
@@ -364,44 +367,70 @@ export class BrowserManager {
364367
`--load-extension=${extensionsDir}`,
365368
);
366369
}
367-
launchArgs.push('--window-position=-9999,-9999', '--window-size=1,1');
368-
useHeadless = false; // extensions require headed mode; off-screen window simulates headless
370+
// New headless (Chromium 112+) runs extensions with no visible window, so
371+
// there is no headed window to hide. Replaces the --window-position hack
372+
// that macOS ignored (issue #432).
373+
launchArgs.push('--headless=new');
369374
console.log(`[browse] Extensions loaded from: ${extensionsDir}`);
370375
}
371376

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-
397377
const contextOptions: BrowserContextOptions = {
398378
viewport: { width: this.currentViewport.width, height: this.currentViewport.height },
399379
deviceScaleFactor: this.deviceScaleFactor,
400380
};
401381
if (this.customUserAgent) {
402382
contextOptions.userAgent = this.customUserAgent;
403383
}
404-
this.context = await this.browser.newContext(contextOptions);
384+
385+
if (useExtensionProfile) {
386+
// Extensions REQUIRE launchPersistentContext — launch() + newContext() loads
387+
// them into an isolated context where they never run. --headless=new (added
388+
// above) keeps the window hidden. Mirrors launchHeaded()/handoff(); like those
389+
// persistent-context paths, this.context.browser() is null, so the disconnect,
390+
// teardown (close), and health checks below are guarded on this.context.
391+
const fs = require('fs');
392+
const userDataDir = resolveChromiumProfile();
393+
fs.mkdirSync(userDataDir, { recursive: true });
394+
cleanSingletonLocks(userDataDir);
395+
const { STEALTH_IGNORE_DEFAULT_ARGS } = await import('./stealth');
396+
this.context = await chromium.launchPersistentContext(userDataDir, {
397+
headless: false, // --headless=new in launchArgs drives windowless new-headless
398+
chromiumSandbox: shouldEnableChromiumSandbox(),
399+
args: launchArgs,
400+
ignoreDefaultArgs: STEALTH_IGNORE_DEFAULT_ARGS,
401+
...contextOptions,
402+
...(this.proxyConfig ? { proxy: this.proxyConfig } : {}),
403+
});
404+
this.browser = this.context.browser(); // null for persistent contexts
405+
} else {
406+
this.browser = await chromium.launch({
407+
headless: true,
408+
// On Windows, Chromium's sandbox fails when the server is spawned through
409+
// the Bun→Node process chain (GitHub #276). Disable it — local daemon
410+
// browsing user-specified URLs has marginal sandbox benefit. Also disabled
411+
// on Linux root/CI/container, where the sandbox requires unprivileged user
412+
// namespaces that aren't available.
413+
chromiumSandbox: shouldEnableChromiumSandbox(),
414+
...(launchArgs.length > 0 ? { args: launchArgs } : {}),
415+
...(this.proxyConfig ? { proxy: this.proxyConfig } : {}),
416+
});
417+
this.context = await this.browser.newContext(contextOptions);
418+
}
419+
420+
// Chromium disconnect → distinguish clean user-quit from crash. Both events
421+
// look identical to Playwright (one 'disconnected' fires), but the underlying
422+
// ChildProcess exit code separates them:
423+
// exitCode === 0 → clean quit (user Cmd+Q on macOS, normal shutdown)
424+
// exitCode !== 0 → crash, signal-kill, or OOM
425+
// Process supervisors (gbrowser's gbd) consume our exit code: code 0 means
426+
// "user wanted this, don't restart"; non-zero means "crash, please bring me
427+
// back." this.browser is null on the persistent-context (extension) path, so
428+
// guard the listener; that path is torn down via this.context in close().
429+
if (this.browser) {
430+
this.browser.on('disconnected', () => {
431+
void handleChromiumDisconnect(this.browser);
432+
});
433+
}
405434

406435
if (Object.keys(this.extraHeaders).length > 0) {
407436
await this.context.setExtraHTTPHeaders(this.extraHeaders);
@@ -723,8 +752,10 @@ export class BrowserManager {
723752
}
724753

725754
async close() {
726-
if (this.browser || (this.connectionMode === 'headed' && this.context)) {
727-
if (this.connectionMode === 'headed') {
755+
if (this.browser || this.context) {
756+
// Persistent contexts (headed handoff OR headless extensions) have a null
757+
// this.browser; closing the context closes the underlying browser.
758+
if (this.connectionMode === 'headed' || !this.browser) {
728759
// Headed/persistent context mode: close the context (which closes the browser)
729760
this.intentionalDisconnect = true;
730761
if (this.browser) this.browser.removeAllListeners('disconnected');
@@ -746,7 +777,14 @@ export class BrowserManager {
746777

747778
/** Health check — verifies Chromium is connected AND responsive */
748779
async isHealthy(): Promise<boolean> {
749-
if (!this.browser || !this.browser.isConnected()) return false;
780+
// Persistent contexts (headed handoff, headless extensions) expose no Browser
781+
// handle (this.browser is null) though this.context is live. Fall through to the
782+
// page-responsiveness probe in that case instead of reporting unhealthy.
783+
if (this.browser) {
784+
if (!this.browser.isConnected()) return false;
785+
} else if (!this.context) {
786+
return false;
787+
}
750788
try {
751789
const page = this.pages.get(this.activeTabId);
752790
if (!page) return true; // connected but no pages — still healthy

browse/test/browser-manager-unit.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,3 +303,27 @@ describe('stealth injected on every context-creation path', () => {
303303
expect(sites.length).toBeGreaterThanOrEqual(2);
304304
});
305305
});
306+
307+
// ─── Extension launch path uses new headless, not an off-screen window (#432) ───
308+
//
309+
// BROWSE_EXTENSIONS_DIR previously launched headed with --window-position=-9999
310+
// + browser.newContext(). On macOS the off-screen window is still shown, and the
311+
// isolated newContext() means the loaded extension never runs. The fix loads the
312+
// extension through launchPersistentContext() with Chrome's new headless mode
313+
// (--headless=new): the extension's service worker loads with no visible window.
314+
describe('extension launch path (issue #432)', () => {
315+
it('loads extensions via new headless, not an off-screen headed window', async () => {
316+
const { readFileSync } = await import('node:fs');
317+
const { join } = await import('node:path');
318+
const src = readFileSync(join(import.meta.dir, '..', 'src', 'browser-manager.ts'), 'utf-8');
319+
320+
// The off-screen-window hack is gone (macOS ignores it; the window shows).
321+
expect(src).not.toContain('--window-position=-9999,-9999');
322+
// New headless keeps the window hidden while still loading extensions.
323+
expect(src).toContain('--headless=new');
324+
// Extensions require a persistent context — launch() + newContext() leaves
325+
// them inert. All three extension-capable paths must use it now.
326+
const persistent = src.match(/launchPersistentContext\(/g) || [];
327+
expect(persistent.length).toBeGreaterThanOrEqual(3);
328+
});
329+
});

0 commit comments

Comments
 (0)