fix: use launchPersistentContext for Chrome extension loading

Playwright's chromium.launch() silently ignores --load-extension.
Switch to launchPersistentContext with ignoreDefaultArgs to remove
--disable-extensions flag. Use bundled Chromium (real Chrome blocks
unpacked extensions). Fixed port 34567 for CDP mode so the extension
auto-connects.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-03-21 15:09:42 -07:00
parent 803bee9cfe
commit 3814a12ab2
2 changed files with 51 additions and 28 deletions
+49 -28
View File
@@ -166,25 +166,32 @@ export class BrowserManager {
launchArgs.push(`--load-extension=${extensionPath}`);
}
// Launch real Chrome via Playwright's channel protocol
// This uses the system Chrome binary, headed, with real window
this.browser = await chromium.launch({
channel: 'chrome',
// Launch headed Chromium via Playwright's persistent context.
// Extensions REQUIRE launchPersistentContext (not launch + newContext).
// Real Chrome (executablePath/channel) silently blocks --load-extension,
// 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');
fs.mkdirSync(userDataDir, { recursive: true });
this.context = await chromium.launchPersistentContext(userDataDir, {
headless: false,
args: launchArgs,
viewport: null, // Use browser's default viewport (real window size)
// Playwright adds flags that block extension loading
ignoreDefaultArgs: [
'--disable-extensions',
'--disable-component-extensions-with-background-pages',
],
});
this.browser = this.context.browser();
this.connectionMode = 'cdp';
this.intentionalDisconnect = false;
// Create a context (channel:chrome doesn't have pre-existing contexts)
const contextOptions: BrowserContextOptions = {
viewport: null, // Use Chrome's default viewport (real window size)
};
this.context = await this.browser.newContext(contextOptions);
// Inject visual indicator — subtle top-edge gradient + floating pill
// so the user always knows which Chrome window gstack controls
await this.context.addInitScript(() => {
const indicatorScript = () => {
const injectIndicator = () => {
if (document.getElementById('gstack-ctrl')) return;
@@ -193,7 +200,7 @@ export class BrowserManager {
topLine.id = 'gstack-ctrl';
topLine.style.cssText = `
position: fixed; top: 0; left: 0; right: 0; height: 2px;
background: linear-gradient(90deg, #4ade80, #22d3ee, #4ade80);
background: linear-gradient(90deg, #F59E0B, #FBBF24, #F59E0B);
background-size: 200% 100%;
animation: gstack-shimmer 3s linear infinite;
pointer-events: none; z-index: 2147483647;
@@ -208,17 +215,17 @@ export class BrowserManager {
z-index: 2147483647; pointer-events: none;
display: flex; align-items: center; gap: 5px;
padding: 4px 10px;
background: rgba(0, 0, 0, 0.7);
background: rgba(12, 12, 12, 0.7);
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
border: 1px solid rgba(74, 222, 128, 0.25);
border-radius: 100px;
border: 1px solid rgba(245, 158, 11, 0.25);
border-radius: 9999px;
font: 500 10px -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
color: rgba(255, 255, 255, 0.7);
letter-spacing: 0.03em;
transition: opacity 0.5s ease;
opacity: 1;
`;
pill.innerHTML = '<span style="width:5px;height:5px;border-radius:50%;background:#4ade80;box-shadow:0 0 4px rgba(74,222,128,0.5);flex-shrink:0;"></span>gstack';
pill.innerHTML = '<span style="width:5px;height:5px;border-radius:50%;background:#F59E0B;box-shadow:0 0 4px rgba(245,158,11,0.5);flex-shrink:0;"></span>gstack';
// Keyframe for shimmer animation
const style = document.createElement('style');
@@ -244,17 +251,31 @@ export class BrowserManager {
} else {
injectIndicator();
}
});
};
await this.context.addInitScript(indicatorScript);
// Create first tab
await this.newTab();
// Persistent context opens a default page — adopt it instead of creating a new one
const existingPages = this.context.pages();
if (existingPages.length > 0) {
const page = existingPages[0];
const id = this.nextTabId++;
this.pages.set(id, page);
this.activeTabId = id;
this.wirePageEvents(page);
// Inject indicator on restored page (addInitScript only fires on new navigations)
try { await page.evaluate(indicatorScript); } catch {}
} else {
await this.newTab();
}
// Browser disconnect handler
this.browser.on('disconnected', () => {
if (this.intentionalDisconnect) return;
console.error('[browse] Real browser disconnected.');
process.exit(1);
});
if (this.browser) {
this.browser.on('disconnected', () => {
if (this.intentionalDisconnect) return;
console.error('[browse] Real browser disconnected.');
process.exit(1);
});
}
// CDP-specific defaults
this.dialogAutoAccept = false; // Don't dismiss user's real dialogs
@@ -293,13 +314,13 @@ export class BrowserManager {
}
async close() {
if (this.browser) {
if (this.browser || (this.connectionMode === 'cdp' && this.context)) {
if (this.connectionMode === 'cdp') {
// CDP/channel:chrome mode: close the browser we launched
// CDP/persistent context mode: close the context (which closes the browser)
this.intentionalDisconnect = true;
this.browser.removeAllListeners('disconnected');
if (this.browser) this.browser.removeAllListeners('disconnected');
await Promise.race([
this.browser.close(),
this.context ? this.context.close() : Promise.resolve(),
new Promise(resolve => setTimeout(resolve, 5000)),
]).catch(() => {});
} else {
+2
View File
@@ -365,9 +365,11 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
console.log('Launching real Chrome browser...');
try {
// Start server with CDP flag — server.ts will use channel:chrome
// Use a well-known port so the Chrome extension auto-connects
const newState = await startServer({
BROWSE_CDP_URL: 'channel:chrome',
BROWSE_CDP_PORT: '0',
BROWSE_PORT: '34567',
});
// Print connected status