@@ -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
0 commit comments