mirror of https://github.com/garrytan/gstack.git
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
This commit is contained in:
parent
a3259400a3
commit
a09e9dd9e7
|
|
@ -340,11 +340,14 @@ export class BrowserManager {
|
||||||
async launch() {
|
async launch() {
|
||||||
// ─── Extension Support ────────────────────────────────────
|
// ─── Extension Support ────────────────────────────────────
|
||||||
// BROWSE_EXTENSIONS_DIR points to an unpacked Chrome extension directory.
|
// BROWSE_EXTENSIONS_DIR points to an unpacked Chrome extension directory.
|
||||||
// Extensions only work in headed mode, so we use an off-screen window.
|
// Extensions load through launchPersistentContext() + Chrome's new headless
|
||||||
|
// mode (--headless=new, set below). launch() + newContext() would isolate the
|
||||||
|
// extension so it never runs, and the old off-screen-window hack still showed a
|
||||||
|
// real window on macOS (issue #432).
|
||||||
const extensionsDir = process.env.BROWSE_EXTENSIONS_DIR;
|
const extensionsDir = process.env.BROWSE_EXTENSIONS_DIR;
|
||||||
|
const useExtensionProfile = Boolean(extensionsDir);
|
||||||
const { STEALTH_LAUNCH_ARGS, buildGStackLaunchArgs } = await import('./stealth');
|
const { STEALTH_LAUNCH_ARGS, buildGStackLaunchArgs } = await import('./stealth');
|
||||||
const launchArgs: string[] = [...STEALTH_LAUNCH_ARGS, ...buildGStackLaunchArgs()];
|
const launchArgs: string[] = [...STEALTH_LAUNCH_ARGS, ...buildGStackLaunchArgs()];
|
||||||
let useHeadless = true;
|
|
||||||
|
|
||||||
// Docker/CI/root: Chromium sandbox requires unprivileged user namespaces which
|
// Docker/CI/root: Chromium sandbox requires unprivileged user namespaces which
|
||||||
// are typically disabled in containers and are never available for the root
|
// are typically disabled in containers and are never available for the root
|
||||||
|
|
@ -354,7 +357,7 @@ export class BrowserManager {
|
||||||
launchArgs.push('--no-sandbox');
|
launchArgs.push('--no-sandbox');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (extensionsDir) {
|
if (useExtensionProfile) {
|
||||||
// Skip --load-extension when running against a custom Chromium build that
|
// Skip --load-extension when running against a custom Chromium build that
|
||||||
// already bakes the extension in (e.g., GBrowser / GStack Browser.app).
|
// already bakes the extension in (e.g., GBrowser / GStack Browser.app).
|
||||||
// Loading it twice causes a ServiceWorkerState::SetWorkerId DCHECK crash.
|
// Loading it twice causes a ServiceWorkerState::SetWorkerId DCHECK crash.
|
||||||
|
|
@ -364,36 +367,13 @@ export class BrowserManager {
|
||||||
`--load-extension=${extensionsDir}`,
|
`--load-extension=${extensionsDir}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
launchArgs.push('--window-position=-9999,-9999', '--window-size=1,1');
|
// New headless (Chromium 112+) runs extensions with no visible window, so
|
||||||
useHeadless = false; // extensions require headed mode; off-screen window simulates headless
|
// there is no headed window to hide. Replaces the --window-position hack
|
||||||
|
// that macOS ignored (issue #432).
|
||||||
|
launchArgs.push('--headless=new');
|
||||||
console.log(`[browse] Extensions loaded from: ${extensionsDir}`);
|
console.log(`[browse] Extensions loaded from: ${extensionsDir}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.browser = await chromium.launch({
|
|
||||||
headless: useHeadless,
|
|
||||||
// On Windows, Chromium's sandbox fails when the server is spawned through
|
|
||||||
// the Bun→Node process chain (GitHub #276). Disable it — local daemon
|
|
||||||
// browsing user-specified URLs has marginal sandbox benefit. Also disabled
|
|
||||||
// on Linux root/CI/container, where the sandbox requires unprivileged user
|
|
||||||
// namespaces that aren't available.
|
|
||||||
chromiumSandbox: shouldEnableChromiumSandbox(),
|
|
||||||
...(launchArgs.length > 0 ? { args: launchArgs } : {}),
|
|
||||||
...(this.proxyConfig ? { proxy: this.proxyConfig } : {}),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Chromium disconnect → distinguish clean user-quit from crash. Both
|
|
||||||
// events look identical to Playwright (one 'disconnected' fires), but
|
|
||||||
// the underlying ChildProcess exit code separates them:
|
|
||||||
// exitCode === 0 → clean quit (user Cmd+Q on macOS, normal shutdown)
|
|
||||||
// exitCode !== 0 → crash, signal-kill, or OOM
|
|
||||||
// Process supervisors (gbrowser's gbd) consume our exit code: code 0
|
|
||||||
// means "user wanted this, don't restart"; non-zero means "crash, please
|
|
||||||
// bring me back." Without this distinction every Cmd+Q gets treated as
|
|
||||||
// a crash and the user-visible window keeps respawning.
|
|
||||||
this.browser.on('disconnected', () => {
|
|
||||||
void handleChromiumDisconnect(this.browser);
|
|
||||||
});
|
|
||||||
|
|
||||||
const contextOptions: BrowserContextOptions = {
|
const contextOptions: BrowserContextOptions = {
|
||||||
viewport: { width: this.currentViewport.width, height: this.currentViewport.height },
|
viewport: { width: this.currentViewport.width, height: this.currentViewport.height },
|
||||||
deviceScaleFactor: this.deviceScaleFactor,
|
deviceScaleFactor: this.deviceScaleFactor,
|
||||||
|
|
@ -401,7 +381,56 @@ export class BrowserManager {
|
||||||
if (this.customUserAgent) {
|
if (this.customUserAgent) {
|
||||||
contextOptions.userAgent = this.customUserAgent;
|
contextOptions.userAgent = this.customUserAgent;
|
||||||
}
|
}
|
||||||
this.context = await this.browser.newContext(contextOptions);
|
|
||||||
|
if (useExtensionProfile) {
|
||||||
|
// Extensions REQUIRE launchPersistentContext — launch() + newContext() loads
|
||||||
|
// them into an isolated context where they never run. --headless=new (added
|
||||||
|
// above) keeps the window hidden. Mirrors launchHeaded()/handoff(); like those
|
||||||
|
// persistent-context paths, this.context.browser() is null, so the disconnect,
|
||||||
|
// teardown (close), and health checks below are guarded on this.context.
|
||||||
|
const fs = require('fs');
|
||||||
|
const userDataDir = resolveChromiumProfile();
|
||||||
|
fs.mkdirSync(userDataDir, { recursive: true });
|
||||||
|
cleanSingletonLocks(userDataDir);
|
||||||
|
const { STEALTH_IGNORE_DEFAULT_ARGS } = await import('./stealth');
|
||||||
|
this.context = await chromium.launchPersistentContext(userDataDir, {
|
||||||
|
headless: false, // --headless=new in launchArgs drives windowless new-headless
|
||||||
|
chromiumSandbox: shouldEnableChromiumSandbox(),
|
||||||
|
args: launchArgs,
|
||||||
|
ignoreDefaultArgs: STEALTH_IGNORE_DEFAULT_ARGS,
|
||||||
|
...contextOptions,
|
||||||
|
...(this.proxyConfig ? { proxy: this.proxyConfig } : {}),
|
||||||
|
});
|
||||||
|
this.browser = this.context.browser(); // null for persistent contexts
|
||||||
|
} else {
|
||||||
|
this.browser = await chromium.launch({
|
||||||
|
headless: true,
|
||||||
|
// On Windows, Chromium's sandbox fails when the server is spawned through
|
||||||
|
// the Bun→Node process chain (GitHub #276). Disable it — local daemon
|
||||||
|
// browsing user-specified URLs has marginal sandbox benefit. Also disabled
|
||||||
|
// on Linux root/CI/container, where the sandbox requires unprivileged user
|
||||||
|
// namespaces that aren't available.
|
||||||
|
chromiumSandbox: shouldEnableChromiumSandbox(),
|
||||||
|
...(launchArgs.length > 0 ? { args: launchArgs } : {}),
|
||||||
|
...(this.proxyConfig ? { proxy: this.proxyConfig } : {}),
|
||||||
|
});
|
||||||
|
this.context = await this.browser.newContext(contextOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chromium disconnect → distinguish clean user-quit from crash. Both events
|
||||||
|
// look identical to Playwright (one 'disconnected' fires), but the underlying
|
||||||
|
// ChildProcess exit code separates them:
|
||||||
|
// exitCode === 0 → clean quit (user Cmd+Q on macOS, normal shutdown)
|
||||||
|
// exitCode !== 0 → crash, signal-kill, or OOM
|
||||||
|
// Process supervisors (gbrowser's gbd) consume our exit code: code 0 means
|
||||||
|
// "user wanted this, don't restart"; non-zero means "crash, please bring me
|
||||||
|
// back." this.browser is null on the persistent-context (extension) path, so
|
||||||
|
// guard the listener; that path is torn down via this.context in close().
|
||||||
|
if (this.browser) {
|
||||||
|
this.browser.on('disconnected', () => {
|
||||||
|
void handleChromiumDisconnect(this.browser);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (Object.keys(this.extraHeaders).length > 0) {
|
if (Object.keys(this.extraHeaders).length > 0) {
|
||||||
await this.context.setExtraHTTPHeaders(this.extraHeaders);
|
await this.context.setExtraHTTPHeaders(this.extraHeaders);
|
||||||
|
|
@ -723,8 +752,10 @@ export class BrowserManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
async close() {
|
async close() {
|
||||||
if (this.browser || (this.connectionMode === 'headed' && this.context)) {
|
if (this.browser || this.context) {
|
||||||
if (this.connectionMode === 'headed') {
|
// Persistent contexts (headed handoff OR headless extensions) have a null
|
||||||
|
// this.browser; closing the context closes the underlying browser.
|
||||||
|
if (this.connectionMode === 'headed' || !this.browser) {
|
||||||
// Headed/persistent context mode: close the context (which closes the browser)
|
// Headed/persistent context mode: close the context (which closes the browser)
|
||||||
this.intentionalDisconnect = true;
|
this.intentionalDisconnect = true;
|
||||||
if (this.browser) this.browser.removeAllListeners('disconnected');
|
if (this.browser) this.browser.removeAllListeners('disconnected');
|
||||||
|
|
@ -746,7 +777,14 @@ export class BrowserManager {
|
||||||
|
|
||||||
/** Health check — verifies Chromium is connected AND responsive */
|
/** Health check — verifies Chromium is connected AND responsive */
|
||||||
async isHealthy(): Promise<boolean> {
|
async isHealthy(): Promise<boolean> {
|
||||||
if (!this.browser || !this.browser.isConnected()) return false;
|
// Persistent contexts (headed handoff, headless extensions) expose no Browser
|
||||||
|
// handle (this.browser is null) though this.context is live. Fall through to the
|
||||||
|
// page-responsiveness probe in that case instead of reporting unhealthy.
|
||||||
|
if (this.browser) {
|
||||||
|
if (!this.browser.isConnected()) return false;
|
||||||
|
} else if (!this.context) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const page = this.pages.get(this.activeTabId);
|
const page = this.pages.get(this.activeTabId);
|
||||||
if (!page) return true; // connected but no pages — still healthy
|
if (!page) return true; // connected but no pages — still healthy
|
||||||
|
|
|
||||||
|
|
@ -303,3 +303,27 @@ describe('stealth injected on every context-creation path', () => {
|
||||||
expect(sites.length).toBeGreaterThanOrEqual(2);
|
expect(sites.length).toBeGreaterThanOrEqual(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Extension launch path uses new headless, not an off-screen window (#432) ───
|
||||||
|
//
|
||||||
|
// BROWSE_EXTENSIONS_DIR previously launched headed with --window-position=-9999
|
||||||
|
// + browser.newContext(). On macOS the off-screen window is still shown, and the
|
||||||
|
// isolated newContext() means the loaded extension never runs. The fix loads the
|
||||||
|
// extension through launchPersistentContext() with Chrome's new headless mode
|
||||||
|
// (--headless=new): the extension's service worker loads with no visible window.
|
||||||
|
describe('extension launch path (issue #432)', () => {
|
||||||
|
it('loads extensions via new headless, not an off-screen headed window', async () => {
|
||||||
|
const { readFileSync } = await import('node:fs');
|
||||||
|
const { join } = await import('node:path');
|
||||||
|
const src = readFileSync(join(import.meta.dir, '..', 'src', 'browser-manager.ts'), 'utf-8');
|
||||||
|
|
||||||
|
// The off-screen-window hack is gone (macOS ignores it; the window shows).
|
||||||
|
expect(src).not.toContain('--window-position=-9999,-9999');
|
||||||
|
// New headless keeps the window hidden while still loading extensions.
|
||||||
|
expect(src).toContain('--headless=new');
|
||||||
|
// Extensions require a persistent context — launch() + newContext() leaves
|
||||||
|
// them inert. All three extension-capable paths must use it now.
|
||||||
|
const persistent = src.match(/launchPersistentContext\(/g) || [];
|
||||||
|
expect(persistent.length).toBeGreaterThanOrEqual(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue