From a9c108a3e4ede5eaaa39bacc9fe0812174395dfc Mon Sep 17 00:00:00 2001 From: kjxcodez Date: Thu, 6 Aug 2026 00:25:27 +0530 Subject: [PATCH] fix(desktop): auto-install Playwright Chromium on first launch Closes #21 - Add lib/playwright-setup.ts with ensurePlaywrightBrowsers() that checks for the chromium binary at startup and downloads it via playwright-core CLI if missing. Browser is stored in {userData}/playwright-browsers so the app fully controls the install location with no admin rights needed. - Call ensurePlaywrightBrowsers() in index.ts app.whenReady() callback, wiring progress output into the splash screen label so users see 'Setting up browser engine...' on first launch rather than a silent hang. - Forward PLAYWRIGHT_BROWSERS_PATH in scheduler.ts worker fork() env so every spawned worker process resolves the binary from the same app-controlled location instead of the OS-wide ms-playwright cache. --- apps/desktop/src/main/index.ts | 18 +- apps/desktop/src/main/lib/playwright-setup.ts | 165 ++++++++++++++++++ apps/desktop/src/main/services/scheduler.ts | 7 + 3 files changed, 188 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/main/lib/playwright-setup.ts diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 74a3d57..9591af3 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -14,6 +14,7 @@ import { telemetry } from './lib/telemetry'; import { UpdateManager } from './services/updater'; import { LocalCrashReporter } from './lib/crash-reporter'; import { loadConfig } from './lib/config'; +import { ensurePlaywrightBrowsers } from './lib/playwright-setup'; // Track and write process crashes locally LocalCrashReporter.initialize(); @@ -143,7 +144,7 @@ function createWindow() { export function registerIpcHandler() {} // App lifecycle -app.whenReady().then(() => { +app.whenReady().then(async () => { telemetry.whenReadyTime = Date.now(); // Create lightweight native splash window immediately @@ -197,7 +198,20 @@ app.whenReady().then(() => { } }); - // 2. Register all IPC handlers exactly once using the coordinator + // 2. Ensure Playwright Chromium browser binaries are installed before any + // scraper job can run. On first launch this downloads ~80 MB; on subsequent + // launches the installed check takes < 10 ms. + try { + updateSplashProgress('browser:setup', 'Setting up browser engine...'); + await ensurePlaywrightBrowsers((line) => { + // Pipe installation stdout into the splash screen label (truncated to 50 chars) + updateSplashProgress('browser:setup', line.slice(0, 60)); + }); + } catch (err) { + AppLogger.error('app', 'Playwright browser setup failed during startup', undefined, err); + } + + // 3. Register all IPC handlers exactly once using the coordinator registerAllIpc( sdk, customHeaders, diff --git a/apps/desktop/src/main/lib/playwright-setup.ts b/apps/desktop/src/main/lib/playwright-setup.ts new file mode 100644 index 0000000..7b8ef29 --- /dev/null +++ b/apps/desktop/src/main/lib/playwright-setup.ts @@ -0,0 +1,165 @@ +import { join, dirname } from 'path'; +import { existsSync } from 'fs'; +import { fork } from 'child_process'; +import { app } from 'electron'; +import { AppLogger } from './logger'; + +/** + * Returns the path where LeadForge OS stores its Playwright browser binaries. + * + * By setting PLAYWRIGHT_BROWSERS_PATH to a sub-directory inside Electron's + * `userData` folder we get three benefits: + * 1. Stable, predictable location that persists across app updates. + * 2. Full write access — no admin / sudo required on any OS. + * 3. Workers receive this path via fork() env so they always find the right binary. + */ +export function getPlaywrightBrowsersPath(): string { + return join(app.getPath('userData'), 'playwright-browsers'); +} + +/** + * Checks whether the Playwright Chromium headless-shell binary already exists + * inside `getPlaywrightBrowsersPath()`. + * + * We probe using Playwright-core's own path-resolution API so we don't have to + * hard-code platform-specific paths (Windows vs macOS vs Linux all differ). + */ +async function isBrowserInstalled(): Promise { + try { + // playwright-core exposes a registry that knows the exact expected binary path. + // This is the same check that `chromium.launch()` does internally before throwing. + const playwrightCorePkg = require.resolve('playwright-core/package.json'); + const playwrightCoreDir = dirname(playwrightCorePkg); + + // Dynamically import playwright-core's internal browser paths utility. + // This lets us avoid hard-coding versioned folder names (e.g. chromium-1228). + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { chromium } = require('playwright-core'); + + // Override the browsers path so the check targets our userData directory. + process.env.PLAYWRIGHT_BROWSERS_PATH = getPlaywrightBrowsersPath(); + + const executablePath: string = chromium.executablePath(); + AppLogger.info( + 'PlaywrightSetup', + `Chromium executable expected at: ${executablePath}` + ); + return existsSync(executablePath); + } catch { + // If anything fails (e.g. playwright-core not bundled) treat it as not installed. + return false; + } +} + +/** + * Runs `playwright-core install chromium` as a forked child process. + * + * Uses `playwright-core` (the dependency of `playwright`) directly so we don't + * rely on npx being available in the end-user's environment. The CLI script is + * always present next to `playwright-core` in node_modules. + * + * The PLAYWRIGHT_BROWSERS_PATH env variable ensures the binary is downloaded into + * the app's userData directory — not the OS-wide cache. + * + * @param onProgress - Optional callback called with each line of stdout/stderr + * so callers can forward installation progress to the UI. + */ +export async function installPlaywrightBrowsers( + onProgress?: (line: string) => void +): Promise { + return new Promise((resolve, reject) => { + const playwrightCorePkg = require.resolve('playwright-core/package.json'); + const cliPath = join(dirname(playwrightCorePkg), 'cli.js'); + + AppLogger.info( + 'PlaywrightSetup', + `Installing Chromium browser via playwright-core CLI: ${cliPath}` + ); + + const browsersPath = getPlaywrightBrowsersPath(); + + const child = fork(cliPath, ['install', 'chromium'], { + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + env: { + ...process.env, + PLAYWRIGHT_BROWSERS_PATH: browsersPath + } + }); + + child.stdout?.on('data', (chunk: Buffer) => { + const line = chunk.toString().trim(); + if (line) { + AppLogger.info('PlaywrightSetup', line); + onProgress?.(line); + } + }); + + child.stderr?.on('data', (chunk: Buffer) => { + const line = chunk.toString().trim(); + if (line) { + AppLogger.warn('PlaywrightSetup', line); + onProgress?.(line); + } + }); + + child.on('close', (code) => { + if (code === 0) { + AppLogger.info('PlaywrightSetup', 'Chromium browser installed successfully.'); + resolve(); + } else { + const err = new Error( + `playwright-core install chromium exited with code ${code}` + ); + AppLogger.error('PlaywrightSetup', err.message, undefined, err); + reject(err); + } + }); + + child.on('error', (err) => { + AppLogger.error('PlaywrightSetup', 'Failed to spawn playwright CLI', undefined, err); + reject(err); + }); + }); +} + +/** + * Ensures Playwright's Chromium browser is present before any scraper jobs run. + * + * Called once during app startup (before the main window is shown). + * If the binary is already present the function returns immediately (~0 ms). + * If missing it performs a one-time download (~60–120 MB) — this typically + * takes 30–90 s depending on network speed. + * + * @param onProgress - Forwarded to `installPlaywrightBrowsers` for UI updates. + */ +export async function ensurePlaywrightBrowsers( + onProgress?: (line: string) => void +): Promise { + // Always set the env var so the current process and all subsequent child + // processes look in the same app-controlled location. + process.env.PLAYWRIGHT_BROWSERS_PATH = getPlaywrightBrowsersPath(); + + const installed = await isBrowserInstalled(); + if (installed) { + AppLogger.info('PlaywrightSetup', 'Chromium browser already present. Skipping installation.'); + return; + } + + AppLogger.info( + 'PlaywrightSetup', + 'Chromium browser not found. Starting one-time installation...' + ); + + try { + await installPlaywrightBrowsers(onProgress); + } catch (err) { + // Log but do not crash the app — scraper jobs will report an actionable error + // if the browser is still missing when they run. + AppLogger.error( + 'PlaywrightSetup', + 'Failed to auto-install Playwright Chromium browser. Scraper jobs may fail.', + undefined, + err + ); + } +} diff --git a/apps/desktop/src/main/services/scheduler.ts b/apps/desktop/src/main/services/scheduler.ts index dc18164..cdaf9d4 100644 --- a/apps/desktop/src/main/services/scheduler.ts +++ b/apps/desktop/src/main/services/scheduler.ts @@ -478,6 +478,13 @@ export class JobScheduler { const worker = fork(workerHostPath, [], { env: { WORKSPACES_DB_DIR: join(app.getPath('userData'), 'workspaces'), + // Forward the Playwright browsers path so workers find chromium in the + // same app-controlled location that ensurePlaywrightBrowsers() installed + // it into. Without this, playwright-core falls back to the OS-wide + // ms-playwright cache which may not exist on a fresh install. + PLAYWRIGHT_BROWSERS_PATH: + process.env.PLAYWRIGHT_BROWSERS_PATH || + join(app.getPath('userData'), 'playwright-browsers'), NODE_ENV: process.env.NODE_ENV }, stdio: ['ignore', 'pipe', 'pipe', 'ipc'],