mirror of
https://github.com/ArchiveBox/ArchiveBox.git
synced 2026-01-03 09:25:42 +10:00
1934 lines
68 KiB
JavaScript
Executable File
1934 lines
68 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
||
/**
|
||
* Chrome Extension Management Utilities
|
||
*
|
||
* Handles downloading, installing, and managing Chrome extensions for browser automation.
|
||
* Ported from the TypeScript implementation in archivebox.ts
|
||
*/
|
||
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const crypto = require('crypto');
|
||
const http = require('http');
|
||
const net = require('net');
|
||
const { exec, spawn } = require('child_process');
|
||
const { promisify } = require('util');
|
||
const { Readable } = require('stream');
|
||
const { finished } = require('stream/promises');
|
||
|
||
const execAsync = promisify(exec);
|
||
|
||
// ============================================================================
|
||
// Environment helpers
|
||
// ============================================================================
|
||
|
||
/**
|
||
* Get environment variable with default value.
|
||
* @param {string} name - Environment variable name
|
||
* @param {string} [defaultValue=''] - Default value if not set
|
||
* @returns {string} - Trimmed environment variable value
|
||
*/
|
||
function getEnv(name, defaultValue = '') {
|
||
return (process.env[name] || defaultValue).trim();
|
||
}
|
||
|
||
/**
|
||
* Get boolean environment variable.
|
||
* @param {string} name - Environment variable name
|
||
* @param {boolean} [defaultValue=false] - Default value if not set
|
||
* @returns {boolean} - Boolean value
|
||
*/
|
||
function getEnvBool(name, defaultValue = false) {
|
||
const val = getEnv(name, '').toLowerCase();
|
||
if (['true', '1', 'yes', 'on'].includes(val)) return true;
|
||
if (['false', '0', 'no', 'off'].includes(val)) return false;
|
||
return defaultValue;
|
||
}
|
||
|
||
/**
|
||
* Get integer environment variable.
|
||
* @param {string} name - Environment variable name
|
||
* @param {number} [defaultValue=0] - Default value if not set
|
||
* @returns {number} - Integer value
|
||
*/
|
||
function getEnvInt(name, defaultValue = 0) {
|
||
const val = parseInt(getEnv(name, String(defaultValue)), 10);
|
||
return isNaN(val) ? defaultValue : val;
|
||
}
|
||
|
||
/**
|
||
* Get array environment variable (JSON array or comma-separated string).
|
||
*
|
||
* Parsing strategy:
|
||
* - If value starts with '[', parse as JSON array
|
||
* - Otherwise, parse as comma-separated values
|
||
*
|
||
* This prevents incorrect splitting of arguments that contain internal commas.
|
||
* For arguments with commas, use JSON format:
|
||
* CHROME_ARGS='["--user-data-dir=/path/with,comma", "--window-size=1440,900"]'
|
||
*
|
||
* @param {string} name - Environment variable name
|
||
* @param {string[]} [defaultValue=[]] - Default value if not set
|
||
* @returns {string[]} - Array of strings
|
||
*/
|
||
function getEnvArray(name, defaultValue = []) {
|
||
const val = getEnv(name, '');
|
||
if (!val) return defaultValue;
|
||
|
||
// If starts with '[', parse as JSON array
|
||
if (val.startsWith('[')) {
|
||
try {
|
||
const parsed = JSON.parse(val);
|
||
if (Array.isArray(parsed)) return parsed;
|
||
} catch (e) {
|
||
console.error(`[!] Failed to parse ${name} as JSON array: ${e.message}`);
|
||
// Fall through to comma-separated parsing
|
||
}
|
||
}
|
||
|
||
// Parse as comma-separated values
|
||
return val.split(',').map(s => s.trim()).filter(Boolean);
|
||
}
|
||
|
||
/**
|
||
* Parse resolution string into width/height.
|
||
* @param {string} resolution - Resolution string like "1440,2000"
|
||
* @returns {{width: number, height: number}} - Parsed dimensions
|
||
*/
|
||
function parseResolution(resolution) {
|
||
const [width, height] = resolution.split(',').map(x => parseInt(x.trim(), 10));
|
||
return { width: width || 1440, height: height || 2000 };
|
||
}
|
||
|
||
// ============================================================================
|
||
// PID file management
|
||
// ============================================================================
|
||
|
||
/**
|
||
* Write PID file with specific mtime for process validation.
|
||
* @param {string} filePath - Path to PID file
|
||
* @param {number} pid - Process ID
|
||
* @param {number} startTimeSeconds - Process start time in seconds
|
||
*/
|
||
function writePidWithMtime(filePath, pid, startTimeSeconds) {
|
||
fs.writeFileSync(filePath, String(pid));
|
||
const startTimeMs = startTimeSeconds * 1000;
|
||
fs.utimesSync(filePath, new Date(startTimeMs), new Date(startTimeMs));
|
||
}
|
||
|
||
/**
|
||
* Write a shell script that can re-run the Chrome command.
|
||
* @param {string} filePath - Path to script file
|
||
* @param {string} binary - Chrome binary path
|
||
* @param {string[]} args - Chrome arguments
|
||
*/
|
||
function writeCmdScript(filePath, binary, args) {
|
||
const escape = (arg) =>
|
||
arg.includes(' ') || arg.includes('"') || arg.includes('$')
|
||
? `"${arg.replace(/"/g, '\\"')}"`
|
||
: arg;
|
||
fs.writeFileSync(
|
||
filePath,
|
||
`#!/bin/bash\n${binary} ${args.map(escape).join(' ')}\n`
|
||
);
|
||
fs.chmodSync(filePath, 0o755);
|
||
}
|
||
|
||
// ============================================================================
|
||
// Port management
|
||
// ============================================================================
|
||
|
||
/**
|
||
* Find a free port on localhost.
|
||
* @returns {Promise<number>} - Available port number
|
||
*/
|
||
function findFreePort() {
|
||
return new Promise((resolve, reject) => {
|
||
const server = net.createServer();
|
||
server.unref();
|
||
server.on('error', reject);
|
||
server.listen(0, () => {
|
||
const port = server.address().port;
|
||
server.close(() => resolve(port));
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Wait for Chrome's DevTools port to be ready.
|
||
* @param {number} port - Debug port number
|
||
* @param {number} [timeout=30000] - Timeout in milliseconds
|
||
* @returns {Promise<Object>} - Chrome version info
|
||
*/
|
||
function waitForDebugPort(port, timeout = 30000) {
|
||
const startTime = Date.now();
|
||
|
||
return new Promise((resolve, reject) => {
|
||
const tryConnect = () => {
|
||
if (Date.now() - startTime > timeout) {
|
||
reject(new Error(`Timeout waiting for Chrome debug port ${port}`));
|
||
return;
|
||
}
|
||
|
||
const req = http.get(`http://127.0.0.1:${port}/json/version`, (res) => {
|
||
let data = '';
|
||
res.on('data', (chunk) => (data += chunk));
|
||
res.on('end', () => {
|
||
try {
|
||
const info = JSON.parse(data);
|
||
resolve(info);
|
||
} catch (e) {
|
||
setTimeout(tryConnect, 100);
|
||
}
|
||
});
|
||
});
|
||
|
||
req.on('error', () => {
|
||
setTimeout(tryConnect, 100);
|
||
});
|
||
|
||
req.setTimeout(1000, () => {
|
||
req.destroy();
|
||
setTimeout(tryConnect, 100);
|
||
});
|
||
};
|
||
|
||
tryConnect();
|
||
});
|
||
}
|
||
|
||
// ============================================================================
|
||
// Zombie process cleanup
|
||
// ============================================================================
|
||
|
||
/**
|
||
* Kill zombie Chrome processes from stale crawls.
|
||
* Recursively scans DATA_DIR for any .../chrome/...pid files from stale crawls.
|
||
* Does not assume specific directory structure - works with nested paths.
|
||
* @param {string} [dataDir] - Data directory (defaults to DATA_DIR env or '.')
|
||
* @returns {number} - Number of zombies killed
|
||
*/
|
||
function killZombieChrome(dataDir = null) {
|
||
dataDir = dataDir || getEnv('DATA_DIR', '.');
|
||
const now = Date.now();
|
||
const fiveMinutesAgo = now - 300000;
|
||
let killed = 0;
|
||
|
||
console.error('[*] Checking for zombie Chrome processes...');
|
||
|
||
if (!fs.existsSync(dataDir)) {
|
||
console.error('[+] No data directory found');
|
||
return 0;
|
||
}
|
||
|
||
/**
|
||
* Recursively find all chrome/.pid files in directory tree
|
||
* @param {string} dir - Directory to search
|
||
* @param {number} depth - Current recursion depth (limit to 10)
|
||
* @returns {Array<{pidFile: string, crawlDir: string}>} - Array of PID file info
|
||
*/
|
||
function findChromePidFiles(dir, depth = 0) {
|
||
if (depth > 10) return []; // Prevent infinite recursion
|
||
|
||
const results = [];
|
||
try {
|
||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||
|
||
for (const entry of entries) {
|
||
if (!entry.isDirectory()) continue;
|
||
|
||
const fullPath = path.join(dir, entry.name);
|
||
|
||
// Found a chrome directory - check for .pid files
|
||
if (entry.name === 'chrome') {
|
||
try {
|
||
const pidFiles = fs.readdirSync(fullPath).filter(f => f.endsWith('.pid'));
|
||
const crawlDir = dir; // Parent of chrome/ is the crawl dir
|
||
|
||
for (const pidFileName of pidFiles) {
|
||
results.push({
|
||
pidFile: path.join(fullPath, pidFileName),
|
||
crawlDir: crawlDir,
|
||
});
|
||
}
|
||
} catch (e) {
|
||
// Skip if can't read chrome dir
|
||
}
|
||
} else {
|
||
// Recurse into subdirectory (skip hidden dirs and node_modules)
|
||
if (!entry.name.startsWith('.') && entry.name !== 'node_modules') {
|
||
results.push(...findChromePidFiles(fullPath, depth + 1));
|
||
}
|
||
}
|
||
}
|
||
} catch (e) {
|
||
// Skip if can't read directory
|
||
}
|
||
return results;
|
||
}
|
||
|
||
try {
|
||
const chromePids = findChromePidFiles(dataDir);
|
||
|
||
for (const {pidFile, crawlDir} of chromePids) {
|
||
// Check if crawl was modified recently (still active)
|
||
try {
|
||
const crawlStats = fs.statSync(crawlDir);
|
||
if (crawlStats.mtimeMs > fiveMinutesAgo) {
|
||
continue; // Crawl is active, skip
|
||
}
|
||
} catch (e) {
|
||
continue;
|
||
}
|
||
|
||
// Crawl is stale, check PID
|
||
try {
|
||
const pid = parseInt(fs.readFileSync(pidFile, 'utf8').trim(), 10);
|
||
if (isNaN(pid) || pid <= 0) continue;
|
||
|
||
// Check if process exists
|
||
try {
|
||
process.kill(pid, 0);
|
||
} catch (e) {
|
||
// Process dead, remove stale PID file
|
||
try { fs.unlinkSync(pidFile); } catch (e) {}
|
||
continue;
|
||
}
|
||
|
||
// Process alive and crawl is stale - zombie!
|
||
console.error(`[!] Found zombie (PID ${pid}) from stale crawl ${path.basename(crawlDir)}`);
|
||
|
||
try {
|
||
try { process.kill(-pid, 'SIGKILL'); } catch (e) { process.kill(pid, 'SIGKILL'); }
|
||
killed++;
|
||
console.error(`[+] Killed zombie (PID ${pid})`);
|
||
try { fs.unlinkSync(pidFile); } catch (e) {}
|
||
} catch (e) {
|
||
console.error(`[!] Failed to kill PID ${pid}: ${e.message}`);
|
||
}
|
||
} catch (e) {
|
||
// Skip invalid PID files
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error(`[!] Error scanning for Chrome processes: ${e.message}`);
|
||
}
|
||
|
||
if (killed > 0) {
|
||
console.error(`[+] Killed ${killed} zombie process(es)`);
|
||
} else {
|
||
console.error('[+] No zombies found');
|
||
}
|
||
|
||
// Clean up stale SingletonLock files from persona chrome_user_data directories
|
||
const personasDir = path.join(dataDir, 'personas');
|
||
if (fs.existsSync(personasDir)) {
|
||
try {
|
||
const personas = fs.readdirSync(personasDir, { withFileTypes: true });
|
||
for (const persona of personas) {
|
||
if (!persona.isDirectory()) continue;
|
||
|
||
const userDataDir = path.join(personasDir, persona.name, 'chrome_user_data');
|
||
const singletonLock = path.join(userDataDir, 'SingletonLock');
|
||
|
||
if (fs.existsSync(singletonLock)) {
|
||
try {
|
||
fs.unlinkSync(singletonLock);
|
||
console.error(`[+] Removed stale SingletonLock: ${singletonLock}`);
|
||
} catch (e) {
|
||
// Ignore - may be in use by active Chrome
|
||
}
|
||
}
|
||
}
|
||
} catch (e) {
|
||
// Ignore errors scanning personas directory
|
||
}
|
||
}
|
||
|
||
return killed;
|
||
}
|
||
|
||
// ============================================================================
|
||
// Chrome launching
|
||
// ============================================================================
|
||
|
||
/**
|
||
* Launch Chromium with extensions and return connection info.
|
||
*
|
||
* @param {Object} options - Launch options
|
||
* @param {string} [options.binary] - Chrome binary path (auto-detected if not provided)
|
||
* @param {string} [options.outputDir='chrome'] - Directory for output files
|
||
* @param {string} [options.userDataDir] - Chrome user data directory for persistent sessions
|
||
* @param {string} [options.resolution='1440,2000'] - Window resolution
|
||
* @param {boolean} [options.headless=true] - Run in headless mode
|
||
* @param {boolean} [options.sandbox=true] - Enable Chrome sandbox
|
||
* @param {boolean} [options.checkSsl=true] - Check SSL certificates
|
||
* @param {string[]} [options.extensionPaths=[]] - Paths to unpacked extensions
|
||
* @param {boolean} [options.killZombies=true] - Kill zombie processes first
|
||
* @returns {Promise<Object>} - {success, cdpUrl, pid, port, process, error}
|
||
*/
|
||
async function launchChromium(options = {}) {
|
||
const {
|
||
binary = findChromium(),
|
||
outputDir = 'chrome',
|
||
userDataDir = getEnv('CHROME_USER_DATA_DIR'),
|
||
resolution = getEnv('CHROME_RESOLUTION') || getEnv('RESOLUTION', '1440,2000'),
|
||
headless = getEnvBool('CHROME_HEADLESS', true),
|
||
sandbox = getEnvBool('CHROME_SANDBOX', true),
|
||
checkSsl = getEnvBool('CHROME_CHECK_SSL_VALIDITY', getEnvBool('CHECK_SSL_VALIDITY', true)),
|
||
extensionPaths = [],
|
||
killZombies = true,
|
||
} = options;
|
||
|
||
if (!binary) {
|
||
return { success: false, error: 'Chrome binary not found' };
|
||
}
|
||
|
||
// Kill zombies first
|
||
if (killZombies) {
|
||
killZombieChrome();
|
||
}
|
||
|
||
const { width, height } = parseResolution(resolution);
|
||
|
||
// Create output directory
|
||
if (!fs.existsSync(outputDir)) {
|
||
fs.mkdirSync(outputDir, { recursive: true });
|
||
}
|
||
|
||
// Create user data directory if specified and doesn't exist
|
||
if (userDataDir) {
|
||
if (!fs.existsSync(userDataDir)) {
|
||
fs.mkdirSync(userDataDir, { recursive: true });
|
||
console.error(`[*] Created user data directory: ${userDataDir}`);
|
||
}
|
||
// Clean up any stale SingletonLock file from previous crashed sessions
|
||
const singletonLock = path.join(userDataDir, 'SingletonLock');
|
||
if (fs.existsSync(singletonLock)) {
|
||
try {
|
||
fs.unlinkSync(singletonLock);
|
||
console.error(`[*] Removed stale SingletonLock: ${singletonLock}`);
|
||
} catch (e) {
|
||
console.error(`[!] Failed to remove SingletonLock: ${e.message}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Find a free port
|
||
const debugPort = await findFreePort();
|
||
console.error(`[*] Using debug port: ${debugPort}`);
|
||
|
||
// Get base Chrome args from config (static flags from CHROME_ARGS env var)
|
||
// These come from config.json defaults, merged by get_config() in Python
|
||
const baseArgs = getEnvArray('CHROME_ARGS', []);
|
||
|
||
// Get extra user-provided args
|
||
const extraArgs = getEnvArray('CHROME_ARGS_EXTRA', []);
|
||
|
||
// Build dynamic Chrome arguments (these must be computed at runtime)
|
||
const dynamicArgs = [
|
||
// Remote debugging setup
|
||
`--remote-debugging-port=${debugPort}`,
|
||
'--remote-debugging-address=127.0.0.1',
|
||
|
||
// Sandbox settings (disable in Docker)
|
||
...(sandbox ? [] : ['--no-sandbox', '--disable-setuid-sandbox']),
|
||
|
||
// Docker-specific workarounds
|
||
'--disable-dev-shm-usage',
|
||
'--disable-gpu',
|
||
|
||
// Window size
|
||
`--window-size=${width},${height}`,
|
||
|
||
// User data directory (for persistent sessions with persona)
|
||
...(userDataDir ? [`--user-data-dir=${userDataDir}`] : []),
|
||
|
||
// Headless mode
|
||
...(headless ? ['--headless=new'] : []),
|
||
|
||
// SSL certificate checking
|
||
...(checkSsl ? [] : ['--ignore-certificate-errors']),
|
||
];
|
||
|
||
// Combine all args: base (from config) + dynamic (runtime) + extra (user overrides)
|
||
// Dynamic args come after base so they can override if needed
|
||
const chromiumArgs = [...baseArgs, ...dynamicArgs, ...extraArgs];
|
||
|
||
// Add extension loading flags
|
||
if (extensionPaths.length > 0) {
|
||
const extPathsArg = extensionPaths.join(',');
|
||
chromiumArgs.push(`--load-extension=${extPathsArg}`);
|
||
chromiumArgs.push('--enable-unsafe-extension-debugging');
|
||
chromiumArgs.push('--disable-features=DisableLoadExtensionCommandLineSwitch,ExtensionManifestV2Unsupported,ExtensionManifestV2Disabled');
|
||
console.error(`[*] Loading ${extensionPaths.length} extension(s) via --load-extension`);
|
||
}
|
||
|
||
chromiumArgs.push('about:blank');
|
||
|
||
// Write command script for debugging
|
||
writeCmdScript(path.join(outputDir, 'cmd.sh'), binary, chromiumArgs);
|
||
|
||
try {
|
||
console.error(`[*] Spawning Chromium (headless=${headless})...`);
|
||
const chromiumProcess = spawn(binary, chromiumArgs, {
|
||
stdio: ['ignore', 'pipe', 'pipe'],
|
||
detached: true,
|
||
});
|
||
|
||
const chromePid = chromiumProcess.pid;
|
||
const chromeStartTime = Date.now() / 1000;
|
||
|
||
if (chromePid) {
|
||
console.error(`[*] Chromium spawned (PID: ${chromePid})`);
|
||
writePidWithMtime(path.join(outputDir, 'chrome.pid'), chromePid, chromeStartTime);
|
||
}
|
||
|
||
// Pipe Chrome output to stderr
|
||
chromiumProcess.stdout.on('data', (data) => {
|
||
process.stderr.write(`[chromium:stdout] ${data}`);
|
||
});
|
||
chromiumProcess.stderr.on('data', (data) => {
|
||
process.stderr.write(`[chromium:stderr] ${data}`);
|
||
});
|
||
|
||
// Wait for debug port
|
||
console.error(`[*] Waiting for debug port ${debugPort}...`);
|
||
const versionInfo = await waitForDebugPort(debugPort, 30000);
|
||
const wsUrl = versionInfo.webSocketDebuggerUrl;
|
||
console.error(`[+] Chromium ready: ${wsUrl}`);
|
||
|
||
fs.writeFileSync(path.join(outputDir, 'cdp_url.txt'), wsUrl);
|
||
fs.writeFileSync(path.join(outputDir, 'port.txt'), String(debugPort));
|
||
|
||
return {
|
||
success: true,
|
||
cdpUrl: wsUrl,
|
||
pid: chromePid,
|
||
port: debugPort,
|
||
process: chromiumProcess,
|
||
};
|
||
} catch (e) {
|
||
return { success: false, error: `${e.name}: ${e.message}` };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Check if a process is still running.
|
||
* @param {number} pid - Process ID to check
|
||
* @returns {boolean} - True if process exists
|
||
*/
|
||
function isProcessAlive(pid) {
|
||
try {
|
||
process.kill(pid, 0); // Signal 0 checks existence without killing
|
||
return true;
|
||
} catch (e) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Find all Chrome child processes for a given debug port.
|
||
* @param {number} port - Debug port number
|
||
* @returns {Array<number>} - Array of PIDs
|
||
*/
|
||
function findChromeProcessesByPort(port) {
|
||
const { execSync } = require('child_process');
|
||
const pids = [];
|
||
|
||
try {
|
||
// Find all Chrome processes using this debug port
|
||
const output = execSync(
|
||
`ps aux | grep -i "chrome.*--remote-debugging-port=${port}" | grep -v grep | awk '{print $2}'`,
|
||
{ encoding: 'utf8', timeout: 5000 }
|
||
);
|
||
|
||
for (const line of output.split('\n')) {
|
||
const pid = parseInt(line.trim(), 10);
|
||
if (!isNaN(pid) && pid > 0) {
|
||
pids.push(pid);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
// Command failed or no processes found
|
||
}
|
||
|
||
return pids;
|
||
}
|
||
|
||
/**
|
||
* Kill a Chrome process by PID.
|
||
* Always sends SIGTERM before SIGKILL, then verifies death.
|
||
*
|
||
* @param {number} pid - Process ID to kill
|
||
* @param {string} [outputDir] - Directory containing PID files to clean up
|
||
*/
|
||
async function killChrome(pid, outputDir = null) {
|
||
if (!pid) return;
|
||
|
||
console.error(`[*] Killing Chrome process tree (PID ${pid})...`);
|
||
|
||
// Get debug port for finding child processes
|
||
let debugPort = null;
|
||
if (outputDir) {
|
||
try {
|
||
const portFile = path.join(outputDir, 'port.txt');
|
||
if (fs.existsSync(portFile)) {
|
||
debugPort = parseInt(fs.readFileSync(portFile, 'utf8').trim(), 10);
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
|
||
// Step 1: SIGTERM to process group (graceful shutdown)
|
||
console.error(`[*] Sending SIGTERM to process group -${pid}...`);
|
||
try {
|
||
process.kill(-pid, 'SIGTERM');
|
||
} catch (e) {
|
||
try {
|
||
console.error(`[*] Process group kill failed, trying single process...`);
|
||
process.kill(pid, 'SIGTERM');
|
||
} catch (e2) {
|
||
console.error(`[!] SIGTERM failed: ${e2.message}`);
|
||
}
|
||
}
|
||
|
||
// Step 2: Wait for graceful shutdown
|
||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||
|
||
// Step 3: Check if still alive
|
||
if (!isProcessAlive(pid)) {
|
||
console.error('[+] Chrome process terminated gracefully');
|
||
} else {
|
||
// Step 4: Force kill ENTIRE process group with SIGKILL
|
||
console.error(`[*] Process still alive, sending SIGKILL to process group -${pid}...`);
|
||
try {
|
||
process.kill(-pid, 'SIGKILL'); // Kill entire process group
|
||
} catch (e) {
|
||
console.error(`[!] Process group SIGKILL failed, trying single process: ${e.message}`);
|
||
try {
|
||
process.kill(pid, 'SIGKILL');
|
||
} catch (e2) {
|
||
console.error(`[!] SIGKILL failed: ${e2.message}`);
|
||
}
|
||
}
|
||
|
||
// Step 5: Wait briefly and verify death
|
||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||
|
||
if (isProcessAlive(pid)) {
|
||
console.error(`[!] WARNING: Process ${pid} is unkillable (likely in UNE state)`);
|
||
console.error(`[!] This typically happens when Chrome crashes in kernel syscall`);
|
||
console.error(`[!] Process will remain as zombie until system reboot`);
|
||
console.error(`[!] macOS IOSurface crash creates unkillable processes in UNE state`);
|
||
|
||
// Try one more time to kill the entire process group
|
||
if (debugPort) {
|
||
const relatedPids = findChromeProcessesByPort(debugPort);
|
||
if (relatedPids.length > 1) {
|
||
console.error(`[*] Found ${relatedPids.length} Chrome processes still running on port ${debugPort}`);
|
||
console.error(`[*] Attempting final process group SIGKILL...`);
|
||
|
||
// Try to kill each unique process group we find
|
||
const processGroups = new Set();
|
||
for (const relatedPid of relatedPids) {
|
||
if (relatedPid !== pid) {
|
||
processGroups.add(relatedPid);
|
||
}
|
||
}
|
||
|
||
for (const groupPid of processGroups) {
|
||
try {
|
||
process.kill(-groupPid, 'SIGKILL');
|
||
} catch (e) {}
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
console.error('[+] Chrome process group killed successfully');
|
||
}
|
||
}
|
||
|
||
// Step 8: Clean up PID files
|
||
// Note: hook-specific .pid files are cleaned up by run_hook() and Snapshot.cleanup()
|
||
if (outputDir) {
|
||
try { fs.unlinkSync(path.join(outputDir, 'chrome.pid')); } catch (e) {}
|
||
}
|
||
|
||
console.error('[*] Chrome cleanup completed');
|
||
}
|
||
|
||
/**
|
||
* Install Chromium using @puppeteer/browsers programmatic API.
|
||
* Uses puppeteer's default cache location, returns the binary path.
|
||
*
|
||
* @param {Object} options - Install options
|
||
* @returns {Promise<Object>} - {success, binary, version, error}
|
||
*/
|
||
async function installChromium(options = {}) {
|
||
// Check if CHROME_BINARY is already set and valid
|
||
const configuredBinary = getEnv('CHROME_BINARY');
|
||
if (configuredBinary && fs.existsSync(configuredBinary)) {
|
||
console.error(`[+] Using configured CHROME_BINARY: ${configuredBinary}`);
|
||
return { success: true, binary: configuredBinary, version: null };
|
||
}
|
||
|
||
// Try to load @puppeteer/browsers from NODE_MODULES_DIR or system
|
||
let puppeteerBrowsers;
|
||
try {
|
||
if (process.env.NODE_MODULES_DIR) {
|
||
module.paths.unshift(process.env.NODE_MODULES_DIR);
|
||
}
|
||
puppeteerBrowsers = require('@puppeteer/browsers');
|
||
} catch (e) {
|
||
console.error(`[!] @puppeteer/browsers not found. Install it first with installPuppeteerCore.`);
|
||
return { success: false, error: '@puppeteer/browsers not installed' };
|
||
}
|
||
|
||
console.error(`[*] Installing Chromium via @puppeteer/browsers...`);
|
||
|
||
try {
|
||
const result = await puppeteerBrowsers.install({
|
||
browser: 'chromium',
|
||
buildId: 'latest',
|
||
});
|
||
|
||
const binary = result.executablePath;
|
||
const version = result.buildId;
|
||
|
||
if (!binary || !fs.existsSync(binary)) {
|
||
console.error(`[!] Chromium binary not found at: ${binary}`);
|
||
return { success: false, error: `Chromium binary not found at: ${binary}` };
|
||
}
|
||
|
||
console.error(`[+] Chromium installed: ${binary}`);
|
||
return { success: true, binary, version };
|
||
} catch (e) {
|
||
console.error(`[!] Failed to install Chromium: ${e.message}`);
|
||
return { success: false, error: e.message };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Install puppeteer-core npm package.
|
||
*
|
||
* @param {Object} options - Install options
|
||
* @param {string} [options.npmPrefix] - npm prefix directory (default: DATA_DIR/lib/<arch>/npm or ./node_modules parent)
|
||
* @param {number} [options.timeout=60000] - Timeout in milliseconds
|
||
* @returns {Promise<Object>} - {success, path, error}
|
||
*/
|
||
async function installPuppeteerCore(options = {}) {
|
||
const arch = `${process.arch}-${process.platform}`;
|
||
const defaultPrefix = path.join(getEnv('LIB_DIR', getEnv('DATA_DIR', '.')), 'npm');
|
||
const {
|
||
npmPrefix = defaultPrefix,
|
||
timeout = 60000,
|
||
} = options;
|
||
|
||
const nodeModulesDir = path.join(npmPrefix, 'node_modules');
|
||
const puppeteerPath = path.join(nodeModulesDir, 'puppeteer-core');
|
||
|
||
// Check if already installed
|
||
if (fs.existsSync(puppeteerPath)) {
|
||
console.error(`[+] puppeteer-core already installed: ${puppeteerPath}`);
|
||
return { success: true, path: puppeteerPath };
|
||
}
|
||
|
||
console.error(`[*] Installing puppeteer-core to ${npmPrefix}...`);
|
||
|
||
// Create directory
|
||
if (!fs.existsSync(npmPrefix)) {
|
||
fs.mkdirSync(npmPrefix, { recursive: true });
|
||
}
|
||
|
||
try {
|
||
const { execSync } = require('child_process');
|
||
execSync(
|
||
`npm install --prefix "${npmPrefix}" puppeteer-core`,
|
||
{ encoding: 'utf8', timeout, stdio: ['pipe', 'pipe', 'pipe'] }
|
||
);
|
||
console.error(`[+] puppeteer-core installed successfully`);
|
||
return { success: true, path: puppeteerPath };
|
||
} catch (e) {
|
||
console.error(`[!] Failed to install puppeteer-core: ${e.message}`);
|
||
return { success: false, error: e.message };
|
||
}
|
||
}
|
||
|
||
// Try to import unzipper, fallback to system unzip if not available
|
||
let unzip = null;
|
||
try {
|
||
const unzipper = require('unzipper');
|
||
unzip = async (sourcePath, destPath) => {
|
||
const stream = fs.createReadStream(sourcePath).pipe(unzipper.Extract({ path: destPath }));
|
||
return stream.promise();
|
||
};
|
||
} catch (err) {
|
||
// Will use system unzip command as fallback
|
||
}
|
||
|
||
/**
|
||
* Compute the extension ID from the unpacked path.
|
||
* Chrome uses a SHA256 hash of the unpacked extension directory path to compute a dynamic id.
|
||
*
|
||
* @param {string} unpacked_path - Path to the unpacked extension directory
|
||
* @returns {string} - 32-character extension ID
|
||
*/
|
||
function getExtensionId(unpacked_path) {
|
||
// Chrome uses a SHA256 hash of the unpacked extension directory path
|
||
const hash = crypto.createHash('sha256');
|
||
hash.update(Buffer.from(unpacked_path, 'utf-8'));
|
||
|
||
// Convert first 32 hex chars to characters in the range 'a'-'p'
|
||
const detected_extension_id = Array.from(hash.digest('hex'))
|
||
.slice(0, 32)
|
||
.map(i => String.fromCharCode(parseInt(i, 16) + 'a'.charCodeAt(0)))
|
||
.join('');
|
||
|
||
return detected_extension_id;
|
||
}
|
||
|
||
/**
|
||
* Download and install a Chrome extension from the Chrome Web Store.
|
||
*
|
||
* @param {Object} extension - Extension metadata object
|
||
* @param {string} extension.webstore_id - Chrome Web Store extension ID
|
||
* @param {string} extension.name - Human-readable extension name
|
||
* @param {string} extension.crx_url - URL to download the CRX file
|
||
* @param {string} extension.crx_path - Local path to save the CRX file
|
||
* @param {string} extension.unpacked_path - Path to extract the extension
|
||
* @returns {Promise<boolean>} - True if installation succeeded
|
||
*/
|
||
async function installExtension(extension) {
|
||
const manifest_path = path.join(extension.unpacked_path, 'manifest.json');
|
||
|
||
// Download CRX file if not already downloaded
|
||
if (!fs.existsSync(manifest_path) && !fs.existsSync(extension.crx_path)) {
|
||
console.log(`[🛠️] Downloading missing extension ${extension.name} ${extension.webstore_id} -> ${extension.crx_path}`);
|
||
|
||
try {
|
||
// Ensure parent directory exists
|
||
const crxDir = path.dirname(extension.crx_path);
|
||
if (!fs.existsSync(crxDir)) {
|
||
fs.mkdirSync(crxDir, { recursive: true });
|
||
}
|
||
|
||
// Download CRX file from Chrome Web Store
|
||
const response = await fetch(extension.crx_url);
|
||
|
||
if (!response.ok) {
|
||
console.warn(`[⚠️] Failed to download extension ${extension.name}: HTTP ${response.status}`);
|
||
return false;
|
||
}
|
||
|
||
if (response.body) {
|
||
const crx_file = fs.createWriteStream(extension.crx_path);
|
||
const crx_stream = Readable.fromWeb(response.body);
|
||
await finished(crx_stream.pipe(crx_file));
|
||
} else {
|
||
console.warn(`[⚠️] Failed to download extension ${extension.name}: No response body`);
|
||
return false;
|
||
}
|
||
} catch (err) {
|
||
console.error(`[❌] Failed to download extension ${extension.name}:`, err);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// Unzip CRX file to unpacked_path (CRX files have extra header bytes but unzip handles it)
|
||
await fs.promises.mkdir(extension.unpacked_path, { recursive: true });
|
||
|
||
try {
|
||
// Use -q to suppress warnings about extra bytes in CRX header
|
||
await execAsync(`/usr/bin/unzip -q -o "${extension.crx_path}" -d "${extension.unpacked_path}"`);
|
||
} catch (err1) {
|
||
// unzip may return non-zero even on success due to CRX header warning, check if manifest exists
|
||
if (!fs.existsSync(manifest_path)) {
|
||
if (unzip) {
|
||
// Fallback to unzipper library
|
||
try {
|
||
await unzip(extension.crx_path, extension.unpacked_path);
|
||
} catch (err2) {
|
||
console.error(`[❌] Failed to unzip ${extension.crx_path}:`, err2.message);
|
||
return false;
|
||
}
|
||
} else {
|
||
console.error(`[❌] Failed to unzip ${extension.crx_path}:`, err1.message);
|
||
return false;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!fs.existsSync(manifest_path)) {
|
||
console.error(`[❌] Failed to install ${extension.crx_path}: could not find manifest.json in unpacked_path`);
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Load or install a Chrome extension, computing all metadata.
|
||
*
|
||
* @param {Object} ext - Partial extension metadata (at minimum: webstore_id or unpacked_path)
|
||
* @param {string} [ext.webstore_id] - Chrome Web Store extension ID
|
||
* @param {string} [ext.name] - Human-readable extension name
|
||
* @param {string} [ext.unpacked_path] - Path to unpacked extension
|
||
* @param {string} [extensions_dir] - Directory to store extensions
|
||
* @returns {Promise<Object>} - Complete extension metadata object
|
||
*/
|
||
async function loadOrInstallExtension(ext, extensions_dir = null) {
|
||
if (!(ext.webstore_id || ext.unpacked_path)) {
|
||
throw new Error('Extension must have either {webstore_id} or {unpacked_path}');
|
||
}
|
||
|
||
// Determine extensions directory
|
||
// Use provided dir, or fall back to getExtensionsDir() which handles env vars and defaults
|
||
const EXTENSIONS_DIR = extensions_dir || getExtensionsDir();
|
||
|
||
// Set statically computable extension metadata
|
||
ext.webstore_id = ext.webstore_id || ext.id;
|
||
ext.name = ext.name || ext.webstore_id;
|
||
ext.webstore_url = ext.webstore_url || `https://chromewebstore.google.com/detail/${ext.webstore_id}`;
|
||
ext.crx_url = ext.crx_url || `https://clients2.google.com/service/update2/crx?response=redirect&prodversion=1230&acceptformat=crx3&x=id%3D${ext.webstore_id}%26uc`;
|
||
ext.crx_path = ext.crx_path || path.join(EXTENSIONS_DIR, `${ext.webstore_id}__${ext.name}.crx`);
|
||
ext.unpacked_path = ext.unpacked_path || path.join(EXTENSIONS_DIR, `${ext.webstore_id}__${ext.name}`);
|
||
|
||
const manifest_path = path.join(ext.unpacked_path, 'manifest.json');
|
||
ext.read_manifest = () => JSON.parse(fs.readFileSync(manifest_path, 'utf-8'));
|
||
ext.read_version = () => fs.existsSync(manifest_path) && ext.read_manifest()?.version || null;
|
||
|
||
// If extension is not installed, download and unpack it
|
||
if (!ext.read_version()) {
|
||
await installExtension(ext);
|
||
}
|
||
|
||
// Autodetect ID from filesystem path (unpacked extensions don't have stable IDs)
|
||
ext.id = getExtensionId(ext.unpacked_path);
|
||
ext.version = ext.read_version();
|
||
|
||
if (!ext.version) {
|
||
console.warn(`[❌] Unable to detect ID and version of installed extension ${ext.unpacked_path}`);
|
||
} else {
|
||
console.log(`[➕] Installed extension ${ext.name} (${ext.version})... ${ext.unpacked_path}`);
|
||
}
|
||
|
||
return ext;
|
||
}
|
||
|
||
/**
|
||
* Check if a Puppeteer target is an extension background page/service worker.
|
||
*
|
||
* @param {Object} target - Puppeteer target object
|
||
* @returns {Promise<Object>} - Object with target_is_bg, extension_id, manifest_version, etc.
|
||
*/
|
||
async function isTargetExtension(target) {
|
||
let target_type;
|
||
let target_ctx;
|
||
let target_url;
|
||
|
||
try {
|
||
target_type = target.type();
|
||
target_ctx = (await target.worker()) || (await target.page()) || null;
|
||
target_url = target.url() || target_ctx?.url() || null;
|
||
} catch (err) {
|
||
if (String(err).includes('No target with given id found')) {
|
||
// Target closed during check, ignore harmless race condition
|
||
target_type = 'closed';
|
||
target_ctx = null;
|
||
target_url = 'about:closed';
|
||
} else {
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
// Check if this is an extension background page or service worker
|
||
const is_chrome_extension = target_url?.startsWith('chrome-extension://');
|
||
const is_background_page = target_type === 'background_page';
|
||
const is_service_worker = target_type === 'service_worker';
|
||
const target_is_bg = is_chrome_extension && (is_background_page || is_service_worker);
|
||
|
||
let extension_id = null;
|
||
let manifest_version = null;
|
||
const target_is_extension = is_chrome_extension || target_is_bg;
|
||
|
||
if (target_is_extension) {
|
||
try {
|
||
extension_id = target_url?.split('://')[1]?.split('/')[0] || null;
|
||
|
||
if (target_ctx) {
|
||
const manifest = await target_ctx.evaluate(() => chrome.runtime.getManifest());
|
||
manifest_version = manifest?.manifest_version || null;
|
||
}
|
||
} catch (err) {
|
||
// Failed to get extension metadata
|
||
}
|
||
}
|
||
|
||
return {
|
||
target_is_extension,
|
||
target_is_bg,
|
||
target_type,
|
||
target_ctx,
|
||
target_url,
|
||
extension_id,
|
||
manifest_version,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Load extension metadata and connection handlers from a browser target.
|
||
*
|
||
* @param {Array} extensions - Array of extension metadata objects to update
|
||
* @param {Object} target - Puppeteer target object
|
||
* @returns {Promise<Object|null>} - Updated extension object or null if not an extension
|
||
*/
|
||
async function loadExtensionFromTarget(extensions, target) {
|
||
const {
|
||
target_is_bg,
|
||
target_is_extension,
|
||
target_type,
|
||
target_ctx,
|
||
target_url,
|
||
extension_id,
|
||
manifest_version,
|
||
} = await isTargetExtension(target);
|
||
|
||
if (!(target_is_bg && extension_id && target_ctx)) {
|
||
return null;
|
||
}
|
||
|
||
// Find matching extension in our list
|
||
const extension = extensions.find(ext => ext.id === extension_id);
|
||
if (!extension) {
|
||
console.warn(`[⚠️] Found loaded extension ${extension_id} that's not in CHROME_EXTENSIONS list`);
|
||
return null;
|
||
}
|
||
|
||
// Load manifest from the extension context
|
||
let manifest = null;
|
||
try {
|
||
manifest = await target_ctx.evaluate(() => chrome.runtime.getManifest());
|
||
} catch (err) {
|
||
console.error(`[❌] Failed to read manifest for extension ${extension_id}:`, err);
|
||
return null;
|
||
}
|
||
|
||
// Create dispatch methods for communicating with the extension
|
||
const new_extension = {
|
||
...extension,
|
||
target,
|
||
target_type,
|
||
target_url,
|
||
manifest,
|
||
manifest_version,
|
||
|
||
// Trigger extension toolbar button click
|
||
dispatchAction: async (tab) => {
|
||
return await target_ctx.evaluate((tabId) => {
|
||
return new Promise((resolve) => {
|
||
chrome.action.onClicked.addListener((tab) => {
|
||
resolve({ success: true, tab });
|
||
});
|
||
chrome.action.openPopup();
|
||
});
|
||
}, tab?.id || null);
|
||
},
|
||
|
||
// Send message to extension
|
||
dispatchMessage: async (message, options = {}) => {
|
||
return await target_ctx.evaluate((msg, opts) => {
|
||
return new Promise((resolve) => {
|
||
chrome.runtime.sendMessage(msg, opts, (response) => {
|
||
resolve(response);
|
||
});
|
||
});
|
||
}, message, options);
|
||
},
|
||
|
||
// Trigger extension command (keyboard shortcut)
|
||
dispatchCommand: async (command) => {
|
||
return await target_ctx.evaluate((cmd) => {
|
||
return new Promise((resolve) => {
|
||
chrome.commands.onCommand.addListener((receivedCommand) => {
|
||
if (receivedCommand === cmd) {
|
||
resolve({ success: true, command: receivedCommand });
|
||
}
|
||
});
|
||
// Note: Actually triggering commands programmatically is not directly supported
|
||
// This would need to be done via CDP or keyboard simulation
|
||
});
|
||
}, command);
|
||
},
|
||
};
|
||
|
||
// Update the extension in the array
|
||
Object.assign(extension, new_extension);
|
||
|
||
console.log(`[🔌] Connected to extension ${extension.name} (${extension.version})`);
|
||
|
||
return new_extension;
|
||
}
|
||
|
||
/**
|
||
* Install all extensions in the list if not already installed.
|
||
*
|
||
* @param {Array} extensions - Array of extension metadata objects
|
||
* @param {string} [extensions_dir] - Directory to store extensions
|
||
* @returns {Promise<Array>} - Array of installed extension objects
|
||
*/
|
||
async function installAllExtensions(extensions, extensions_dir = null) {
|
||
console.log(`[⚙️] Installing ${extensions.length} chrome extensions...`);
|
||
|
||
for (const extension of extensions) {
|
||
await loadOrInstallExtension(extension, extensions_dir);
|
||
}
|
||
|
||
return extensions;
|
||
}
|
||
|
||
/**
|
||
* Load and connect to all extensions from a running browser.
|
||
*
|
||
* @param {Object} browser - Puppeteer browser instance
|
||
* @param {Array} extensions - Array of extension metadata objects
|
||
* @returns {Promise<Array>} - Array of loaded extension objects with connection handlers
|
||
*/
|
||
async function loadAllExtensionsFromBrowser(browser, extensions) {
|
||
console.log(`[⚙️] Loading ${extensions.length} chrome extensions from browser...`);
|
||
|
||
// Find loaded extensions at runtime by examining browser targets
|
||
for (const target of browser.targets()) {
|
||
await loadExtensionFromTarget(extensions, target);
|
||
}
|
||
|
||
return extensions;
|
||
}
|
||
|
||
/**
|
||
* Load extension manifest.json file
|
||
*
|
||
* @param {string} unpacked_path - Path to unpacked extension directory
|
||
* @returns {object|null} - Parsed manifest object or null if not found/invalid
|
||
*/
|
||
function loadExtensionManifest(unpacked_path) {
|
||
const manifest_path = path.join(unpacked_path, 'manifest.json');
|
||
|
||
if (!fs.existsSync(manifest_path)) {
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
const manifest_content = fs.readFileSync(manifest_path, 'utf-8');
|
||
return JSON.parse(manifest_content);
|
||
} catch (error) {
|
||
// Invalid JSON or read error
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @deprecated Use puppeteer's enableExtensions option instead.
|
||
*
|
||
* Generate Chrome launch arguments for loading extensions.
|
||
* NOTE: This is deprecated. Use puppeteer.launch({ pipe: true, enableExtensions: [paths] }) instead.
|
||
*
|
||
* @param {Array} extensions - Array of extension metadata objects
|
||
* @returns {Array<string>} - Chrome CLI arguments for loading extensions
|
||
*/
|
||
function getExtensionLaunchArgs(extensions) {
|
||
console.warn('[DEPRECATED] getExtensionLaunchArgs is deprecated. Use puppeteer enableExtensions option instead.');
|
||
if (!extensions || extensions.length === 0) {
|
||
return [];
|
||
}
|
||
|
||
// Filter out extensions without unpacked_path first
|
||
const validExtensions = extensions.filter(ext => ext.unpacked_path);
|
||
|
||
const unpacked_paths = validExtensions.map(ext => ext.unpacked_path);
|
||
// Use computed id (from path hash) for allowlisting, as that's what Chrome uses for unpacked extensions
|
||
// Fall back to webstore_id if computed id not available
|
||
const extension_ids = validExtensions.map(ext => ext.id || getExtensionId(ext.unpacked_path));
|
||
|
||
return [
|
||
`--load-extension=${unpacked_paths.join(',')}`,
|
||
`--allowlisted-extension-id=${extension_ids.join(',')}`,
|
||
'--allow-legacy-extension-manifests',
|
||
'--disable-extensions-auto-update',
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Get extension paths for use with puppeteer's enableExtensions option.
|
||
* Following puppeteer best practices: https://pptr.dev/guides/chrome-extensions
|
||
*
|
||
* @param {Array} extensions - Array of extension metadata objects
|
||
* @returns {Array<string>} - Array of extension unpacked paths
|
||
*/
|
||
function getExtensionPaths(extensions) {
|
||
if (!extensions || extensions.length === 0) {
|
||
return [];
|
||
}
|
||
return extensions
|
||
.filter(ext => ext.unpacked_path)
|
||
.map(ext => ext.unpacked_path);
|
||
}
|
||
|
||
/**
|
||
* Wait for an extension target to be available in the browser.
|
||
* Following puppeteer best practices for accessing extension contexts.
|
||
*
|
||
* For Manifest V3 extensions (service workers):
|
||
* const worker = await waitForExtensionTarget(browser, extensionId);
|
||
* // worker is a WebWorker context
|
||
*
|
||
* For Manifest V2 extensions (background pages):
|
||
* const page = await waitForExtensionTarget(browser, extensionId);
|
||
* // page is a Page context
|
||
*
|
||
* @param {Object} browser - Puppeteer browser instance
|
||
* @param {string} extensionId - Extension ID to wait for (computed from path hash)
|
||
* @param {number} [timeout=30000] - Timeout in milliseconds
|
||
* @returns {Promise<Object>} - Worker or Page context for the extension
|
||
*/
|
||
async function waitForExtensionTarget(browser, extensionId, timeout = 30000) {
|
||
// Try to find service worker first (Manifest V3)
|
||
try {
|
||
const workerTarget = await browser.waitForTarget(
|
||
target => target.type() === 'service_worker' &&
|
||
target.url().includes(`chrome-extension://${extensionId}`),
|
||
{ timeout }
|
||
);
|
||
const worker = await workerTarget.worker();
|
||
if (worker) return worker;
|
||
} catch (err) {
|
||
// No service worker found, try background page
|
||
}
|
||
|
||
// Try background page (Manifest V2)
|
||
try {
|
||
const backgroundTarget = await browser.waitForTarget(
|
||
target => target.type() === 'background_page' &&
|
||
target.url().includes(`chrome-extension://${extensionId}`),
|
||
{ timeout }
|
||
);
|
||
const page = await backgroundTarget.page();
|
||
if (page) return page;
|
||
} catch (err) {
|
||
// No background page found
|
||
}
|
||
|
||
// Try any extension page as fallback
|
||
const extTarget = await browser.waitForTarget(
|
||
target => target.url().startsWith(`chrome-extension://${extensionId}`),
|
||
{ timeout }
|
||
);
|
||
|
||
// Return worker or page depending on target type
|
||
if (extTarget.type() === 'service_worker') {
|
||
return await extTarget.worker();
|
||
}
|
||
return await extTarget.page();
|
||
}
|
||
|
||
/**
|
||
* Get all loaded extension targets from a browser.
|
||
*
|
||
* @param {Object} browser - Puppeteer browser instance
|
||
* @returns {Array<Object>} - Array of extension target info objects
|
||
*/
|
||
function getExtensionTargets(browser) {
|
||
return browser.targets()
|
||
.filter(target =>
|
||
target.url().startsWith('chrome-extension://') ||
|
||
target.type() === 'service_worker' ||
|
||
target.type() === 'background_page'
|
||
)
|
||
.map(target => ({
|
||
type: target.type(),
|
||
url: target.url(),
|
||
extensionId: target.url().includes('chrome-extension://')
|
||
? target.url().split('chrome-extension://')[1]?.split('/')[0]
|
||
: null,
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* Find Chromium/Chrome binary path.
|
||
* Checks CHROME_BINARY env var first, then falls back to system locations.
|
||
*
|
||
* @returns {string|null} - Absolute path to browser binary or null if not found
|
||
*/
|
||
function findChromium() {
|
||
const { execSync } = require('child_process');
|
||
|
||
// Helper to validate a binary by running --version
|
||
const validateBinary = (binaryPath) => {
|
||
if (!binaryPath || !fs.existsSync(binaryPath)) return false;
|
||
try {
|
||
execSync(`"${binaryPath}" --version`, { encoding: 'utf8', timeout: 5000, stdio: 'pipe' });
|
||
return true;
|
||
} catch (e) {
|
||
return false;
|
||
}
|
||
};
|
||
|
||
// 1. Check CHROME_BINARY env var first
|
||
const chromeBinary = getEnv('CHROME_BINARY');
|
||
if (chromeBinary) {
|
||
const absPath = path.resolve(chromeBinary);
|
||
if (validateBinary(absPath)) {
|
||
return absPath;
|
||
}
|
||
console.error(`[!] Warning: CHROME_BINARY="${chromeBinary}" is not valid`);
|
||
}
|
||
|
||
// 2. Warn that no CHROME_BINARY is configured, searching fallbacks
|
||
if (!chromeBinary) {
|
||
console.error('[!] Warning: CHROME_BINARY not set, searching system locations...');
|
||
}
|
||
|
||
// Helper to find Chromium in @puppeteer/browsers directory structure
|
||
const findInPuppeteerDir = (baseDir) => {
|
||
if (!fs.existsSync(baseDir)) return null;
|
||
try {
|
||
const versions = fs.readdirSync(baseDir);
|
||
for (const version of versions.sort().reverse()) {
|
||
const versionDir = path.join(baseDir, version);
|
||
const candidates = [
|
||
path.join(versionDir, 'chrome-mac-arm64/Chromium.app/Contents/MacOS/Chromium'),
|
||
path.join(versionDir, 'chrome-mac/Chromium.app/Contents/MacOS/Chromium'),
|
||
path.join(versionDir, 'chrome-mac-x64/Chromium.app/Contents/MacOS/Chromium'),
|
||
path.join(versionDir, 'chrome-linux64/chrome'),
|
||
path.join(versionDir, 'chrome-linux/chrome'),
|
||
];
|
||
for (const c of candidates) {
|
||
if (fs.existsSync(c)) return c;
|
||
}
|
||
}
|
||
} catch (e) {}
|
||
return null;
|
||
};
|
||
|
||
// 3. Search fallback locations (Chromium first, then Chrome)
|
||
const fallbackLocations = [
|
||
// System Chromium
|
||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||
'/usr/bin/chromium',
|
||
'/usr/bin/chromium-browser',
|
||
// Puppeteer cache
|
||
path.join(process.env.HOME || '', '.cache/puppeteer/chromium'),
|
||
path.join(process.env.HOME || '', '.cache/puppeteer'),
|
||
// Chrome (fallback - extensions may not work in 137+)
|
||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||
'/usr/bin/google-chrome',
|
||
'/usr/bin/google-chrome-stable',
|
||
];
|
||
|
||
for (const loc of fallbackLocations) {
|
||
// Check if it's a puppeteer cache dir
|
||
if (loc.includes('.cache/puppeteer')) {
|
||
const binary = findInPuppeteerDir(loc);
|
||
if (binary && validateBinary(binary)) {
|
||
return binary;
|
||
}
|
||
} else if (validateBinary(loc)) {
|
||
if (loc.includes('Google Chrome') || loc.includes('google-chrome')) {
|
||
console.error('[!] Warning: Using Chrome instead of Chromium. Extension loading may not work in Chrome 137+');
|
||
}
|
||
return loc;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
// ============================================================================
|
||
// Shared Extension Installer Utilities
|
||
// ============================================================================
|
||
|
||
/**
|
||
* Get the extensions directory path.
|
||
* Centralized path calculation used by extension installers and chrome launch.
|
||
*
|
||
* Path is derived from environment variables in this priority:
|
||
* 1. CHROME_EXTENSIONS_DIR (explicit override)
|
||
* 2. DATA_DIR/personas/ACTIVE_PERSONA/chrome_extensions (default)
|
||
*
|
||
* @returns {string} - Absolute path to extensions directory
|
||
*/
|
||
function getExtensionsDir() {
|
||
const dataDir = getEnv('DATA_DIR', '.');
|
||
const persona = getEnv('ACTIVE_PERSONA', 'Default');
|
||
return getEnv('CHROME_EXTENSIONS_DIR') ||
|
||
path.join(dataDir, 'personas', persona, 'chrome_extensions');
|
||
}
|
||
|
||
/**
|
||
* Get machine type string for platform-specific paths.
|
||
* Matches Python's archivebox.config.paths.get_machine_type()
|
||
*
|
||
* @returns {string} - Machine type (e.g., 'x86_64-linux', 'arm64-darwin')
|
||
*/
|
||
function getMachineType() {
|
||
if (process.env.MACHINE_TYPE) {
|
||
return process.env.MACHINE_TYPE;
|
||
}
|
||
|
||
let machine = process.arch;
|
||
const system = process.platform;
|
||
|
||
// Normalize machine type to match Python's convention
|
||
if (machine === 'arm64' || machine === 'aarch64') {
|
||
machine = 'arm64';
|
||
} else if (machine === 'x64' || machine === 'x86_64' || machine === 'amd64') {
|
||
machine = 'x86_64';
|
||
} else if (machine === 'ia32' || machine === 'x86') {
|
||
machine = 'x86';
|
||
}
|
||
|
||
return `${machine}-${system}`;
|
||
}
|
||
|
||
/**
|
||
* Get LIB_DIR path for platform-specific binaries.
|
||
* Returns DATA_DIR/lib/MACHINE_TYPE/
|
||
*
|
||
* @returns {string} - Absolute path to lib directory
|
||
*/
|
||
function getLibDir() {
|
||
if (process.env.LIB_DIR) {
|
||
return path.resolve(process.env.LIB_DIR);
|
||
}
|
||
const dataDir = getEnv('DATA_DIR', './data');
|
||
const machineType = getMachineType();
|
||
return path.resolve(path.join(dataDir, 'lib', machineType));
|
||
}
|
||
|
||
/**
|
||
* Get NODE_MODULES_DIR path for npm packages.
|
||
* Returns LIB_DIR/npm/node_modules/
|
||
*
|
||
* @returns {string} - Absolute path to node_modules directory
|
||
*/
|
||
function getNodeModulesDir() {
|
||
if (process.env.NODE_MODULES_DIR) {
|
||
return path.resolve(process.env.NODE_MODULES_DIR);
|
||
}
|
||
return path.resolve(path.join(getLibDir(), 'npm', 'node_modules'));
|
||
}
|
||
|
||
/**
|
||
* Get all test environment paths as a JSON object.
|
||
* This is the single source of truth for path calculations - Python calls this
|
||
* to avoid duplicating path logic.
|
||
*
|
||
* @returns {Object} - Object with all test environment paths
|
||
*/
|
||
function getTestEnv() {
|
||
const dataDir = getEnv('DATA_DIR', './data');
|
||
const machineType = getMachineType();
|
||
const libDir = getLibDir();
|
||
const nodeModulesDir = getNodeModulesDir();
|
||
|
||
return {
|
||
DATA_DIR: dataDir,
|
||
MACHINE_TYPE: machineType,
|
||
LIB_DIR: libDir,
|
||
NODE_MODULES_DIR: nodeModulesDir,
|
||
NODE_PATH: nodeModulesDir, // Node.js uses NODE_PATH for module resolution
|
||
NPM_BIN_DIR: path.join(libDir, 'npm', '.bin'),
|
||
CHROME_EXTENSIONS_DIR: getExtensionsDir(),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Install a Chrome extension with caching support.
|
||
*
|
||
* This is the main entry point for extension installer hooks. It handles:
|
||
* - Checking for cached extension metadata
|
||
* - Installing the extension if not cached
|
||
* - Writing cache file for future runs
|
||
*
|
||
* @param {Object} extension - Extension metadata object
|
||
* @param {string} extension.webstore_id - Chrome Web Store extension ID
|
||
* @param {string} extension.name - Human-readable extension name (used for cache file)
|
||
* @param {Object} [options] - Options
|
||
* @param {string} [options.extensionsDir] - Override extensions directory
|
||
* @param {boolean} [options.quiet=false] - Suppress info logging
|
||
* @returns {Promise<Object|null>} - Installed extension metadata or null on failure
|
||
*/
|
||
async function installExtensionWithCache(extension, options = {}) {
|
||
const {
|
||
extensionsDir = getExtensionsDir(),
|
||
quiet = false,
|
||
} = options;
|
||
|
||
const cacheFile = path.join(extensionsDir, `${extension.name}.extension.json`);
|
||
|
||
// Check if extension is already cached and valid
|
||
if (fs.existsSync(cacheFile)) {
|
||
try {
|
||
const cached = JSON.parse(fs.readFileSync(cacheFile, 'utf-8'));
|
||
const manifestPath = path.join(cached.unpacked_path, 'manifest.json');
|
||
|
||
if (fs.existsSync(manifestPath)) {
|
||
if (!quiet) {
|
||
console.log(`[*] ${extension.name} extension already installed (using cache)`);
|
||
}
|
||
return cached;
|
||
}
|
||
} catch (e) {
|
||
// Cache file corrupted, re-install
|
||
console.warn(`[⚠️] Extension cache corrupted for ${extension.name}, re-installing...`);
|
||
}
|
||
}
|
||
|
||
// Install extension
|
||
if (!quiet) {
|
||
console.log(`[*] Installing ${extension.name} extension...`);
|
||
}
|
||
|
||
const installedExt = await loadOrInstallExtension(extension, extensionsDir);
|
||
|
||
if (!installedExt?.version) {
|
||
console.error(`[❌] Failed to install ${extension.name} extension`);
|
||
return null;
|
||
}
|
||
|
||
// Write cache file
|
||
try {
|
||
await fs.promises.mkdir(extensionsDir, { recursive: true });
|
||
await fs.promises.writeFile(cacheFile, JSON.stringify(installedExt, null, 2));
|
||
if (!quiet) {
|
||
console.log(`[+] Extension metadata written to ${cacheFile}`);
|
||
}
|
||
} catch (e) {
|
||
console.warn(`[⚠️] Failed to write cache file: ${e.message}`);
|
||
}
|
||
|
||
if (!quiet) {
|
||
console.log(`[+] ${extension.name} extension installed`);
|
||
}
|
||
|
||
return installedExt;
|
||
}
|
||
|
||
// ============================================================================
|
||
// Snapshot Hook Utilities (for CDP-based plugins like ssl, responses, dns)
|
||
// ============================================================================
|
||
|
||
/**
|
||
* Parse command line arguments into an object.
|
||
* Handles --key=value and --flag formats.
|
||
*
|
||
* @returns {Object} - Parsed arguments object
|
||
*/
|
||
function parseArgs() {
|
||
const args = {};
|
||
process.argv.slice(2).forEach(arg => {
|
||
if (arg.startsWith('--')) {
|
||
const [key, ...valueParts] = arg.slice(2).split('=');
|
||
args[key.replace(/-/g, '_')] = valueParts.join('=') || true;
|
||
}
|
||
});
|
||
return args;
|
||
}
|
||
|
||
/**
|
||
* Wait for Chrome session files to be ready.
|
||
* Polls for cdp_url.txt and target_id.txt in the chrome session directory.
|
||
*
|
||
* @param {string} chromeSessionDir - Path to chrome session directory (e.g., '../chrome')
|
||
* @param {number} [timeoutMs=60000] - Timeout in milliseconds
|
||
* @returns {Promise<boolean>} - True if files are ready, false if timeout
|
||
*/
|
||
async function waitForChromeSession(chromeSessionDir, timeoutMs = 60000) {
|
||
const cdpFile = path.join(chromeSessionDir, 'cdp_url.txt');
|
||
const targetIdFile = path.join(chromeSessionDir, 'target_id.txt');
|
||
const startTime = Date.now();
|
||
|
||
while (Date.now() - startTime < timeoutMs) {
|
||
if (fs.existsSync(cdpFile) && fs.existsSync(targetIdFile)) {
|
||
return true;
|
||
}
|
||
await new Promise(resolve => setTimeout(resolve, 100));
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Read CDP WebSocket URL from chrome session directory.
|
||
*
|
||
* @param {string} chromeSessionDir - Path to chrome session directory
|
||
* @returns {string|null} - CDP URL or null if not found
|
||
*/
|
||
function readCdpUrl(chromeSessionDir) {
|
||
const cdpFile = path.join(chromeSessionDir, 'cdp_url.txt');
|
||
if (fs.existsSync(cdpFile)) {
|
||
return fs.readFileSync(cdpFile, 'utf8').trim();
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Read target ID from chrome session directory.
|
||
*
|
||
* @param {string} chromeSessionDir - Path to chrome session directory
|
||
* @returns {string|null} - Target ID or null if not found
|
||
*/
|
||
function readTargetId(chromeSessionDir) {
|
||
const targetIdFile = path.join(chromeSessionDir, 'target_id.txt');
|
||
if (fs.existsSync(targetIdFile)) {
|
||
return fs.readFileSync(targetIdFile, 'utf8').trim();
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Connect to Chrome browser and find the target page.
|
||
* This is a high-level utility that handles all the connection logic:
|
||
* 1. Wait for chrome session files
|
||
* 2. Connect to browser via CDP
|
||
* 3. Find the target page by ID
|
||
*
|
||
* @param {Object} options - Connection options
|
||
* @param {string} [options.chromeSessionDir='../chrome'] - Path to chrome session directory
|
||
* @param {number} [options.timeoutMs=60000] - Timeout for waiting
|
||
* @param {Object} [options.puppeteer] - Puppeteer module (must be passed in)
|
||
* @returns {Promise<Object>} - { browser, page, targetId, cdpUrl }
|
||
* @throws {Error} - If connection fails or page not found
|
||
*/
|
||
async function connectToPage(options = {}) {
|
||
const {
|
||
chromeSessionDir = '../chrome',
|
||
timeoutMs = 60000,
|
||
puppeteer,
|
||
} = options;
|
||
|
||
if (!puppeteer) {
|
||
throw new Error('puppeteer module must be passed to connectToPage()');
|
||
}
|
||
|
||
// Wait for chrome session to be ready
|
||
const sessionReady = await waitForChromeSession(chromeSessionDir, timeoutMs);
|
||
if (!sessionReady) {
|
||
throw new Error(`Chrome session not ready after ${timeoutMs/1000}s (chrome plugin must run first)`);
|
||
}
|
||
|
||
// Read session files
|
||
const cdpUrl = readCdpUrl(chromeSessionDir);
|
||
if (!cdpUrl) {
|
||
throw new Error('No Chrome session found (cdp_url.txt missing)');
|
||
}
|
||
|
||
const targetId = readTargetId(chromeSessionDir);
|
||
|
||
// Connect to browser
|
||
const browser = await puppeteer.connect({ browserWSEndpoint: cdpUrl });
|
||
|
||
// Find the target page
|
||
const pages = await browser.pages();
|
||
let page = null;
|
||
|
||
if (targetId) {
|
||
page = pages.find(p => {
|
||
const target = p.target();
|
||
return target && target._targetId === targetId;
|
||
});
|
||
}
|
||
|
||
// Fallback to last page if target not found
|
||
if (!page) {
|
||
page = pages[pages.length - 1];
|
||
}
|
||
|
||
if (!page) {
|
||
throw new Error('No page found in browser');
|
||
}
|
||
|
||
return { browser, page, targetId, cdpUrl };
|
||
}
|
||
|
||
/**
|
||
* Wait for page navigation to complete.
|
||
* Polls for page_loaded.txt marker file written by chrome_navigate.
|
||
*
|
||
* @param {string} chromeSessionDir - Path to chrome session directory
|
||
* @param {number} [timeoutMs=120000] - Timeout in milliseconds
|
||
* @param {number} [postLoadDelayMs=0] - Additional delay after page load marker
|
||
* @returns {Promise<void>}
|
||
* @throws {Error} - If timeout waiting for navigation
|
||
*/
|
||
async function waitForPageLoaded(chromeSessionDir, timeoutMs = 120000, postLoadDelayMs = 0) {
|
||
const pageLoadedMarker = path.join(chromeSessionDir, 'page_loaded.txt');
|
||
const pollInterval = 100;
|
||
let waitTime = 0;
|
||
|
||
while (!fs.existsSync(pageLoadedMarker) && waitTime < timeoutMs) {
|
||
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
||
waitTime += pollInterval;
|
||
}
|
||
|
||
if (!fs.existsSync(pageLoadedMarker)) {
|
||
throw new Error('Timeout waiting for navigation (chrome_navigate did not complete)');
|
||
}
|
||
|
||
// Optional post-load delay for late responses
|
||
if (postLoadDelayMs > 0) {
|
||
await new Promise(resolve => setTimeout(resolve, postLoadDelayMs));
|
||
}
|
||
}
|
||
|
||
// Export all functions
|
||
module.exports = {
|
||
// Environment helpers
|
||
getEnv,
|
||
getEnvBool,
|
||
getEnvInt,
|
||
getEnvArray,
|
||
parseResolution,
|
||
// PID file management
|
||
writePidWithMtime,
|
||
writeCmdScript,
|
||
// Port management
|
||
findFreePort,
|
||
waitForDebugPort,
|
||
// Zombie cleanup
|
||
killZombieChrome,
|
||
// Chrome launching
|
||
launchChromium,
|
||
killChrome,
|
||
// Chrome/Chromium install
|
||
installChromium,
|
||
installPuppeteerCore,
|
||
// Chrome/Chromium binary finding
|
||
findChromium,
|
||
// Extension utilities
|
||
getExtensionId,
|
||
loadExtensionManifest,
|
||
installExtension,
|
||
loadOrInstallExtension,
|
||
isTargetExtension,
|
||
loadExtensionFromTarget,
|
||
installAllExtensions,
|
||
loadAllExtensionsFromBrowser,
|
||
// New puppeteer best-practices helpers
|
||
getExtensionPaths,
|
||
waitForExtensionTarget,
|
||
getExtensionTargets,
|
||
// Shared path utilities (single source of truth for Python/JS)
|
||
getMachineType,
|
||
getLibDir,
|
||
getNodeModulesDir,
|
||
getExtensionsDir,
|
||
getTestEnv,
|
||
// Shared extension installer utilities
|
||
installExtensionWithCache,
|
||
// Deprecated - use enableExtensions option instead
|
||
getExtensionLaunchArgs,
|
||
// Snapshot hook utilities (for CDP-based plugins)
|
||
parseArgs,
|
||
waitForChromeSession,
|
||
readCdpUrl,
|
||
readTargetId,
|
||
connectToPage,
|
||
waitForPageLoaded,
|
||
};
|
||
|
||
// CLI usage
|
||
if (require.main === module) {
|
||
const args = process.argv.slice(2);
|
||
|
||
if (args.length === 0) {
|
||
console.log('Usage: chrome_utils.js <command> [args...]');
|
||
console.log('');
|
||
console.log('Commands:');
|
||
console.log(' findChromium Find Chrome/Chromium binary');
|
||
console.log(' installChromium Install Chromium via @puppeteer/browsers');
|
||
console.log(' installPuppeteerCore Install puppeteer-core npm package');
|
||
console.log(' launchChromium Launch Chrome with CDP debugging');
|
||
console.log(' killChrome <pid> Kill Chrome process by PID');
|
||
console.log(' killZombieChrome Clean up zombie Chrome processes');
|
||
console.log('');
|
||
console.log(' getMachineType Get machine type (e.g., x86_64-linux)');
|
||
console.log(' getLibDir Get LIB_DIR path');
|
||
console.log(' getNodeModulesDir Get NODE_MODULES_DIR path');
|
||
console.log(' getExtensionsDir Get Chrome extensions directory');
|
||
console.log(' getTestEnv Get all paths as JSON (for tests)');
|
||
console.log('');
|
||
console.log(' getExtensionId <path> Get extension ID from unpacked path');
|
||
console.log(' loadExtensionManifest Load extension manifest.json');
|
||
console.log(' loadOrInstallExtension Load or install an extension');
|
||
console.log(' installExtensionWithCache Install extension with caching');
|
||
console.log('');
|
||
console.log('Environment variables:');
|
||
console.log(' DATA_DIR Base data directory');
|
||
console.log(' LIB_DIR Library directory (computed if not set)');
|
||
console.log(' MACHINE_TYPE Machine type override');
|
||
console.log(' NODE_MODULES_DIR Node modules directory');
|
||
console.log(' CHROME_BINARY Chrome binary path');
|
||
console.log(' CHROME_EXTENSIONS_DIR Extensions directory');
|
||
process.exit(1);
|
||
}
|
||
|
||
const [command, ...commandArgs] = args;
|
||
|
||
(async () => {
|
||
try {
|
||
switch (command) {
|
||
case 'findChromium': {
|
||
const binary = findChromium();
|
||
if (binary) {
|
||
console.log(binary);
|
||
} else {
|
||
console.error('Chromium binary not found');
|
||
process.exit(1);
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'installChromium': {
|
||
const result = await installChromium();
|
||
if (result.success) {
|
||
console.log(JSON.stringify({
|
||
binary: result.binary,
|
||
version: result.version,
|
||
}));
|
||
} else {
|
||
console.error(result.error);
|
||
process.exit(1);
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'installPuppeteerCore': {
|
||
const [npmPrefix] = commandArgs;
|
||
const result = await installPuppeteerCore({ npmPrefix: npmPrefix || undefined });
|
||
if (result.success) {
|
||
console.log(JSON.stringify({ path: result.path }));
|
||
} else {
|
||
console.error(result.error);
|
||
process.exit(1);
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'launchChromium': {
|
||
const [outputDir, extensionPathsJson] = commandArgs;
|
||
const extensionPaths = extensionPathsJson ? JSON.parse(extensionPathsJson) : [];
|
||
const result = await launchChromium({
|
||
outputDir: outputDir || 'chrome',
|
||
extensionPaths,
|
||
});
|
||
if (result.success) {
|
||
console.log(JSON.stringify({
|
||
cdpUrl: result.cdpUrl,
|
||
pid: result.pid,
|
||
port: result.port,
|
||
}));
|
||
} else {
|
||
console.error(result.error);
|
||
process.exit(1);
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'killChrome': {
|
||
const [pidStr, outputDir] = commandArgs;
|
||
const pid = parseInt(pidStr, 10);
|
||
if (isNaN(pid)) {
|
||
console.error('Invalid PID');
|
||
process.exit(1);
|
||
}
|
||
await killChrome(pid, outputDir);
|
||
break;
|
||
}
|
||
|
||
case 'killZombieChrome': {
|
||
const [dataDir] = commandArgs;
|
||
const killed = killZombieChrome(dataDir);
|
||
console.log(killed);
|
||
break;
|
||
}
|
||
|
||
case 'getExtensionId': {
|
||
const [unpacked_path] = commandArgs;
|
||
const id = getExtensionId(unpacked_path);
|
||
console.log(id);
|
||
break;
|
||
}
|
||
|
||
case 'loadExtensionManifest': {
|
||
const [unpacked_path] = commandArgs;
|
||
const manifest = loadExtensionManifest(unpacked_path);
|
||
console.log(JSON.stringify(manifest));
|
||
break;
|
||
}
|
||
|
||
case 'getExtensionLaunchArgs': {
|
||
const [extensions_json] = commandArgs;
|
||
const extensions = JSON.parse(extensions_json);
|
||
const launchArgs = getExtensionLaunchArgs(extensions);
|
||
console.log(JSON.stringify(launchArgs));
|
||
break;
|
||
}
|
||
|
||
case 'loadOrInstallExtension': {
|
||
const [webstore_id, name, extensions_dir] = commandArgs;
|
||
const ext = await loadOrInstallExtension({ webstore_id, name }, extensions_dir);
|
||
console.log(JSON.stringify(ext, null, 2));
|
||
break;
|
||
}
|
||
|
||
case 'getMachineType': {
|
||
console.log(getMachineType());
|
||
break;
|
||
}
|
||
|
||
case 'getLibDir': {
|
||
console.log(getLibDir());
|
||
break;
|
||
}
|
||
|
||
case 'getNodeModulesDir': {
|
||
console.log(getNodeModulesDir());
|
||
break;
|
||
}
|
||
|
||
case 'getExtensionsDir': {
|
||
console.log(getExtensionsDir());
|
||
break;
|
||
}
|
||
|
||
case 'getTestEnv': {
|
||
console.log(JSON.stringify(getTestEnv(), null, 2));
|
||
break;
|
||
}
|
||
|
||
case 'installExtensionWithCache': {
|
||
const [webstore_id, name] = commandArgs;
|
||
if (!webstore_id || !name) {
|
||
console.error('Usage: installExtensionWithCache <webstore_id> <name>');
|
||
process.exit(1);
|
||
}
|
||
const ext = await installExtensionWithCache({ webstore_id, name });
|
||
if (ext) {
|
||
console.log(JSON.stringify(ext, null, 2));
|
||
} else {
|
||
process.exit(1);
|
||
}
|
||
break;
|
||
}
|
||
|
||
default:
|
||
console.error(`Unknown command: ${command}`);
|
||
process.exit(1);
|
||
}
|
||
} catch (error) {
|
||
console.error(`Error: ${error.message}`);
|
||
process.exit(1);
|
||
}
|
||
})();
|
||
}
|