diff --git a/browse/src/commands.ts b/browse/src/commands.ts index 15244538..ae80f32d 100644 --- a/browse/src/commands.ts +++ b/browse/src/commands.ts @@ -15,6 +15,7 @@ export const READ_COMMANDS = new Set([ 'js', 'eval', 'css', 'attrs', 'console', 'network', 'cookies', 'storage', 'perf', 'dialog', 'is', + 'inspect', ]); export const WRITE_COMMANDS = new Set([ @@ -22,6 +23,7 @@ export const WRITE_COMMANDS = new Set([ 'click', 'fill', 'select', 'hover', 'type', 'press', 'scroll', 'wait', 'viewport', 'cookie', 'cookie-import', 'cookie-import-browser', 'header', 'useragent', 'upload', 'dialog-accept', 'dialog-dismiss', + 'style', 'cleanup', 'prettyscreenshot', ]); export const META_COMMANDS = new Set([ @@ -115,6 +117,11 @@ export const COMMAND_DESCRIPTIONS: Record' }, // Frame 'frame': { category: 'Meta', description: 'Switch to iframe context (or main to return)', usage: 'frame ' }, + // CSS Inspector + 'inspect': { category: 'Inspection', description: 'Deep CSS inspection via CDP — full rule cascade, box model, computed styles', usage: 'inspect [selector] [--all] [--history]' }, + 'style': { category: 'Interaction', description: 'Modify CSS property on element (with undo support)', usage: 'style | style --undo [N]' }, + 'cleanup': { category: 'Interaction', description: 'Remove page clutter (ads, cookie banners, sticky elements, social widgets)', usage: 'cleanup [--ads] [--cookies] [--sticky] [--social] [--all]' }, + 'prettyscreenshot': { category: 'Visual', description: 'Clean screenshot with optional cleanup, scroll positioning, and element hiding', usage: 'prettyscreenshot [--scroll-to sel|text] [--cleanup] [--hide sel...] [--width px] [path]' }, }; // Load-time validation: descriptions must cover exactly the command sets diff --git a/browse/src/read-commands.ts b/browse/src/read-commands.ts index 5615b60f..83c791a3 100644 --- a/browse/src/read-commands.ts +++ b/browse/src/read-commands.ts @@ -11,6 +11,7 @@ import type { Page, Frame } from 'playwright'; import * as fs from 'fs'; import * as path from 'path'; import { TEMP_DIR, isPathWithin } from './platform'; +import { inspectElement, formatInspectorResult, getModificationHistory } from './cdp-inspector'; /** Detect await keyword, ignoring comments. Accepted risk: await in string literals triggers wrapping (harmless). */ function hasAwait(code: string): boolean { @@ -352,6 +353,54 @@ export async function handleReadCommand( .join('\n'); } + case 'inspect': { + // Parse flags + let includeUA = false; + let showHistory = false; + let selector: string | undefined; + + for (const arg of args) { + if (arg === '--all') { + includeUA = true; + } else if (arg === '--history') { + showHistory = true; + } else if (!selector) { + selector = arg; + } + } + + // --history mode: return modification history + if (showHistory) { + const history = getModificationHistory(); + if (history.length === 0) return '(no style modifications)'; + return history.map((m, i) => + `[${i}] ${m.selector} { ${m.property}: ${m.oldValue} → ${m.newValue} } (${m.source}, ${m.method})` + ).join('\n'); + } + + // If no selector given, check for stored inspector data + if (!selector) { + // Access stored inspector data from the server's in-memory state + // The server stores this when the extension picks an element via POST /inspector/pick + const stored = (bm as any)._inspectorData; + const storedTs = (bm as any)._inspectorTimestamp; + if (stored) { + const stale = storedTs && (Date.now() - storedTs > 60000); + let output = formatInspectorResult(stored, { includeUA }); + if (stale) output = '⚠ Data may be stale (>60s old)\n\n' + output; + return output; + } + throw new Error('Usage: browse inspect [selector] [--all] [--history]\nOr pick an element in the Chrome sidebar first.'); + } + + // Direct inspection by selector + const result = await inspectElement(page, selector, { includeUA }); + // Store for later retrieval + (bm as any)._inspectorData = result; + (bm as any)._inspectorTimestamp = Date.now(); + return formatInspectorResult(result, { includeUA }); + } + default: throw new Error(`Unknown read command: ${command}`); } diff --git a/browse/src/server.ts b/browse/src/server.ts index dca38040..a247ef4b 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -23,6 +23,7 @@ import { COMMAND_DESCRIPTIONS } from './commands'; import { handleSnapshot, SNAPSHOT_FLAGS } from './snapshot'; import { resolveConfig, ensureStateDir, readVersionHash } from './config'; import { emitActivity, subscribe, getActivityAfter, getActivityHistory, getSubscriberCount } from './activity'; +import { inspectElement, modifyStyle, resetModifications, getModificationHistory, detachSession, type InspectorResult } from './cdp-inspector'; // Bun.spawn used instead of child_process.spawn (compiled bun binaries // fail posix_spawn on all executables including /bin/bash) import * as fs from 'fs'; @@ -544,6 +545,22 @@ const idleCheckInterval = setInterval(() => { import { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS } from './commands'; export { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS }; +// ─── Inspector State (in-memory) ────────────────────────────── +let inspectorData: InspectorResult | null = null; +let inspectorTimestamp: number = 0; + +// Inspector SSE subscribers +type InspectorSubscriber = (event: any) => void; +const inspectorSubscribers = new Set(); + +function emitInspectorEvent(event: any): void { + for (const notify of inspectorSubscribers) { + queueMicrotask(() => { + try { notify(event); } catch {} + }); + } +} + // ─── Server ──────────────────────────────────────────────────── const browserManager = new BrowserManager(); let isShuttingDown = false; @@ -728,6 +745,9 @@ async function shutdown() { isShuttingDown = true; console.log('[browse] Shutting down...'); + // Clean up CDP inspector sessions + try { detachSession(); } catch {} + inspectorSubscribers.clear(); // Stop watch mode if active if (browserManager.isWatching()) browserManager.stopWatch(); killAgent(); @@ -1127,6 +1147,149 @@ async function start() { }); } + // ─── Inspector endpoints ────────────────────────────────────── + + // POST /inspector/pick — receive element pick from extension, run CDP inspection + if (url.pathname === '/inspector/pick' && req.method === 'POST') { + const body = await req.json(); + const { selector, activeTabUrl } = body; + if (!selector) { + return new Response(JSON.stringify({ error: 'Missing selector' }), { + status: 400, headers: { 'Content-Type': 'application/json' }, + }); + } + try { + const page = browserManager.getPage(); + const result = await inspectElement(page, selector); + inspectorData = result; + inspectorTimestamp = Date.now(); + // Also store on browserManager for CLI access + (browserManager as any)._inspectorData = result; + (browserManager as any)._inspectorTimestamp = inspectorTimestamp; + emitInspectorEvent({ type: 'pick', selector, timestamp: inspectorTimestamp }); + return new Response(JSON.stringify(result), { + status: 200, headers: { 'Content-Type': 'application/json' }, + }); + } catch (err: any) { + return new Response(JSON.stringify({ error: err.message }), { + status: 500, headers: { 'Content-Type': 'application/json' }, + }); + } + } + + // GET /inspector — return latest inspector data + if (url.pathname === '/inspector' && req.method === 'GET') { + if (!inspectorData) { + return new Response(JSON.stringify({ data: null }), { + status: 200, headers: { 'Content-Type': 'application/json' }, + }); + } + const stale = inspectorTimestamp > 0 && (Date.now() - inspectorTimestamp > 60000); + return new Response(JSON.stringify({ data: inspectorData, timestamp: inspectorTimestamp, stale }), { + status: 200, headers: { 'Content-Type': 'application/json' }, + }); + } + + // POST /inspector/apply — apply a CSS modification + if (url.pathname === '/inspector/apply' && req.method === 'POST') { + const body = await req.json(); + const { selector, property, value } = body; + if (!selector || !property || value === undefined) { + return new Response(JSON.stringify({ error: 'Missing selector, property, or value' }), { + status: 400, headers: { 'Content-Type': 'application/json' }, + }); + } + try { + const page = browserManager.getPage(); + const mod = await modifyStyle(page, selector, property, value); + emitInspectorEvent({ type: 'apply', modification: mod, timestamp: Date.now() }); + return new Response(JSON.stringify(mod), { + status: 200, headers: { 'Content-Type': 'application/json' }, + }); + } catch (err: any) { + return new Response(JSON.stringify({ error: err.message }), { + status: 500, headers: { 'Content-Type': 'application/json' }, + }); + } + } + + // POST /inspector/reset — clear all modifications + if (url.pathname === '/inspector/reset' && req.method === 'POST') { + try { + const page = browserManager.getPage(); + await resetModifications(page); + emitInspectorEvent({ type: 'reset', timestamp: Date.now() }); + return new Response(JSON.stringify({ ok: true }), { + status: 200, headers: { 'Content-Type': 'application/json' }, + }); + } catch (err: any) { + return new Response(JSON.stringify({ error: err.message }), { + status: 500, headers: { 'Content-Type': 'application/json' }, + }); + } + } + + // GET /inspector/history — return modification list + if (url.pathname === '/inspector/history' && req.method === 'GET') { + return new Response(JSON.stringify({ history: getModificationHistory() }), { + status: 200, headers: { 'Content-Type': 'application/json' }, + }); + } + + // GET /inspector/events — SSE for inspector state changes + if (url.pathname === '/inspector/events' && req.method === 'GET') { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + // Send current state immediately + if (inspectorData) { + controller.enqueue(encoder.encode( + `event: state\ndata: ${JSON.stringify({ data: inspectorData, timestamp: inspectorTimestamp })}\n\n` + )); + } + + // Subscribe for live events + const notify: InspectorSubscriber = (event) => { + try { + controller.enqueue(encoder.encode( + `event: inspector\ndata: ${JSON.stringify(event)}\n\n` + )); + } catch { + inspectorSubscribers.delete(notify); + } + }; + inspectorSubscribers.add(notify); + + // Heartbeat every 15s + const heartbeat = setInterval(() => { + try { + controller.enqueue(encoder.encode(`: heartbeat\n\n`)); + } catch { + clearInterval(heartbeat); + inspectorSubscribers.delete(notify); + } + }, 15000); + + // Cleanup on disconnect + req.signal.addEventListener('abort', () => { + clearInterval(heartbeat); + inspectorSubscribers.delete(notify); + try { controller.close(); } catch {} + }); + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }, + }); + } + + // ─── Command endpoint ────────────────────────────────────────── + if (url.pathname === '/command' && req.method === 'POST') { resetIdleTimer(); // Only commands reset idle timer const body = await req.json(); diff --git a/browse/src/write-commands.ts b/browse/src/write-commands.ts index 02413daf..f3292cc1 100644 --- a/browse/src/write-commands.ts +++ b/browse/src/write-commands.ts @@ -11,6 +11,49 @@ import { validateNavigationUrl } from './url-validation'; import * as fs from 'fs'; import * as path from 'path'; import { TEMP_DIR, isPathWithin } from './platform'; +import { modifyStyle, undoModification, resetModifications, getModificationHistory } from './cdp-inspector'; + +// Security: Path validation for screenshot output +const SAFE_DIRECTORIES = [TEMP_DIR, process.cwd()]; + +function validateOutputPath(filePath: string): void { + const resolved = path.resolve(filePath); + const isSafe = SAFE_DIRECTORIES.some(dir => isPathWithin(resolved, dir)); + if (!isSafe) { + throw new Error(`Path must be within: ${SAFE_DIRECTORIES.join(', ')}`); + } +} + +/** Common selectors for page clutter removal */ +const CLEANUP_SELECTORS = { + ads: [ + 'ins.adsbygoogle', '[id^="google_ads"]', '[id^="div-gpt-ad"]', + 'iframe[src*="doubleclick"]', 'iframe[src*="googlesyndication"]', + '[class*="ad-banner"]', '[class*="ad-wrapper"]', '[class*="ad-container"]', + '[data-ad]', '[data-ad-slot]', '[class*="sponsored"]', + '.ad', '.ads', '.advert', '.advertisement', + ], + cookies: [ + '[class*="cookie-consent"]', '[class*="cookie-banner"]', '[class*="cookie-notice"]', + '[id*="cookie-consent"]', '[id*="cookie-banner"]', '[id*="cookie-notice"]', + '[class*="consent-banner"]', '[class*="consent-modal"]', + '[class*="gdpr"]', '[id*="gdpr"]', + '[class*="CookieConsent"]', '[id*="CookieConsent"]', + '#onetrust-consent-sdk', '.onetrust-pc-dark-filter', + '[class*="cc-banner"]', '[class*="cc-window"]', + ], + sticky: [ + // Select fixed/sticky positioned elements (except navs and headers at top) + // This is handled via JavaScript evaluation, not pure selectors + ], + social: [ + '[class*="social-share"]', '[class*="share-buttons"]', '[class*="share-bar"]', + '[class*="social-widget"]', '[class*="social-icons"]', + 'iframe[src*="facebook.com/plugins"]', 'iframe[src*="platform.twitter"]', + '[class*="fb-like"]', '[class*="tweet-button"]', + '[class*="addthis"]', '[class*="sharethis"]', + ], +}; export async function handleWriteCommand( command: string, @@ -358,6 +401,253 @@ export async function handleWriteCommand( return `Cookie picker opened at ${pickerUrl}\nDetected browsers: ${browsers.map(b => b.name).join(', ')}\nSelect domains to import, then close the picker when done.`; } + case 'style': { + // style --undo [N] → revert modification + if (args[0] === '--undo') { + const idx = args[1] ? parseInt(args[1], 10) : undefined; + await undoModification(page, idx); + return idx !== undefined ? `Reverted modification #${idx}` : 'Reverted last modification'; + } + + // style + const [selector, property, ...valueParts] = args; + const value = valueParts.join(' '); + if (!selector || !property || !value) { + throw new Error('Usage: browse style | style --undo [N]'); + } + + // Validate CSS property name + if (!/^[a-zA-Z-]+$/.test(property)) { + throw new Error(`Invalid CSS property name: ${property}. Only letters and hyphens allowed.`); + } + + const mod = await modifyStyle(page, selector, property, value); + return `Style modified: ${selector} { ${property}: ${mod.oldValue || '(none)'} → ${value} } (${mod.method})`; + } + + case 'cleanup': { + // Parse flags + let doAds = false, doCookies = false, doSticky = false, doSocial = false; + let doAll = false; + + if (args.length === 0) { + throw new Error('Usage: browse cleanup [--ads] [--cookies] [--sticky] [--social] [--all]'); + } + + for (const arg of args) { + switch (arg) { + case '--ads': doAds = true; break; + case '--cookies': doCookies = true; break; + case '--sticky': doSticky = true; break; + case '--social': doSocial = true; break; + case '--all': doAll = true; break; + default: + throw new Error(`Unknown cleanup flag: ${arg}. Use: --ads, --cookies, --sticky, --social, --all`); + } + } + + if (doAll) { + doAds = doCookies = doSticky = doSocial = true; + } + + const removed: string[] = []; + + // Build selector list for categories to clean + const selectors: string[] = []; + if (doAds) selectors.push(...CLEANUP_SELECTORS.ads); + if (doCookies) selectors.push(...CLEANUP_SELECTORS.cookies); + if (doSocial) selectors.push(...CLEANUP_SELECTORS.social); + + if (selectors.length > 0) { + const count = await page.evaluate((sels: string[]) => { + let removed = 0; + for (const sel of sels) { + try { + const els = document.querySelectorAll(sel); + els.forEach(el => { + (el as HTMLElement).style.display = 'none'; + removed++; + }); + } catch {} + } + return removed; + }, selectors); + if (count > 0) { + if (doAds) removed.push('ads'); + if (doCookies) removed.push('cookie banners'); + if (doSocial) removed.push('social widgets'); + } + } + + // Sticky/fixed elements — handled separately with computed style check + if (doSticky) { + const stickyCount = await page.evaluate(() => { + let removed = 0; + const allElements = document.querySelectorAll('*'); + for (const el of allElements) { + const style = getComputedStyle(el); + if (style.position === 'fixed' || style.position === 'sticky') { + const tag = el.tagName.toLowerCase(); + // Skip main nav/header elements + if (tag === 'nav' || tag === 'header') continue; + if (el.getAttribute('role') === 'navigation') continue; + // Skip elements at the very top that look like navbars + const rect = el.getBoundingClientRect(); + if (rect.top <= 10 && rect.height < 100 && tag !== 'div') continue; + (el as HTMLElement).style.display = 'none'; + removed++; + } + } + return removed; + }); + if (stickyCount > 0) removed.push(`${stickyCount} sticky/fixed elements`); + } + + if (removed.length === 0) return 'No clutter elements found to remove.'; + return `Cleaned up: ${removed.join(', ')}`; + } + + case 'prettyscreenshot': { + // Parse flags + let scrollTo: string | undefined; + let doCleanup = false; + const hideSelectors: string[] = []; + let viewportWidth: number | undefined; + let outputPath: string | undefined; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--scroll-to' && i + 1 < args.length) { + scrollTo = args[++i]; + } else if (args[i] === '--cleanup') { + doCleanup = true; + } else if (args[i] === '--hide' && i + 1 < args.length) { + // Collect all following non-flag args as selectors to hide + i++; + while (i < args.length && !args[i].startsWith('--')) { + hideSelectors.push(args[i]); + i++; + } + i--; // Back up since the for loop will increment + } else if (args[i] === '--width' && i + 1 < args.length) { + viewportWidth = parseInt(args[++i], 10); + if (isNaN(viewportWidth)) throw new Error('--width must be a number'); + } else if (!args[i].startsWith('--')) { + outputPath = args[i]; + } else { + throw new Error(`Unknown prettyscreenshot flag: ${args[i]}`); + } + } + + // Default output path + if (!outputPath) { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); + outputPath = `${TEMP_DIR}/browse-pretty-${timestamp}.png`; + } + validateOutputPath(outputPath); + + const originalViewport = page.viewportSize(); + + // Set viewport width if specified + if (viewportWidth && originalViewport) { + await page.setViewportSize({ width: viewportWidth, height: originalViewport.height }); + } + + // Run cleanup if requested + if (doCleanup) { + const allSelectors = [ + ...CLEANUP_SELECTORS.ads, + ...CLEANUP_SELECTORS.cookies, + ...CLEANUP_SELECTORS.social, + ]; + await page.evaluate((sels: string[]) => { + for (const sel of sels) { + try { + document.querySelectorAll(sel).forEach(el => { + (el as HTMLElement).style.display = 'none'; + }); + } catch {} + } + // Also hide fixed/sticky (except nav) + for (const el of document.querySelectorAll('*')) { + const style = getComputedStyle(el); + if (style.position === 'fixed' || style.position === 'sticky') { + const tag = el.tagName.toLowerCase(); + if (tag === 'nav' || tag === 'header') continue; + if (el.getAttribute('role') === 'navigation') continue; + (el as HTMLElement).style.display = 'none'; + } + } + }, allSelectors); + } + + // Hide specific elements + if (hideSelectors.length > 0) { + await page.evaluate((sels: string[]) => { + for (const sel of sels) { + try { + document.querySelectorAll(sel).forEach(el => { + (el as HTMLElement).style.display = 'none'; + }); + } catch {} + } + }, hideSelectors); + } + + // Scroll to target + if (scrollTo) { + // Try as CSS selector first, then as text content + const scrolled = await page.evaluate((target: string) => { + // Try CSS selector + let el = document.querySelector(target); + if (el) { + el.scrollIntoView({ behavior: 'instant', block: 'center' }); + return true; + } + // Try text match + const walker = document.createTreeWalker( + document.body, + NodeFilter.SHOW_TEXT, + null, + ); + let node: Node | null; + while ((node = walker.nextNode())) { + if (node.textContent?.includes(target)) { + const parent = node.parentElement; + if (parent) { + parent.scrollIntoView({ behavior: 'instant', block: 'center' }); + return true; + } + } + } + return false; + }, scrollTo); + + if (!scrolled) { + // Restore viewport before throwing + if (viewportWidth && originalViewport) { + await page.setViewportSize(originalViewport); + } + throw new Error(`Could not find element or text to scroll to: ${scrollTo}`); + } + // Brief wait for scroll to settle + await page.waitForTimeout(300); + } + + // Take screenshot + await page.screenshot({ path: outputPath, fullPage: !scrollTo }); + + // Restore viewport + if (viewportWidth && originalViewport) { + await page.setViewportSize(originalViewport); + } + + const parts = ['Screenshot saved']; + if (doCleanup) parts.push('(cleaned)'); + if (scrollTo) parts.push(`(scrolled to: ${scrollTo})`); + parts.push(`: ${outputPath}`); + return parts.join(' '); + } + default: throw new Error(`Unknown write command: ${command}`); }