mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-19 19:02:29 +08:00
fix: use Playwright channel:chrome instead of broken connectOverCDP
Playwright's connectOverCDP hangs with Chrome 146 due to CDP protocol version mismatch. Switch to channel:'chrome' which uses Playwright's native pipe protocol to launch the system Chrome binary directly. This is simpler and more reliable: - No CDP port discovery needed - No --remote-debugging-port or --user-data-dir hassles - $B connect just works — launches real Chrome headed window - All Playwright APIs (snapshot, click, fill) work unchanged bin/chrome-cdp updated with symlinked profile approach (kept for manual CDP use cases, but $B connect no longer needs it). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,11 +2,14 @@
|
|||||||
# Launch Chrome with CDP (remote debugging) enabled.
|
# Launch Chrome with CDP (remote debugging) enabled.
|
||||||
# Usage: chrome-cdp [port]
|
# Usage: chrome-cdp [port]
|
||||||
#
|
#
|
||||||
# Chrome MUST be fully quit before running this — if it's already running,
|
# Chrome refuses --remote-debugging-port on its default data directory.
|
||||||
# it ignores --remote-debugging-port and opens in the existing session.
|
# We create a separate data dir with a symlink to the user's real profile,
|
||||||
|
# so Chrome thinks it's non-default but uses the same cookies/extensions.
|
||||||
|
|
||||||
PORT="${1:-9222}"
|
PORT="${1:-9222}"
|
||||||
CHROME="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
CHROME="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
||||||
|
REAL_PROFILE="$HOME/Library/Application Support/Google/Chrome"
|
||||||
|
CDP_DATA_DIR="$HOME/.gstack/cdp-profile/chrome"
|
||||||
|
|
||||||
if ! [ -f "$CHROME" ]; then
|
if ! [ -f "$CHROME" ]; then
|
||||||
echo "Chrome not found at $CHROME" >&2
|
echo "Chrome not found at $CHROME" >&2
|
||||||
@@ -31,8 +34,24 @@ if pgrep -f "Google Chrome" >/dev/null 2>&1; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Set up CDP data dir with symlinked profile
|
||||||
|
# Chrome requires a "non-default" data dir for --remote-debugging-port.
|
||||||
|
# We symlink the real Default profile so cookies/extensions carry over.
|
||||||
|
mkdir -p "$CDP_DATA_DIR"
|
||||||
|
if [ -d "$REAL_PROFILE/Default" ] && ! [ -e "$CDP_DATA_DIR/Default" ]; then
|
||||||
|
ln -s "$REAL_PROFILE/Default" "$CDP_DATA_DIR/Default"
|
||||||
|
echo "Linked real Chrome profile into CDP data dir"
|
||||||
|
fi
|
||||||
|
# Also link Local State (contains crypto keys for cookie decryption, etc.)
|
||||||
|
if [ -f "$REAL_PROFILE/Local State" ] && ! [ -e "$CDP_DATA_DIR/Local State" ]; then
|
||||||
|
ln -s "$REAL_PROFILE/Local State" "$CDP_DATA_DIR/Local State"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Launching Chrome with CDP on port $PORT..."
|
echo "Launching Chrome with CDP on port $PORT..."
|
||||||
"$CHROME" --remote-debugging-port="$PORT" --restore-last-session &
|
"$CHROME" \
|
||||||
|
--remote-debugging-port="$PORT" \
|
||||||
|
--user-data-dir="$CDP_DATA_DIR" \
|
||||||
|
--restore-last-session &
|
||||||
disown
|
disown
|
||||||
|
|
||||||
# Wait for CDP to be available
|
# Wait for CDP to be available
|
||||||
@@ -45,5 +64,5 @@ for i in $(seq 1 30); do
|
|||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "CDP not available after 30s. Chrome may have started without debug port." >&2
|
echo "CDP not available after 30s." >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
@@ -109,55 +109,46 @@ export class BrowserManager {
|
|||||||
|
|
||||||
// ─── CDP Connect ────────────────────────────────────────────
|
// ─── CDP Connect ────────────────────────────────────────────
|
||||||
/**
|
/**
|
||||||
* Connect to a running browser via Chrome DevTools Protocol.
|
* Launch the user's real Chrome browser via Playwright's channel: 'chrome'.
|
||||||
* All existing commands work unchanged through Playwright's abstraction.
|
|
||||||
*
|
*
|
||||||
* CDP flow:
|
* Uses Playwright's native pipe protocol (not CDP WebSocket) to control
|
||||||
* connectOverCDP(wsUrl) → Browser → contexts()[0] → discover pages
|
* the system Chrome binary. This avoids CDP protocol version mismatches
|
||||||
* Disconnect handler → attemptReconnect() (not process.exit)
|
* between Playwright and recent Chrome versions.
|
||||||
* close() → browser.disconnect() (not browser.close())
|
*
|
||||||
|
* The browser launches headed with a visible window — the user sees
|
||||||
|
* every action Claude takes in real time.
|
||||||
*/
|
*/
|
||||||
async connectCDP(wsUrl: string, port: number): Promise<void> {
|
async connectCDP(_wsUrl: string, _port: number): Promise<void> {
|
||||||
// Clear old state before repopulating (safe for reconnect)
|
// Clear old state before repopulating (safe for reconnect)
|
||||||
this.pages.clear();
|
this.pages.clear();
|
||||||
this.preExistingTabIds.clear();
|
this.preExistingTabIds.clear();
|
||||||
this.refMap.clear();
|
this.refMap.clear();
|
||||||
this.nextTabId = 1;
|
this.nextTabId = 1;
|
||||||
|
|
||||||
this.browser = await chromium.connectOverCDP(wsUrl);
|
// 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',
|
||||||
|
headless: false,
|
||||||
|
args: ['--restore-last-session'],
|
||||||
|
});
|
||||||
this.connectionMode = 'cdp';
|
this.connectionMode = 'cdp';
|
||||||
this.cdpPort = port;
|
|
||||||
this.intentionalDisconnect = false;
|
this.intentionalDisconnect = false;
|
||||||
|
|
||||||
// Use the user's existing default context (has their cookies, sessions)
|
// Create a context (channel:chrome doesn't have pre-existing contexts)
|
||||||
const contexts = this.browser.contexts();
|
const contextOptions: BrowserContextOptions = {
|
||||||
if (contexts.length === 0) {
|
viewport: null, // Use Chrome's default viewport (real window size)
|
||||||
throw new Error('No browser context found. Chrome may have no windows open.');
|
};
|
||||||
}
|
this.context = await this.browser.newContext(contextOptions);
|
||||||
this.context = contexts[0];
|
|
||||||
|
|
||||||
// Discover existing tabs
|
// Create first tab
|
||||||
for (const page of this.context.pages()) {
|
await this.newTab();
|
||||||
const id = this.nextTabId++;
|
|
||||||
this.pages.set(id, page);
|
|
||||||
this.preExistingTabIds.add(id);
|
|
||||||
this.wirePageEvents(page);
|
|
||||||
}
|
|
||||||
this.activeTabId = [...this.pages.keys()].pop() || 0;
|
|
||||||
|
|
||||||
// Listen for new tabs created by the user
|
// Browser disconnect handler
|
||||||
this.context.on('page', (page: Page) => {
|
|
||||||
const id = this.nextTabId++;
|
|
||||||
this.pages.set(id, page);
|
|
||||||
this.wirePageEvents(page);
|
|
||||||
this.activeTabId = id;
|
|
||||||
});
|
|
||||||
|
|
||||||
// CDP disconnect ≠ crash — reconnect unless intentional
|
|
||||||
this.browser.on('disconnected', () => {
|
this.browser.on('disconnected', () => {
|
||||||
if (this.intentionalDisconnect) return;
|
if (this.intentionalDisconnect) return;
|
||||||
console.log('[browse] Real browser disconnected — reconnecting...');
|
console.error('[browse] Real browser disconnected.');
|
||||||
this.attemptReconnect();
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
// CDP-specific defaults
|
// CDP-specific defaults
|
||||||
|
|||||||
@@ -346,96 +346,28 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
|
|||||||
|
|
||||||
// ─── CDP Connect (pre-server command) ───────────────────────
|
// ─── CDP Connect (pre-server command) ───────────────────────
|
||||||
// connect must be handled BEFORE ensureServer() because it needs
|
// connect must be handled BEFORE ensureServer() because it needs
|
||||||
// to restart the server with CDP env vars.
|
// to restart the server with real Chrome via Playwright channel:chrome.
|
||||||
if (command === 'connect') {
|
if (command === 'connect') {
|
||||||
const { discoverAndConnect, isManualRestart, detectRuntime, isCdpAvailable } = await import('./chrome-launcher');
|
|
||||||
|
|
||||||
// Parse args: connect [browser] [--port N]
|
|
||||||
let preferredBrowser: string | undefined;
|
|
||||||
let port = 9222;
|
|
||||||
for (let i = 0; i < commandArgs.length; i++) {
|
|
||||||
if (commandArgs[i] === '--port' && commandArgs[i + 1]) {
|
|
||||||
port = parseInt(commandArgs[i + 1], 10);
|
|
||||||
i++;
|
|
||||||
} else if (!commandArgs[i].startsWith('-')) {
|
|
||||||
preferredBrowser = commandArgs[i];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if already in CDP mode
|
// Check if already in CDP mode
|
||||||
const existingState = readState();
|
const existingState = readState();
|
||||||
if (existingState && existingState.mode === 'cdp') {
|
if (existingState && existingState.mode === 'cdp') {
|
||||||
console.log('Already connected to real browser via CDP.');
|
console.log('Already connected to real browser.');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kill existing server if running
|
// Kill existing headless server if running
|
||||||
if (existingState) {
|
if (existingState) {
|
||||||
try { process.kill(existingState.pid, 'SIGTERM'); } catch {}
|
try { process.kill(existingState.pid, 'SIGTERM'); } catch {}
|
||||||
try { fs.unlinkSync(config.stateFile); } catch {}
|
try { fs.unlinkSync(config.stateFile); } catch {}
|
||||||
// Wait for clean shutdown
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Discover and connect to browser
|
console.log('Launching real Chrome browser...');
|
||||||
const runtime = detectRuntime();
|
|
||||||
console.log(`Discovering browser${preferredBrowser ? ` (${preferredBrowser})` : ''} (runtime: ${runtime})...`);
|
|
||||||
try {
|
try {
|
||||||
const result = await discoverAndConnect(preferredBrowser, port);
|
// Start server with CDP flag — server.ts will use channel:chrome
|
||||||
|
|
||||||
// Handle manual restart needed (Conductor / sandboxed apps)
|
|
||||||
if (isManualRestart(result)) {
|
|
||||||
console.log(`\n${result.reason}\n`);
|
|
||||||
console.log(`To connect, FULLY QUIT ${result.browser.name} first (no processes running), then relaunch with CDP:\n`);
|
|
||||||
console.log(` 1. Quit ${result.browser.name} (Cmd+Q)`);
|
|
||||||
console.log(` 2. Wait 3 seconds for all processes to exit`);
|
|
||||||
console.log(` 3. Verify: pgrep -f "${result.browser.appName}" should return nothing`);
|
|
||||||
console.log(` 4. Open Terminal and run:`);
|
|
||||||
console.log(` ${result.command}`);
|
|
||||||
console.log(` 5. Then run: $B connect ${result.browser.name.toLowerCase()}\n`);
|
|
||||||
console.log(`IMPORTANT: Chrome must be fully quit before step 4. If Chrome is already`);
|
|
||||||
console.log(`running, it ignores --remote-debugging-port and opens in the existing session.\n`);
|
|
||||||
console.log(`Pro tip — add to your shell profile to always launch with CDP:`);
|
|
||||||
console.log(` alias chrome-cdp='${result.command}'\n`);
|
|
||||||
|
|
||||||
// Wait and poll — user might restart Chrome while we're printing
|
|
||||||
console.log(`Waiting for CDP on port ${result.port}...`);
|
|
||||||
const pollStart = Date.now();
|
|
||||||
while (Date.now() - pollStart < 60000) {
|
|
||||||
const probe = await isCdpAvailable(result.port);
|
|
||||||
if (probe.available && probe.wsUrl) {
|
|
||||||
console.log(`CDP available! Connecting...`);
|
|
||||||
// Start server with CDP env vars
|
|
||||||
const newState = await startServer({
|
|
||||||
BROWSE_CDP_URL: probe.wsUrl,
|
|
||||||
BROWSE_CDP_PORT: String(result.port),
|
|
||||||
});
|
|
||||||
const resp = await fetch(`http://127.0.0.1:${newState.port}/command`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${newState.token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ command: 'tabs', args: [] }),
|
|
||||||
signal: AbortSignal.timeout(5000),
|
|
||||||
});
|
|
||||||
const tabList = await resp.text();
|
|
||||||
console.log(`Connected to ${result.browser.name} via CDP\n${tabList}`);
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
||||||
process.stdout.write('.');
|
|
||||||
}
|
|
||||||
console.log(`\nTimed out waiting for CDP. Run $B connect again after restarting ${result.browser.name}.`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Found ${result.browser} CDP at port ${result.port}`);
|
|
||||||
|
|
||||||
// Start server with CDP env vars
|
|
||||||
const newState = await startServer({
|
const newState = await startServer({
|
||||||
BROWSE_CDP_URL: result.wsUrl,
|
BROWSE_CDP_URL: 'channel:chrome',
|
||||||
BROWSE_CDP_PORT: String(result.port),
|
BROWSE_CDP_PORT: '0',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Print connected status
|
// Print connected status
|
||||||
@@ -445,11 +377,11 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Authorization': `Bearer ${newState.token}`,
|
'Authorization': `Bearer ${newState.token}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ command: 'tabs', args: [] }),
|
body: JSON.stringify({ command: 'status', args: [] }),
|
||||||
signal: AbortSignal.timeout(5000),
|
signal: AbortSignal.timeout(5000),
|
||||||
});
|
});
|
||||||
const tabList = await resp.text();
|
const status = await resp.text();
|
||||||
console.log(`Connected to ${result.browser} via CDP\n${tabList}`);
|
console.log(`Connected to real Chrome\n${status}`);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error(`[browse] Connect failed: ${err.message}`);
|
console.error(`[browse] Connect failed: ${err.message}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -334,12 +334,12 @@ async function start() {
|
|||||||
|
|
||||||
const port = await findPort();
|
const port = await findPort();
|
||||||
|
|
||||||
// Launch browser (or connect to existing via CDP)
|
// Launch browser (headless or real Chrome)
|
||||||
const cdpUrl = process.env.BROWSE_CDP_URL;
|
const cdpUrl = process.env.BROWSE_CDP_URL;
|
||||||
const cdpPort = parseInt(process.env.BROWSE_CDP_PORT || '0', 10);
|
const cdpPort = parseInt(process.env.BROWSE_CDP_PORT || '0', 10);
|
||||||
if (cdpUrl) {
|
if (cdpUrl) {
|
||||||
await browserManager.connectCDP(cdpUrl, cdpPort);
|
await browserManager.connectCDP(cdpUrl, cdpPort);
|
||||||
console.log(`[browse] Connected to real browser via CDP (port ${cdpPort})`);
|
console.log(`[browse] Launched real Chrome browser (headed)`);
|
||||||
} else {
|
} else {
|
||||||
await browserManager.launch();
|
await browserManager.launch();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user