mirror of
https://github.com/garrytan/gstack.git
synced 2026-07-20 22:40:58 +02:00
feat(browser-manager): isCustomChromium gate + per-workspace profile + lock cleanup
Three fold-ins so gbrowser can become a thin overlay instead of forking browse-server: - Export isCustomChromium(): detects custom Chromium builds that bake the extension in as a component extension. Prefers explicit GSTACK_CHROMIUM_KIND=custom-extension-baked signal; falls back to GSTACK_CHROMIUM_PATH substring containing 'GBrowser' / 'gbrowser'. Gates the --load-extension push at launchHeaded so we don't trigger ServiceWorkerState::SetWorkerId DCHECK when two copies of the same service worker race to register. - Swap hardcoded path.join(HOME, '.gstack', 'chromium-profile') in launchHeaded for resolveChromiumProfile() so phoenix can pass a per-workspace profile via CHROMIUM_PROFILE env (one daemon per gbd workspace, each with a distinct profile dir). - Call cleanSingletonLocks(userDataDir) immediately after mkdirSync. Chromium's ProcessSingleton refuses to start when stale SingletonLock/Socket/Cookie files survive a SIGKILL or hard crash; pre-launch cleanup defends against the crash case. Safe under external coordination (gbd.lock for gbrowser, single-instance CLI check for gstack). The existing .auth.json write at L291-302 is preserved — extensions still need it for bootstrap even when component-baked. Adds browse/test/browser-manager-custom-chromium.test.ts with 8 tests covering both the env-kind and path-substring signals plus stock / playwright-bundled Chromium negative cases. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,25 @@ import { writeSecureFile, mkdirSecure } from './file-permissions';
|
|||||||
import { addConsoleEntry, addNetworkEntry, addDialogEntry, networkBuffer, type DialogEntry } from './buffers';
|
import { addConsoleEntry, addNetworkEntry, addDialogEntry, networkBuffer, type DialogEntry } from './buffers';
|
||||||
import { validateNavigationUrl } from './url-validation';
|
import { validateNavigationUrl } from './url-validation';
|
||||||
import { TabSession, type RefEntry } from './tab-session';
|
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 };
|
export type { RefEntry };
|
||||||
|
|
||||||
@@ -283,9 +302,17 @@ export class BrowserManager {
|
|||||||
'--disable-blink-features=AutomationControlled',
|
'--disable-blink-features=AutomationControlled',
|
||||||
];
|
];
|
||||||
if (extensionPath) {
|
if (extensionPath) {
|
||||||
launchArgs.push(`--disable-extensions-except=${extensionPath}`);
|
// Skip --load-extension when running against a custom Chromium build
|
||||||
launchArgs.push(`--load-extension=${extensionPath}`);
|
// that already bakes the extension in as a component extension
|
||||||
// Write auth token for extension bootstrap.
|
// (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
|
// Write to ~/.gstack/.auth.json (not the extension dir, which may be read-only
|
||||||
// in .app bundles and breaks codesigning).
|
// in .app bundles and breaks codesigning).
|
||||||
if (authToken) {
|
if (authToken) {
|
||||||
@@ -308,9 +335,17 @@ export class BrowserManager {
|
|||||||
// so we use Playwright's bundled Chromium which reliably loads extensions.
|
// so we use Playwright's bundled Chromium which reliably loads extensions.
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const userDataDir = path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile');
|
const userDataDir = resolveChromiumProfile();
|
||||||
fs.mkdirSync(userDataDir, { recursive: true });
|
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.
|
// Support custom Chromium binary via GSTACK_CHROMIUM_PATH env var.
|
||||||
// Used by GStack Browser.app to point at the bundled Chromium.
|
// Used by GStack Browser.app to point at the bundled Chromium.
|
||||||
const executablePath = process.env.GSTACK_CHROMIUM_PATH || undefined;
|
const executablePath = process.env.GSTACK_CHROMIUM_PATH || undefined;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user