diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index fd906caa3..cdbd5fc50 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -20,6 +20,25 @@ import { writeSecureFile, mkdirSecure } from './file-permissions'; import { addConsoleEntry, addNetworkEntry, addDialogEntry, networkBuffer, type DialogEntry } from './buffers'; import { validateNavigationUrl } from './url-validation'; import { TabSession, type RefEntry } from './tab-session'; +import { resolveChromiumProfile, cleanSingletonLocks } from './config'; + +/** + * Detect whether GSTACK_CHROMIUM_PATH points at a custom Chromium build that + * already bakes the gstack extension in as a component extension (e.g., + * GStack Browser.app / GBrowser). Passing --load-extension against such a + * binary triggers a ServiceWorkerState::SetWorkerId DCHECK because two + * copies of the same service worker try to register. + * + * Resolution: + * 1. GSTACK_CHROMIUM_KIND === 'custom-extension-baked' (preferred, explicit) + * 2. GSTACK_CHROMIUM_PATH path substring contains 'GBrowser' or 'gbrowser' + * (fallback for callers that only set the path) + */ +export function isCustomChromium(): boolean { + if (process.env.GSTACK_CHROMIUM_KIND === 'custom-extension-baked') return true; + const p = process.env.GSTACK_CHROMIUM_PATH || ''; + return p.includes('GBrowser') || p.includes('gbrowser'); +} export type { RefEntry }; @@ -283,9 +302,17 @@ export class BrowserManager { '--disable-blink-features=AutomationControlled', ]; if (extensionPath) { - launchArgs.push(`--disable-extensions-except=${extensionPath}`); - launchArgs.push(`--load-extension=${extensionPath}`); - // Write auth token for extension bootstrap. + // Skip --load-extension when running against a custom Chromium build + // that already bakes the extension in as a component extension + // (gbrowser / GStack Browser.app). Loading it twice causes a + // ServiceWorkerState::SetWorkerId DCHECK crash. + if (!isCustomChromium()) { + launchArgs.push(`--disable-extensions-except=${extensionPath}`); + launchArgs.push(`--load-extension=${extensionPath}`); + } + // Write auth token for extension bootstrap (still required even when + // the extension is component-baked — it reads ~/.gstack/.auth.json at + // startup to learn how to call the daemon). // Write to ~/.gstack/.auth.json (not the extension dir, which may be read-only // in .app bundles and breaks codesigning). if (authToken) { @@ -308,9 +335,17 @@ export class BrowserManager { // so we use Playwright's bundled Chromium which reliably loads extensions. const fs = require('fs'); const path = require('path'); - const userDataDir = path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile'); + const userDataDir = resolveChromiumProfile(); fs.mkdirSync(userDataDir, { recursive: true }); + // Pre-launch cleanup of stale SingletonLock/Socket/Cookie. Chromium's + // ProcessSingleton refuses to start when these exist from a prior crash + // (SIGKILL, hard crash) — the lockfiles point at a PID that may no longer + // exist. Shutdown cleanup doesn't run on hard crashes, so we clean here + // too. Safe under external coordination: gbd.lock for gbrowser, + // single-instance CLI check for gstack. + cleanSingletonLocks(userDataDir); + // Support custom Chromium binary via GSTACK_CHROMIUM_PATH env var. // Used by GStack Browser.app to point at the bundled Chromium. const executablePath = process.env.GSTACK_CHROMIUM_PATH || undefined; diff --git a/browse/test/browser-manager-custom-chromium.test.ts b/browse/test/browser-manager-custom-chromium.test.ts new file mode 100644 index 000000000..782a8eece --- /dev/null +++ b/browse/test/browser-manager-custom-chromium.test.ts @@ -0,0 +1,67 @@ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { isCustomChromium } from '../src/browser-manager'; + +describe('browser-manager: isCustomChromium', () => { + let origPath: string | undefined; + let origKind: string | undefined; + + beforeEach(() => { + origPath = process.env.GSTACK_CHROMIUM_PATH; + origKind = process.env.GSTACK_CHROMIUM_KIND; + }); + + afterEach(() => { + if (origPath === undefined) delete process.env.GSTACK_CHROMIUM_PATH; + else process.env.GSTACK_CHROMIUM_PATH = origPath; + if (origKind === undefined) delete process.env.GSTACK_CHROMIUM_KIND; + else process.env.GSTACK_CHROMIUM_KIND = origKind; + }); + + test('GSTACK_CHROMIUM_KIND=custom-extension-baked → true (preferred explicit signal)', () => { + delete process.env.GSTACK_CHROMIUM_PATH; + process.env.GSTACK_CHROMIUM_KIND = 'custom-extension-baked'; + expect(isCustomChromium()).toBe(true); + }); + + test('GSTACK_CHROMIUM_KIND wins even when path is stock Chromium', () => { + process.env.GSTACK_CHROMIUM_PATH = '/usr/bin/chromium'; + process.env.GSTACK_CHROMIUM_KIND = 'custom-extension-baked'; + expect(isCustomChromium()).toBe(true); + }); + + test('PascalCase GBrowser in path → true (fallback substring match)', () => { + delete process.env.GSTACK_CHROMIUM_KIND; + process.env.GSTACK_CHROMIUM_PATH = '/Applications/GBrowser.app/Contents/MacOS/GBrowser'; + expect(isCustomChromium()).toBe(true); + }); + + test('lowercase gbrowser in path → true (fallback substring match)', () => { + delete process.env.GSTACK_CHROMIUM_KIND; + process.env.GSTACK_CHROMIUM_PATH = '/Applications/gbrowser-dev.app/Contents/MacOS/GBrowser'; + expect(isCustomChromium()).toBe(true); + }); + + test('both env vars unset → false', () => { + delete process.env.GSTACK_CHROMIUM_PATH; + delete process.env.GSTACK_CHROMIUM_KIND; + expect(isCustomChromium()).toBe(false); + }); + + test('stock chromium path → false', () => { + delete process.env.GSTACK_CHROMIUM_KIND; + process.env.GSTACK_CHROMIUM_PATH = '/usr/bin/chromium'; + expect(isCustomChromium()).toBe(false); + }); + + test('Playwright bundled chromium path → false', () => { + delete process.env.GSTACK_CHROMIUM_KIND; + process.env.GSTACK_CHROMIUM_PATH = '/Users/me/Library/Caches/ms-playwright/chromium-1234/chrome-mac/Chromium.app/Contents/MacOS/Chromium'; + expect(isCustomChromium()).toBe(false); + }); + + test('GSTACK_CHROMIUM_KIND with unrelated value falls through to path check', () => { + process.env.GSTACK_CHROMIUM_KIND = 'something-else'; + process.env.GSTACK_CHROMIUM_PATH = '/usr/bin/chromium'; + expect(isCustomChromium()).toBe(false); + }); +});