From ab4e9ae5207d1fcf766403027ed8989222bd48e6 Mon Sep 17 00:00:00 2001 From: Sinabina Date: Tue, 21 Jul 2026 14:11:36 -0700 Subject: [PATCH] refactor: remove browser sidebar extension Co-Authored-By: Claude Opus 4.8 (1M context) --- browse/src/browser-manager.ts | 124 +-- bun.lock | 6 - extension/background.js | 608 ---------- extension/content.css | 124 --- extension/content.js | 378 ------- extension/icons/icon-128.png | Bin 2839 -> 0 bytes extension/icons/icon-16.png | Bin 400 -> 0 bytes extension/icons/icon-48.png | Bin 1106 -> 0 bytes extension/inspector.css | 29 - extension/inspector.js | 474 -------- extension/manifest.json | 31 - extension/popup.html | 98 -- extension/popup.js | 60 - extension/sidepanel-terminal.js | 1024 ----------------- extension/sidepanel.css | 1846 ------------------------------- extension/sidepanel.html | 202 ---- extension/sidepanel.js | 1415 ----------------------- package.json | 5 +- runtime/install.js | 3 +- scripts/build-app.sh | 7 - scripts/build.sh | 1 - 21 files changed, 12 insertions(+), 6423 deletions(-) delete mode 100644 extension/background.js delete mode 100644 extension/content.css delete mode 100644 extension/content.js delete mode 100644 extension/icons/icon-128.png delete mode 100644 extension/icons/icon-16.png delete mode 100644 extension/icons/icon-48.png delete mode 100644 extension/inspector.css delete mode 100644 extension/inspector.js delete mode 100644 extension/manifest.json delete mode 100644 extension/popup.html delete mode 100644 extension/popup.js delete mode 100644 extension/sidepanel-terminal.js delete mode 100644 extension/sidepanel.css delete mode 100644 extension/sidepanel.html delete mode 100644 extension/sidepanel.js diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index 5cdfbb8ec..eaa187b6f 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -17,7 +17,6 @@ import { chromium, type Browser, type BrowserContext, type BrowserContextOptions, type Page, type Locator, type Cookie } from 'playwright'; import { readdirSync } from 'node:fs'; -import { writeSecureFile, mkdirSecure } from './file-permissions'; import { addConsoleEntry, addNetworkEntry, addDialogEntry, networkBuffer, type DialogEntry } from './buffers'; import { emitActivity } from './activity'; import { validateNavigationUrl } from './url-validation'; @@ -243,9 +242,8 @@ export class BrowserManager { // back below. Pre-guardrail, nothing tracked tab count growth and a // user could accumulate hundreds of tabs (each holding 50–300 MB of // Chromium-side RSS) without warning until the OS OOM-killer fired. - // The toast UX lives in the sidebar (extension/sidepanel.js); the - // server-side responsibility is the audit-trail activity entry that - // appears in the activity feed even when the sidebar is closed. + // The server-side responsibility is the audit-trail activity entry that + // appears in the activity feed. private static readonly TAB_GUARDRAIL_SOFT = 50; private static readonly TAB_GUARDRAIL_HARD = 200; private tabGuardrailSoftHit = false; @@ -265,7 +263,7 @@ export class BrowserManager { } if (!this.tabGuardrailHardHit && total >= BrowserManager.TAB_GUARDRAIL_HARD) { this.tabGuardrailHardHit = true; - const msg = `Tab count crossed ${BrowserManager.TAB_GUARDRAIL_HARD} (now ${total}). OOM risk imminent. Open the sidebar to see top RAM consumers.`; + const msg = `Tab count crossed ${BrowserManager.TAB_GUARDRAIL_HARD} (now ${total}). OOM risk imminent. Close unused tabs to free RAM.`; console.error(`[browse] ${msg}`); emitActivity({ type: 'error', command: 'tab-guardrail', error: msg, tabs: total }); } @@ -320,43 +318,6 @@ export class BrowserManager { this.watchSnapshots.push(snapshot); } - /** - * Find the gstack Chrome extension directory. - * Checks: repo root /extension, global install, dev install. - */ - private findExtensionPath(): string | null { - const fs = require('fs'); - const path = require('path'); - const candidates = [ - // Explicit override via env var (used by GStack Browser.app bundle) - process.env.BROWSE_EXTENSIONS_DIR || '', - // Relative to this source file (dev mode: browse/src/ -> ../../extension) - path.resolve(__dirname, '..', '..', 'extension'), - // Global gstack install - path.join(process.env.HOME || '', '.claude', 'skills', 'gstack', 'extension'), - // Git repo root (detected via BROWSE_STATE_FILE location) - (() => { - const stateFile = process.env.BROWSE_STATE_FILE || ''; - if (stateFile) { - const repoRoot = path.resolve(path.dirname(stateFile), '..'); - return path.join(repoRoot, '.claude', 'skills', 'gstack', 'extension'); - } - return ''; - })(), - ].filter(Boolean); - - for (const candidate of candidates) { - try { - if (fs.existsSync(path.join(candidate, 'manifest.json'))) { - return candidate; - } - } catch (err: any) { - if (err?.code !== 'ENOENT' && err?.code !== 'EACCES') throw err; - } - } - return null; - } - /** * Set the proxy config applied to chromium.launch() in launch() and * launchHeaded(). Called by server.ts at startup once the (optional) SOCKS5 @@ -378,14 +339,9 @@ export class BrowserManager { } async launch() { - // ─── Extension Support ──────────────────────────────────── - // BROWSE_EXTENSIONS_DIR points to an unpacked Chrome extension directory. - // Extensions only work in headed mode, so we use an off-screen window. - const extensionsDir = process.env.BROWSE_EXTENSIONS_DIR; - if (extensionsDir) assertHeadedBrowserProvider(); const { STEALTH_LAUNCH_ARGS, buildGStackLaunchArgs } = await import('./stealth'); const launchArgs: string[] = [...STEALTH_LAUNCH_ARGS, ...buildGStackLaunchArgs()]; - let useHeadless = true; + const useHeadless = true; const executablePath = configuredChromiumExecutable(); // Docker/CI/root: Chromium sandbox requires unprivileged user namespaces which @@ -396,21 +352,6 @@ export class BrowserManager { launchArgs.push('--no-sandbox'); } - if (extensionsDir) { - // Skip --load-extension when running against a custom Chromium build that - // already bakes the extension in (e.g., GBrowser / GStack Browser.app). - // Loading it twice causes a ServiceWorkerState::SetWorkerId DCHECK crash. - if (!isCustomChromium()) { - launchArgs.push( - `--disable-extensions-except=${extensionsDir}`, - `--load-extension=${extensionsDir}`, - ); - } - launchArgs.push('--window-position=-9999,-9999', '--window-size=1,1'); - useHeadless = false; // extensions require headed mode; off-screen window simulates headless - console.log(`[browse] Extensions loaded from: ${extensionsDir}`); - } - this.browser = await chromium.launch({ headless: useHeadless, ...(executablePath @@ -468,23 +409,18 @@ export class BrowserManager { // ─── Headed Mode ───────────────────────────────────────────── /** - * Launch Playwright's bundled Chromium in headed mode with the gstack - * Chrome extension auto-loaded. Uses launchPersistentContext() which - * is required for extension loading (launch() + newContext() can't - * load extensions). + * Launch Playwright's bundled Chromium in headed mode. * * The browser launches headed with a visible window — the user sees * every action Claude takes in real time. */ - async launchHeaded(authToken?: string): Promise { + async launchHeaded(_authToken?: string): Promise { assertHeadedBrowserProvider(); // Clear old state before repopulating this.pages.clear(); this.tabSessions.clear(); this.nextTabId = 1; - // Find the gstack extension directory for auto-loading - const extensionPath = this.findExtensionPath(); const { STEALTH_LAUNCH_ARGS, buildGStackLaunchArgs } = await import('./stealth'); const launchArgs = [ '--hide-crash-restore-bubble', @@ -499,38 +435,9 @@ export class BrowserManager { // Chromium too. ...buildGStackLaunchArgs(), ]; - if (extensionPath) { - // Skip --load-extension when running against a custom Chromium build - // that already bakes the extension in as a component extension - // (gbrowser / GStack Browser.app). Loading it twice causes a - // ServiceWorkerState::SetWorkerId DCHECK crash. - if (!isCustomChromium()) { - launchArgs.push(`--disable-extensions-except=${extensionPath}`); - launchArgs.push(`--load-extension=${extensionPath}`); - } - // Write auth token for extension bootstrap (still required even when - // the extension is component-baked — it reads ~/.gstack/.auth.json at - // startup to learn how to call the daemon). - // Write to ~/.gstack/.auth.json (not the extension dir, which may be read-only - // in .app bundles and breaks codesigning). - if (authToken) { - const fs = require('fs'); - const path = require('path'); - const gstackDir = path.join(process.env.HOME || '/tmp', '.gstack'); - mkdirSecure(gstackDir); - const authFile = path.join(gstackDir, '.auth.json'); - try { - writeSecureFile(authFile, JSON.stringify({ token: authToken, port: this.serverPort || 34567 })); - } catch (err: any) { - console.warn(`[browse] Could not write .auth.json: ${err.message}`); - } - } - } - // Launch headed Chromium via Playwright's persistent context. - // Extensions REQUIRE launchPersistentContext (not launch + newContext). - // Real Chrome (executablePath/channel) silently blocks --load-extension, - // so we use Playwright's bundled Chromium which reliably loads extensions. + // Launch headed Chromium via Playwright's persistent context so the + // profile (cookies, storage) persists across runs. const fs = require('fs'); const path = require('path'); const userDataDir = resolveChromiumProfile(); @@ -1600,27 +1507,16 @@ export class BrowserManager { const state = await this.saveState(); const currentUrl = this.getCurrentUrl(); - // 2. Launch new headed browser with extension (same as launchHeaded) - // Uses launchPersistentContext so the extension auto-loads. + // 2. Launch new headed browser (same as launchHeaded). + // Uses launchPersistentContext so the profile persists. let newContext: BrowserContext; try { const fs = require('fs'); - const path = require('path'); - const extensionPath = this.findExtensionPath(); const { STEALTH_LAUNCH_ARGS, buildGStackLaunchArgs } = await import('./stealth'); // Same blink-level stealth flags as launch()/launchHeaded(). Without // STEALTH_LAUNCH_ARGS the handed-off browser kept the AutomationControlled // tell that the other two paths strip. const launchArgs: string[] = ['--hide-crash-restore-bubble', ...STEALTH_LAUNCH_ARGS, ...buildGStackLaunchArgs()]; - if (extensionPath) { - launchArgs.push(`--disable-extensions-except=${extensionPath}`); - launchArgs.push(`--load-extension=${extensionPath}`); - // Auth token is served via /health endpoint now (no file write needed). - // Extension reads token from /health on connect. - console.log(`[browse] Handoff: loading extension from ${extensionPath}`); - } else { - console.log('[browse] Handoff: extension not found — headed mode without side panel'); - } const userDataDir = resolveChromiumProfile(); fs.mkdirSync(userDataDir, { recursive: true }); diff --git a/bun.lock b/bun.lock index 720119df6..b5721849d 100644 --- a/bun.lock +++ b/bun.lock @@ -13,8 +13,6 @@ "playwright": "npm:playwright-core@^1.58.2", "sharp": "^0.34.5", "socks": "^2.8.8", - "xterm": "5", - "xterm-addon-fit": "^0.8.0", }, "devDependencies": { "@anthropic-ai/claude-agent-sdk": "0.2.117", @@ -521,10 +519,6 @@ "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], - "xterm": ["xterm@5.3.0", "", {}, "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg=="], - - "xterm-addon-fit": ["xterm-addon-fit@0.8.0", "", { "peerDependencies": { "xterm": "^5.0.0" } }, "sha512-yj3Np7XlvxxhYF/EJ7p3KHaMt6OdwQ+HDu573Vx1lRXsVxOcnVJs51RgjZOouIZOczTsskaS+CpXspK81/DLqw=="], - "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], diff --git a/extension/background.js b/extension/background.js deleted file mode 100644 index f130a2076..000000000 --- a/extension/background.js +++ /dev/null @@ -1,608 +0,0 @@ -/** - * gstack browse — background service worker - * - * Polls /health every 10s to detect browse server. - * Fetches /refs on snapshot completion, relays to content script. - * Proxies commands from sidebar → browse server. - * Updates badge: amber (connected), gray (disconnected). - */ - -const DEFAULT_PORT = 34567; // Well-known port used by `$B connect` -let serverPort = null; -let authToken = null; -let isConnected = false; -let healthInterval = null; - -// ─── Port Discovery ──────────────────────────────────────────── - -async function loadPort() { - const data = await chrome.storage.local.get('port'); - serverPort = data.port || DEFAULT_PORT; - return serverPort; -} - -async function savePort(port) { - serverPort = port; - await chrome.storage.local.set({ port }); -} - -function getBaseUrl() { - return serverPort ? `http://127.0.0.1:${serverPort}` : null; -} - -// ─── Auth Token Bootstrap ───────────────────────────────────── - -async function loadAuthToken() { - if (authToken) return; - // Get token from browse server /health endpoint (localhost-only, safe). - // Previously read from .auth.json in extension dir, but that breaks - // read-only .app bundles and codesigning. - const base = getBaseUrl(); - if (!base) return; - try { - const resp = await fetch(`${base}/health`, { signal: AbortSignal.timeout(3000) }); - if (resp.ok) { - const data = await resp.json(); - if (data.token) authToken = data.token; - } - } catch (err) { - console.error('[gstack bg] Failed to load auth token:', err.message); - } -} - -// ─── Health Polling ──────────────────────────────────────────── - -async function checkHealth() { - const base = getBaseUrl(); - if (!base) { - setDisconnected(); - return; - } - - // Retry loading auth token if we don't have one yet - if (!authToken) await loadAuthToken(); - - try { - const resp = await fetch(`${base}/health`, { signal: AbortSignal.timeout(3000) }); - if (!resp.ok) { setDisconnected(); return; } - const data = await resp.json(); - if (data.status === 'healthy') { - // Always refresh auth token from /health — the server generates a new - // token on each restart, so the old one becomes stale. - if (data.token) authToken = data.token; - // Forward chatEnabled so sidepanel can show/hide chat tab - setConnected({ ...data, chatEnabled: !!data.chatEnabled }); - } else { - setDisconnected(); - } - } catch (err) { - console.error('[gstack bg] Health check failed:', err.message); - setDisconnected(); - } -} - -function setConnected(healthData) { - const wasDisconnected = !isConnected; - isConnected = true; - chrome.action.setBadgeBackgroundColor({ color: '#F59E0B' }); - chrome.action.setBadgeText({ text: ' ' }); - - // Broadcast health to popup and side panel (token excluded — use getToken message instead) - chrome.runtime.sendMessage({ type: 'health', data: healthData }).catch((err) => { - console.debug('[gstack bg] No listener for health broadcast:', err.message); - }); - - // Notify content scripts on connection change - if (wasDisconnected) { - notifyContentScripts('connected'); - } -} - -function setDisconnected() { - const wasConnected = isConnected; - isConnected = false; - // Keep authToken — it persists across reconnections - chrome.action.setBadgeText({ text: '' }); - - chrome.runtime.sendMessage({ type: 'health', data: null }).catch((err) => { - console.debug('[gstack bg] No listener for disconnect broadcast:', err.message); - }); - - // Notify content scripts on disconnection - if (wasConnected) { - notifyContentScripts('disconnected'); - } -} - -async function notifyContentScripts(type) { - try { - const tabs = await chrome.tabs.query({}); - for (const tab of tabs) { - if (tab.id) { - chrome.tabs.sendMessage(tab.id, { type }).catch(() => { - // Expected: tabs without content script - }); - } - } - } catch (err) { - console.error('[gstack bg] Failed to query tabs for notification:', err.message); - } -} - -// ─── Command Proxy ───────────────────────────────────────────── - -async function executeCommand(command, args) { - const base = getBaseUrl(); - if (!base || !authToken) { - return { error: 'Not connected to browse server' }; - } - - try { - const resp = await fetch(`${base}/command`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${authToken}`, - }, - body: JSON.stringify({ command, args }), - signal: AbortSignal.timeout(30000), - }); - const data = await resp.json(); - return data; - } catch (err) { - return { error: err.message || 'Command failed' }; - } -} - -// ─── Refs Relay ───────────────────────────────────────────────── - -async function fetchAndRelayRefs() { - const base = getBaseUrl(); - if (!base || !isConnected) return; - - try { - const headers = {}; - if (authToken) headers['Authorization'] = `Bearer ${authToken}`; - const resp = await fetch(`${base}/refs`, { signal: AbortSignal.timeout(3000), headers }); - if (!resp.ok) { - console.warn(`[gstack bg] Refs endpoint returned ${resp.status}`); - return; - } - const data = await resp.json(); - - // Send to all tabs' content scripts - const tabs = await chrome.tabs.query({}); - for (const tab of tabs) { - if (tab.id) { - chrome.tabs.sendMessage(tab.id, { type: 'refs', data }).catch(() => { - // Expected: tabs without content script - }); - } - } - } catch (err) { - console.error('[gstack bg] Failed to fetch/relay refs:', err.message); - } -} - -// ─── Inspector ────────────────────────────────────────────────── - -// Track inspector mode per tab — 'full' (inspector.js injected) or 'basic' (content.js fallback) -let inspectorMode = 'full'; - -async function injectInspector(tabId) { - // Try full inspector injection first - try { - await chrome.scripting.executeScript({ - target: { tabId, allFrames: true }, - files: ['inspector.js'], - }); - // CSS injection failure alone doesn't need fallback - try { - await chrome.scripting.insertCSS({ - target: { tabId, allFrames: true }, - files: ['inspector.css'], - }); - } catch (err) { - console.debug('[gstack bg] Inspector CSS injection failed (non-fatal):', err.message); - } - // Send startPicker to the injected inspector.js - try { - await chrome.tabs.sendMessage(tabId, { type: 'startPicker' }); - } catch (err) { - console.warn('[gstack bg] Failed to send startPicker:', err.message); - } - inspectorMode = 'full'; - return { ok: true, mode: 'full' }; - } catch (err) { - // Script injection failed (CSP, chrome:// page, etc.) - // Fall back to content.js basic picker (loaded by manifest on most pages) - try { - await chrome.tabs.sendMessage(tabId, { type: 'startBasicPicker' }); - inspectorMode = 'basic'; - return { ok: true, mode: 'basic' }; - } catch (err2) { - console.error('[gstack bg] Inspector injection failed completely:', err.message, '| Basic fallback:', err2.message); - inspectorMode = 'full'; - return { error: 'Cannot inspect this page' }; - } - } -} - -async function stopInspector(tabId) { - try { - await chrome.tabs.sendMessage(tabId, { type: 'stopPicker' }); - } catch (err) { - console.debug('[gstack bg] Failed to stop picker on tab', tabId, ':', err.message); - } - return { ok: true }; -} - -async function postInspectorPick(selector, frameInfo, basicData, activeTabUrl) { - const base = getBaseUrl(); - if (!base || !authToken) { - // No browse server — return basic data as fallback - return { mode: 'basic', selector, basicData, frameInfo }; - } - - try { - const resp = await fetch(`${base}/inspector/pick`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${authToken}`, - }, - body: JSON.stringify({ selector, activeTabUrl, frameInfo }), - signal: AbortSignal.timeout(10000), - }); - if (!resp.ok) { - // Server error — fall back to basic mode - return { mode: 'basic', selector, basicData, frameInfo }; - } - const data = await resp.json(); - return { mode: 'cdp', ...data }; - } catch (err) { - console.debug('[gstack bg] Inspector pick server unavailable, using basic mode:', err.message); - return { mode: 'basic', selector, basicData, frameInfo }; - } -} - -async function sendToContentScript(tabId, message) { - try { - const response = await chrome.tabs.sendMessage(tabId, message); - return response || { ok: true }; - } catch { - return { error: 'Content script not available' }; - } -} - -// ─── Message Handling ────────────────────────────────────────── - -chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { - // Security: only accept messages from this extension's own scripts - if (sender.id !== chrome.runtime.id) { - console.warn('[gstack] Rejected message from unknown sender:', sender.id); - return; - } - - const ALLOWED_TYPES = new Set([ - 'getPort', 'setPort', 'getServerUrl', 'getToken', 'fetchRefs', - 'openSidePanel', 'sidebarOpened', 'command', 'sidebar-command', - 'getTabState', - // Inspector message types - 'startInspector', 'stopInspector', 'elementPicked', 'pickerCancelled', - 'applyStyle', 'toggleClass', 'injectCSS', 'resetAll', - 'inspectResult' - ]); - if (!ALLOWED_TYPES.has(msg.type)) { - console.warn('[gstack] Rejected unknown message type:', msg.type); - return; - } - - if (msg.type === 'getPort') { - const ownExtensionOrigin = `chrome-extension://${chrome.runtime.id}`; - sendResponse({ - port: serverPort, - connected: isConnected, - token: sender.origin === ownExtensionOrigin ? authToken : null, - }); - return true; - } - - if (msg.type === 'getTabState') { - snapshotTabs().then(snap => sendResponse(snap || { active: null, tabs: [] })); - return true; // async sendResponse - } - - if (msg.type === 'setPort') { - savePort(msg.port).then(() => { - checkHealth(); - sendResponse({ ok: true }); - }); - return true; - } - - if (msg.type === 'getServerUrl') { - sendResponse({ url: getBaseUrl() }); - return true; - } - - // Token delivered via targeted sendResponse, not broadcast — limits exposure. - // Only respond to extension pages (sidepanel/popup) — content scripts have - // sender.tab set, so reject those to prevent token access from injected contexts. - if (msg.type === 'getToken') { - if (sender.tab) { - console.warn('[gstack] Rejected getToken from content script context'); - sendResponse({ token: null }); - } else { - sendResponse({ token: authToken }); - } - return true; - } - - if (msg.type === 'fetchRefs') { - fetchAndRelayRefs().then(() => sendResponse({ ok: true })); - return true; - } - - // Open side panel from content script pill click - if (msg.type === 'openSidePanel') { - if (chrome.sidePanel?.open && sender.tab) { - chrome.sidePanel.open({ tabId: sender.tab.id }).catch((err) => { - console.warn('[gstack bg] Failed to open side panel:', err.message); - }); - } - return; - } - - // Sidebar opened — tell active tab's content script so the welcome page - // can hide its arrow hint. Only fires when the sidebar actually connects. - if (msg.type === 'sidebarOpened') { - chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { - const tabId = tabs?.[0]?.id; - if (tabId) { - chrome.tabs.sendMessage(tabId, { type: 'sidebarOpened' }).catch(() => { - // Expected: tab may not have content script - }); - } - }); - return; - } - - // Inspector: inject + start picker - if (msg.type === 'startInspector') { - chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { - const tabId = tabs?.[0]?.id; - if (!tabId) { sendResponse({ error: 'No active tab' }); return; } - injectInspector(tabId).then(result => sendResponse(result)); - }); - return true; - } - - // Inspector: stop picker - if (msg.type === 'stopInspector') { - chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { - const tabId = tabs?.[0]?.id; - if (!tabId) { sendResponse({ error: 'No active tab' }); return; } - stopInspector(tabId).then(result => sendResponse(result)); - }); - return true; - } - - // Inspector: element picked by content script - if (msg.type === 'elementPicked') { - chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { - const activeTabUrl = tabs?.[0]?.url || null; - const frameInfo = msg.frameSrc ? { frameSrc: msg.frameSrc, frameName: msg.frameName } : null; - postInspectorPick(msg.selector, frameInfo, msg.basicData, activeTabUrl) - .then(result => { - // Forward enriched result to sidepanel - chrome.runtime.sendMessage({ - type: 'inspectResult', - data: { - ...result, - selector: msg.selector, - tagName: msg.tagName, - classes: msg.classes, - id: msg.id, - dimensions: msg.dimensions, - basicData: msg.basicData, - frameInfo, - }, - }).catch((err) => { - console.warn('[gstack bg] Failed to forward inspectResult to sidepanel:', err.message); - }); - sendResponse({ ok: true }); - }); - }); - return true; - } - - // Inspector: picker cancelled - if (msg.type === 'pickerCancelled') { - chrome.runtime.sendMessage({ type: 'pickerCancelled' }).catch((err) => { - console.debug('[gstack bg] No listener for pickerCancelled:', err.message); - }); - return; - } - - // Inspector: route alteration commands to content script - if (msg.type === 'applyStyle' || msg.type === 'toggleClass' || msg.type === 'injectCSS' || msg.type === 'resetAll') { - chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { - const tabId = tabs?.[0]?.id; - if (!tabId) { sendResponse({ error: 'No active tab' }); return; } - sendToContentScript(tabId, msg).then(result => sendResponse(result)); - }); - return true; - } - - // Sidebar → browse server command proxy - if (msg.type === 'command') { - executeCommand(msg.command, msg.args).then(result => sendResponse(result)); - return true; - } - - // Sidebar → Claude Code (file-based message queue) - if (msg.type === 'sidebar-command') { - const base = getBaseUrl(); - if (!base || !authToken) { - sendResponse({ error: 'Not connected' }); - return true; - } - // Capture the active tab's URL so the sidebar agent knows what page - // the user is actually looking at (Playwright's page.url() can be stale - // if the user navigated manually in headed mode). - chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { - const activeTabUrl = tabs?.[0]?.url || null; - fetch(`${base}/sidebar-command`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${authToken}`, - }, - body: JSON.stringify({ message: msg.message, activeTabUrl }), - }) - .then(r => { - if (!r.ok) { - console.error(`[gstack bg] sidebar-command failed: ${r.status} ${r.statusText}`); - return r.json().catch(() => ({ error: `Server returned ${r.status}` })); - } - return r.json(); - }) - .then(data => sendResponse(data)) - .catch(err => { - console.error('[gstack bg] sidebar-command error:', err.message); - sendResponse({ error: err.message }); - }); - }); - return true; - } -}); - -// ─── Side Panel ───────────────────────────────────────────────── - -// Click extension icon → open side panel directly (no popup) -if (chrome.sidePanel && chrome.sidePanel.setPanelBehavior) { - chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch((err) => { - console.warn('[gstack bg] Failed to set panel behavior:', err.message); - }); -} - -// Auto-open side panel with retry. chrome.sidePanel.open() can fail silently -// if the window/tab isn't fully ready yet. Retry up to 5 times with backoff. -async function autoOpenSidePanel() { - if (!chrome.sidePanel?.open) return; - for (let attempt = 0; attempt < 5; attempt++) { - try { - const wins = await chrome.windows.getAll({ windowTypes: ['normal'] }); - if (wins.length > 0) { - await chrome.sidePanel.open({ windowId: wins[0].id }); - console.log(`[gstack] Side panel opened on attempt ${attempt + 1}`); - return; // success - } - } catch (e) { - // May throw if window isn't ready or user gesture required - console.log(`[gstack] Side panel open attempt ${attempt + 1} failed:`, e.message); - } - // Backoff: 500ms, 1000ms, 2000ms, 3000ms, 5000ms - await new Promise(r => setTimeout(r, [500, 1000, 2000, 3000, 5000][attempt])); - } - console.log('[gstack] Side panel auto-open failed after 5 attempts'); -} - -// Fire on install/update -chrome.runtime.onInstalled.addListener(() => { - autoOpenSidePanel(); -}); - -// Fire on every service worker startup (covers persistent context reuse) -autoOpenSidePanel(); - -// ─── Tab Awareness ─────────────────────────────────────────────── -// Push live tab state to the sidepanel so claude in the Terminal pane -// always has up-to-date tabs.json + active-tab.json on disk. The -// sidepanel relays these to terminal-agent.ts over the live WebSocket; -// terminal-agent writes the files for claude to read. - -async function snapshotTabs() { - try { - const [active] = await chrome.tabs.query({ active: true, currentWindow: true }); - const all = await chrome.tabs.query({}); - const slim = all.map(t => ({ - tabId: t.id, - url: t.url || '', - title: t.title || '', - active: !!t.active, - windowId: t.windowId, - pinned: !!t.pinned, - audible: !!t.audible, - })); - return { - active: active ? { tabId: active.id, url: active.url || '', title: active.title || '' } : null, - tabs: slim, - }; - } catch { - return null; - } -} - -async function pushTabState(reason) { - const snapshot = await snapshotTabs(); - if (!snapshot) return; - chrome.runtime.sendMessage({ - type: 'browserTabState', - reason, - ...snapshot, - }).catch(() => {}); // expected: sidepanel may not be open -} - -chrome.tabs.onActivated.addListener((activeInfo) => { - // Keep the legacy event for any consumer still listening to it (the chat - // path is gone but the message type is harmless), and also fire the new - // unified state push so claude's tabs.json reflects the new active tab. - chrome.tabs.get(activeInfo.tabId, (tab) => { - if (chrome.runtime.lastError || !tab) return; - chrome.runtime.sendMessage({ - type: 'browserTabActivated', - tabId: activeInfo.tabId, - url: tab.url || '', - title: tab.title || '', - }).catch(() => {}); - }); - pushTabState('activated'); -}); - -chrome.tabs.onCreated.addListener(() => pushTabState('created')); -chrome.tabs.onRemoved.addListener(() => pushTabState('removed')); -chrome.tabs.onUpdated.addListener((_id, changeInfo) => { - // Throttle: only re-push on URL or title changes, not on every loading - // tick. We don't want to spam claude with a state push every 50ms while - // a page loads. - if (changeInfo.url || changeInfo.title || changeInfo.status === 'complete') { - pushTabState('updated'); - } -}); - -// ─── Startup ──────────────────────────────────────────────────── - -// Fast-retry health check on startup. The server may not be listening yet -// (Chromium launches before Bun.serve starts). Retry every 1s for the -// first 15 seconds, then switch to 10s polling. -loadAuthToken().then(() => { - loadPort().then(() => { - let startupAttempts = 0; - const startupCheck = setInterval(async () => { - startupAttempts++; - await checkHealth(); - if (isConnected || startupAttempts >= 15) { - clearInterval(startupCheck); - // Switch to slow polling now that we're connected (or gave up) - if (!healthInterval) { - healthInterval = setInterval(checkHealth, 10000); - } - if (!isConnected) { - console.log('[gstack] Startup health checks failed after 15 attempts, falling back to 10s polling'); - } - } - }, 1000); - }); -}); diff --git a/extension/content.css b/extension/content.css deleted file mode 100644 index 31d3f1ebb..000000000 --- a/extension/content.css +++ /dev/null @@ -1,124 +0,0 @@ -/* gstack browse — ref overlay + status pill styles - * Design system: DESIGN.md (amber accent, zinc neutrals) - */ - -#gstack-ref-overlays { - font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace !important; -} - -/* Connection status pill — bottom-right corner */ -#gstack-status-pill { - position: fixed; - bottom: 16px; - right: 16px; - z-index: 2147483646; - display: flex; - align-items: center; - gap: 6px; - padding: 6px 12px; - background: rgba(12, 12, 12, 0.85); - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); - border: 1px solid rgba(245, 158, 11, 0.25); - border-radius: 9999px; - color: #e0e0e0; - font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; - font-size: 11px; - font-weight: 500; - letter-spacing: 0.02em; - pointer-events: auto; - cursor: pointer; - transition: opacity 0.5s ease; - box-shadow: 0 2px 12px rgba(0, 0, 0, 0.4); -} - -#gstack-status-pill:hover { - opacity: 1 !important; -} - -.gstack-pill-dot { - width: 6px; - height: 6px; - border-radius: 50%; - background: #F59E0B; - box-shadow: 0 0 6px rgba(245, 158, 11, 0.5); - flex-shrink: 0; -} - -@media (prefers-reduced-motion: reduce) { - #gstack-status-pill { - transition: none; - } -} - -.gstack-ref-badge { - position: absolute; - background: rgba(220, 38, 38, 0.9); - color: #fff; - font-size: 10px; - font-weight: 700; - padding: 1px 4px; - border-radius: 4px; - line-height: 14px; - pointer-events: none; - z-index: 2147483647; -} - -/* Floating ref panel (used when positions are unknown) */ -.gstack-ref-panel { - position: fixed; - bottom: 12px; - right: 12px; - width: 220px; - max-height: 300px; - background: rgba(12, 12, 12, 0.95); - border: 1px solid #262626; - border-radius: 8px; - overflow: hidden; - pointer-events: auto; - box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5); - font-size: 11px; -} - -.gstack-ref-panel-header { - padding: 6px 10px; - background: #141414; - border-bottom: 1px solid #262626; - color: #FAFAFA; - font-weight: 600; - font-size: 11px; -} - -.gstack-ref-panel-list { - max-height: 260px; - overflow-y: auto; -} - -.gstack-ref-panel-row { - padding: 3px 10px; - border-bottom: 1px solid #1f1f1f; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.gstack-ref-panel-id { - color: #FBBF24; - font-weight: 600; - margin-right: 4px; -} - -.gstack-ref-panel-role { - color: #A1A1AA; - margin-right: 4px; -} - -.gstack-ref-panel-name { - color: #e0e0e0; -} - -.gstack-ref-panel-more { - padding: 4px 10px; - color: #52525B; - font-style: italic; -} diff --git a/extension/content.js b/extension/content.js deleted file mode 100644 index 8af84d7f7..000000000 --- a/extension/content.js +++ /dev/null @@ -1,378 +0,0 @@ -/** - * gstack browse — content script - * - * Receives ref data from background worker via chrome.runtime.onMessage. - * Renders @ref overlay badges on the page (CDP mode only — positions are accurate). - * In headless mode, shows a floating ref panel instead (positions unknown). - */ - -let overlayContainer = null; -let statusPill = null; -let pillFadeTimer = null; -let refCount = 0; - -// ─── Connection Status Pill ────────────────────────────────── - -function showStatusPill(connected, refs) { - refCount = refs || 0; - - if (!statusPill) { - statusPill = document.createElement('div'); - statusPill.id = 'gstack-status-pill'; - statusPill.style.cursor = 'pointer'; - statusPill.addEventListener('click', () => { - // Ask background to open the side panel - chrome.runtime.sendMessage({ type: 'openSidePanel' }); - }); - document.body.appendChild(statusPill); - } - - if (!connected) { - statusPill.style.display = 'none'; - return; - } - - const refText = refCount > 0 ? ` · ${refCount} refs` : ''; - statusPill.innerHTML = ` gstack${refText}`; - statusPill.style.display = 'flex'; - statusPill.style.opacity = '1'; - - // Fade to subtle after 3s - clearTimeout(pillFadeTimer); - pillFadeTimer = setTimeout(() => { - statusPill.style.opacity = '0.3'; - }, 3000); -} - -function hideStatusPill() { - if (statusPill) { - statusPill.style.display = 'none'; - } -} - -function ensureContainer() { - if (overlayContainer) return overlayContainer; - overlayContainer = document.createElement('div'); - overlayContainer.id = 'gstack-ref-overlays'; - overlayContainer.style.cssText = 'position: fixed; top: 0; left: 0; width: 0; height: 0; z-index: 2147483647; pointer-events: none;'; - document.body.appendChild(overlayContainer); - return overlayContainer; -} - -function clearOverlays() { - if (overlayContainer) { - overlayContainer.innerHTML = ''; - } -} - -function renderRefBadges(refs) { - clearOverlays(); - if (!refs || refs.length === 0) return; - - const container = ensureContainer(); - - for (const ref of refs) { - // Try to find the element using accessible name/role for positioning - // In CDP mode, we could use bounding boxes from the server - // For now, use a floating panel approach - const badge = document.createElement('div'); - badge.className = 'gstack-ref-badge'; - badge.textContent = ref.ref; - badge.title = `${ref.role}: "${ref.name}"`; - container.appendChild(badge); - } -} - -function renderRefPanel(refs) { - clearOverlays(); - if (!refs || refs.length === 0) return; - - const container = ensureContainer(); - - const panel = document.createElement('div'); - panel.className = 'gstack-ref-panel'; - - const header = document.createElement('div'); - header.className = 'gstack-ref-panel-header'; - header.textContent = `gstack refs (${refs.length})`; - header.style.cssText = 'pointer-events: auto; cursor: move;'; - panel.appendChild(header); - - const list = document.createElement('div'); - list.className = 'gstack-ref-panel-list'; - for (const ref of refs.slice(0, 30)) { // Show max 30 in panel - const row = document.createElement('div'); - row.className = 'gstack-ref-panel-row'; - const idSpan = document.createElement('span'); - idSpan.className = 'gstack-ref-panel-id'; - idSpan.textContent = ref.ref; - const roleSpan = document.createElement('span'); - roleSpan.className = 'gstack-ref-panel-role'; - roleSpan.textContent = ref.role; - const nameSpan = document.createElement('span'); - nameSpan.className = 'gstack-ref-panel-name'; - nameSpan.textContent = '"' + ref.name + '"'; - row.append(idSpan, document.createTextNode(' '), roleSpan, document.createTextNode(' '), nameSpan); - list.appendChild(row); - } - if (refs.length > 30) { - const more = document.createElement('div'); - more.className = 'gstack-ref-panel-more'; - more.textContent = `+${refs.length - 30} more`; - list.appendChild(more); - } - panel.appendChild(list); - container.appendChild(panel); -} - -// ─── Basic Inspector Picker (CSP fallback) ────────────────── -// When inspector.js can't be injected (CSP, chrome:// pages), content.js -// provides a basic element picker using getComputedStyle + CSSOM. - -let basicPickerActive = false; -let basicPickerOverlay = null; -let basicPickerLastEl = null; -let basicPickerSavedOutline = ''; - -const BASIC_KEY_PROPERTIES = [ - 'display', 'position', 'top', 'right', 'bottom', 'left', - 'width', 'height', 'min-width', 'max-width', 'min-height', 'max-height', - 'margin-top', 'margin-right', 'margin-bottom', 'margin-left', - 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', - 'border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width', - 'color', 'background-color', 'background-image', - 'font-family', 'font-size', 'font-weight', 'line-height', - 'text-align', 'text-decoration', - 'overflow', 'overflow-x', 'overflow-y', - 'opacity', 'z-index', - 'flex-direction', 'justify-content', 'align-items', 'flex-wrap', 'gap', - 'grid-template-columns', 'grid-template-rows', - 'box-shadow', 'border-radius', 'transform', -]; - -function captureBasicData(el) { - const computed = getComputedStyle(el); - const rect = el.getBoundingClientRect(); - - const computedStyles = {}; - for (const prop of BASIC_KEY_PROPERTIES) { - computedStyles[prop] = computed.getPropertyValue(prop); - } - - const boxModel = { - content: { width: rect.width, height: rect.height }, - padding: { - top: parseFloat(computed.paddingTop) || 0, - right: parseFloat(computed.paddingRight) || 0, - bottom: parseFloat(computed.paddingBottom) || 0, - left: parseFloat(computed.paddingLeft) || 0, - }, - border: { - top: parseFloat(computed.borderTopWidth) || 0, - right: parseFloat(computed.borderRightWidth) || 0, - bottom: parseFloat(computed.borderBottomWidth) || 0, - left: parseFloat(computed.borderLeftWidth) || 0, - }, - margin: { - top: parseFloat(computed.marginTop) || 0, - right: parseFloat(computed.marginRight) || 0, - bottom: parseFloat(computed.marginBottom) || 0, - left: parseFloat(computed.marginLeft) || 0, - }, - }; - - // Matched CSS rules via CSSOM (same-origin only) - const matchedRules = []; - try { - for (const sheet of document.styleSheets) { - try { - const rules = sheet.cssRules || sheet.rules; - if (!rules) continue; - for (const rule of rules) { - if (rule.type !== CSSRule.STYLE_RULE) continue; - try { - if (el.matches(rule.selectorText)) { - const properties = []; - for (let i = 0; i < rule.style.length; i++) { - const prop = rule.style[i]; - properties.push({ - name: prop, - value: rule.style.getPropertyValue(prop), - priority: rule.style.getPropertyPriority(prop), - }); - } - matchedRules.push({ - selector: rule.selectorText, - properties, - source: sheet.href || 'inline', - }); - } - } catch (e) { if (!(e instanceof TypeError) && !(e instanceof DOMException)) throw e; } - } - } catch (e) { if (!(e instanceof DOMException)) throw e; } - } - } catch (e) { if (!(e instanceof TypeError) && !(e instanceof DOMException)) throw e; } - - return { computedStyles, boxModel, matchedRules }; -} - -function basicBuildSelector(el) { - if (el.id) { - const sel = '#' + CSS.escape(el.id); - try { if (document.querySelectorAll(sel).length === 1) return sel; } catch (e) { if (!(e instanceof TypeError) && !(e instanceof DOMException)) throw e; } - } - const parts = []; - let current = el; - while (current && current !== document.body && current !== document.documentElement) { - let part = current.tagName.toLowerCase(); - if (current.id) { - parts.unshift('#' + CSS.escape(current.id)); - break; - } - if (current.className && typeof current.className === 'string') { - const classes = current.className.trim().split(/\s+/).filter(c => c.length > 0); - if (classes.length > 0) part += '.' + classes.map(c => CSS.escape(c)).join('.'); - } - const parent = current.parentElement; - if (parent) { - const siblings = Array.from(parent.children).filter(s => s.tagName === current.tagName); - if (siblings.length > 1) { - part += `:nth-child(${Array.from(parent.children).indexOf(current) + 1})`; - } - } - parts.unshift(part); - current = current.parentElement; - } - return parts.join(' > '); -} - -function basicPickerHighlight(el) { - // Restore previous element - if (basicPickerLastEl && basicPickerLastEl !== el) { - basicPickerLastEl.style.outline = basicPickerSavedOutline; - } - if (el) { - basicPickerSavedOutline = el.style.outline; - el.style.outline = '2px solid rgba(59, 130, 246, 0.6)'; - basicPickerLastEl = el; - } -} - -function basicPickerCleanup() { - if (basicPickerLastEl) { - basicPickerLastEl.style.outline = basicPickerSavedOutline; - basicPickerLastEl = null; - basicPickerSavedOutline = ''; - } - basicPickerActive = false; - document.removeEventListener('mousemove', onBasicMouseMove, true); - document.removeEventListener('click', onBasicClick, true); - document.removeEventListener('keydown', onBasicKeydown, true); -} - -function onBasicMouseMove(e) { - if (!basicPickerActive) return; - e.preventDefault(); - e.stopPropagation(); - const el = document.elementFromPoint(e.clientX, e.clientY); - if (el && el !== basicPickerLastEl) { - basicPickerHighlight(el); - } -} - -function onBasicClick(e) { - if (!basicPickerActive) return; - e.preventDefault(); - e.stopPropagation(); - const el = e.target; - - const basicData = captureBasicData(el); - const selector = basicBuildSelector(el); - const tagName = el.tagName.toLowerCase(); - const id = el.id || null; - const classes = el.className && typeof el.className === 'string' - ? el.className.trim().split(/\s+/).filter(c => c.length > 0) - : []; - - basicPickerCleanup(); - - chrome.runtime.sendMessage({ - type: 'inspectResult', - data: { - selector, - tagName, - id, - classes, - basicData, - mode: 'basic', - boxModel: basicData.boxModel, - computedStyles: basicData.computedStyles, - matchedRules: basicData.matchedRules, - }, - }); -} - -function onBasicKeydown(e) { - if (e.key === 'Escape') { - basicPickerCleanup(); - chrome.runtime.sendMessage({ type: 'pickerCancelled' }); - } -} - -function startBasicPicker() { - basicPickerActive = true; - document.addEventListener('mousemove', onBasicMouseMove, true); - document.addEventListener('click', onBasicClick, true); - document.addEventListener('keydown', onBasicKeydown, true); -} - -// Do NOT dispatch gstack-extension-ready here — the extension being loaded -// does not mean the sidebar is open. The welcome page arrow hint should only -// hide when the sidebar is actually open. We dispatch it when we receive -// a 'sidebarOpened' message from background.js. - -// Listen for messages from background worker -chrome.runtime.onMessage.addListener((msg) => { - // Sidebar actually opened — now hide the welcome page arrow hint - if (msg.type === 'sidebarOpened') { - document.dispatchEvent(new CustomEvent('gstack-extension-ready')); - return; - } - if (msg.type === 'startBasicPicker') { - startBasicPicker(); - return; - } - if (msg.type === 'stopBasicPicker') { - basicPickerCleanup(); - return; - } - if (msg.type === 'refs' && msg.data) { - const refs = msg.data.refs || []; - const mode = msg.data.mode; - - if (refs.length === 0) { - clearOverlays(); - showStatusPill(true, 0); - return; - } - - // CDP mode: could use bounding boxes (future) - // For now: floating panel for all modes - renderRefPanel(refs); - showStatusPill(true, refs.length); - } - - if (msg.type === 'clearRefs') { - clearOverlays(); - showStatusPill(true, 0); - } - - if (msg.type === 'connected') { - showStatusPill(true, refCount); - } - - if (msg.type === 'disconnected') { - hideStatusPill(); - clearOverlays(); - } -}); diff --git a/extension/icons/icon-128.png b/extension/icons/icon-128.png deleted file mode 100644 index bad5e886df0830964948362882ea3df2d4565efe..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2839 zcmb7Gc`(!u8~^TFH#rhNa^}cpMUGr07TF@_y6ao|G~%p>R*X+R~#_oiqZ^9#L0N$NFZYy=oYUvN(BO zP(HR-NBp6;XpE~$R^$}v#wSZcd(7h6bXE8_s#Rru{b3_DB7C5}b=$gKlwZ-kAJJF2 z46ce+#GsvdW<}Q2*dB_p3qt+^6gn?VYjs^+2>WtwmlmV$h36$^y^`<*J*_Tzurf~y zq{5cz1B^E@a>s&E*c?M7692d=>>JGTwiT&r>9`>1TA5?v2Gq52{2B!lMW1l9&>X`@ zNF#0YXXnc^iH#UQ`Doo$PG@FPqQnG}bD(b`*b{gq)OGSvl`yaSc6O65#GQ^(fk9Lt zE-gUl?WRGxlvwBZ2kTNvDmMKvzoDVQ@b*58mVb2#x+G&^VX;)tw>HJ0nM;Y9WwH9C zz?1sqm7!D?LgEAzpM^n4D4ZLMTUif}y)V|8W(^y;3qn9MoLOcJVhCvlpw2lqCq=2A z1?BlR2-z=el&6JS%ma^@ERd`7-4r$J9p{at(_rAZ5Tp7NEY&EK&@MzsQ|6Wbv985d z&Q*)SOq%{l3&!xu~rZ$`vB#NIA?{#)nLo!IFgPv(A zusht+ZsOCVaIT^G);#M_tDLSvb?-%Qq?H1itgBvv%Z7bFOcqSe1~IX1p%SC>;4WS$>X8pi?&lDjh3_qSQ*!$YLD0`}xT4Jm z_Q;H-7JARR4ppfMR#6Dd?xp*I#IbtBV>i>tIC{mZ`|*Qv6RdvwuWXjTecxUd;-K~$ zUlA*HVt-o$Z6ovjObtr5t!&1S&!Wgz^m6tKw+Y?mOa&i(x_8~T z@2kq;GahuCZ&H1(o)5N*m_qwk=!-s!>6=SCD>$HT6VT(y{|U#4kHLsIrp+x|99%n( z&SGjHS1PIGytl+mCpkYX{Cbak6u8^yyUYji?pgA1tB*8_JA0GZsAt0{upvWo-$e9D zrskSud!wETPlT# z_YP&hFX_13juX|eOF1>?Gj9L-4qR$qWQ0{C?W{1Opl!n*?+$k^lCWJe$JN3DVd*lG zQ`-SrZE24rgm}#xXE<1(2U(pLwvWa!yu8F$tn-FO{FxyfF5b*r2{plLD{W!!RR2* ziGpDV;*;XD&f6Zg4w<#6pbT_(2|1-WbY~lE`%}_Fjf%8H;k`zM%7p1;u;>9&7=lgB z<-u{ijZ_RPp6IY&`}BoTh~DsRz(NrfU*+iA-p{6J1WN+mdw+1?(|RYRWf4G7xj|yO z&0Pj5Mh-y;$-v{AG9V={$TF}mv&j@q-|k30M)YyLCm?Pn(51cxN*E@ zaea5#<*jK|4yJ$JDkQu}luxz#y=SPy z7mQL=JEoIi*|7t{)GVM3qB=wu&cQ@H*Z;Cc_T>%n@F_gps+50kkCL0qUkk{_!(@X+ zhKbC;Sb^V#?t+7)ROZ+E<|;0G*r@4zQBHc)#kMSsp;+YEbo-E$iD;16Y?FE1OzU37 z`0=WU5|8ux8M~Fg=sL$#%Z8f%So11mvhd0NRh6ZDv;cU5xuVrh+AabVsewH`SXHXU znjiSkt|wDqDKd6cRd6R^UOMNI@p{q1rv_MGxj+sC-qvhIC^>)QC}(o|80&C_1QEn% zT-{Hg60iUIWW#?FsDnmmBxD>)a$Gk2Q0(}%=yca740)PY0&F?%)&Om5=2fNj*L#%N z`LQk0lm4yj+cbVy^=Y!N^H;k%<8bkE(^ANoq$@!gR9R0vjY@@&C%|U%$cA`63Fq3 z&iYkkwKq6`Mu>yYzZX^!u*MURN7Dr3xyQ@kKnaLJj!ZUl6t!jkVxjf{6;aI#%+_#t zzsXXn?;=lnk3094)y3r0f`pbSpW^^AnPo59pX$I5;&_Ypx zrg5xA?&&^CYX8hPZ|+TM3P+KQJqr9N?{KMHqWbHqJvF~vUnb(-!sR<9qzEPXe( zj>2pIN*4^G5Vyy{$OfTj`FWkOoC{?%6~TUODOf2ETXl&yF~|;pC%9o4W}NRy zb3!L;DU1~$?kAnmI2?|v?O}PGn=}(V!KtRE<~pOkD_AH0yO~&L7?q||z+#;mbAEdp zr^iu?B87v$L!nCyvz>58cWI_WLfY;jm2!HG`YGcx`0fYmL^qpl%v)@{`xlkm@!H6h z=dm2eYz(xfK3n?{9w6UyFeM#FE_jo8OtG(z&(E-(nS-C7iH(gKSN!KYhkIOqfZRdR z5saQ+SfOH_bhN4AckbLdj*=r|8ifTn_+a$q9TJJSSk+B}&t#vion1)eaP;HpzZV3O z<1qDRy@}EMZLZEdZ-f1^c>JEVXgA-1C)Bh#aa`PESZp0h(c>(*_|283bb(uXM!My2 HXY79f!Omwz diff --git a/extension/icons/icon-16.png b/extension/icons/icon-16.png deleted file mode 100644 index e0f7b060bea7523f37d78194dc6ff76a5b22f4db..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 400 zcmV;B0dM|^P)?uvBfbDZXX}$9fT>&DnwQRD!WHRD|jINQqby27| ukJuH^&p4MF0FWOdpVLGnJ|H9~BJmH8t6|_Gbih(@Cwi9})+ zKmf#Iu`NP~Q$U@c3SH2nwQf!(lYfeMJRTk!8|&uQ5x+oBNl8hgWm(p9z|(%KF`#04 zditu=TDSSF#1tuIn*{FhTZk!I>lz6}{1)PYi1b^5SJn;qO<*+|Eeb<*g<^A56A5cl z(}Lk~xH2ladQIf@U3oJQwBYgkY+4&KJaCu7av0lR|B`&sE9vYG@Xw@oGorM#RC{ft zp;B?QHO=<2l{b}|g4e$aar~Tx@}R)8ftGrkH}<6o8II*==Ph3TG=#0p;yoqtLvh8M z`_07zJkeltWbd4Dys?3zFub)tO&||-taWFGJvFvbta0BRdN@P*M(5erKPY+c8;gNq zNu)s2RHZo3n4u_?{r@jKn&I>X%e4+813?Q8KA3TQezxD@nGZswGmC#a`J=^0hiBQ8 z<*0P`X2rJKH3P%$DwEL!XsA?_MzZ(*R9f)T$H7J77q~bmdH-AIKC8RKeI^@?z^-l1 z^Yv7(#mIznFTQgo$cN{GS)a|M-4{e71MwX7zU#N#gF0u=uL0R{zpxn@D9e$&e5?5kc=2=g?Q5 zoa2$|<%`|cO`6wQ(~fan3HNf0&jXLu+8jIREJo{jLEm4N%-Mn+<%-?+DmH|&zpg7G zd8#AqT8FU#OG4+fvovluPud&V_jZ`>OaIXn0W&c3-@33)lx7Z<>byXR=zWm6DbS#v|vs_~Zv~7bAHwut51n zO-sE^Q~&^;hI(=Yk_CyTVbMy^$y2nRH`6@k()I!RsxFJ)D3<`$UE z2>wp3<{SP8ccbeD{AS&N-$-D>ZxJ52CV@V`RhS}#=#xT-<9-Vfhdai3u-?%vm#bU8TQpz^1bq#Cp1Nejx;uobe Y0`2WmvSAIF8vp { - // Guard against double-injection - if (window.__gstackInspectorActive) return; - window.__gstackInspectorActive = true; - - // ─── State ────────────────────────────────────────────────────── - let pickerActive = false; - let highlightEl = null; - let tooltipEl = null; - let lastPickTime = 0; - const PICK_DEBOUNCE_MS = 200; - - // Track original inline styles for resetAll - const originalStyles = new Map(); // element -> Map - const injectedStyleIds = new Set(); - - // ─── Highlight Overlay ────────────────────────────────────────── - - function createHighlight() { - if (highlightEl) return; - - highlightEl = document.createElement('div'); - highlightEl.id = 'gstack-inspector-highlight'; - highlightEl.style.cssText = ` - position: fixed; - pointer-events: none; - z-index: 2147483647; - background: rgba(59, 130, 246, 0.15); - border: 2px solid rgba(59, 130, 246, 0.6); - border-radius: 2px; - transition: top 50ms, left 50ms, width 50ms, height 50ms; - `; - document.documentElement.appendChild(highlightEl); - - tooltipEl = document.createElement('div'); - tooltipEl.id = 'gstack-inspector-tooltip'; - tooltipEl.style.cssText = ` - position: fixed; - pointer-events: none; - z-index: 2147483647; - background: #27272A; - color: #e0e0e0; - font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace; - font-size: 11px; - padding: 3px 8px; - border-radius: 4px; - white-space: nowrap; - box-shadow: 0 2px 8px rgba(0,0,0,0.4); - display: none; - `; - document.documentElement.appendChild(tooltipEl); - } - - function removeHighlight() { - if (highlightEl) { highlightEl.remove(); highlightEl = null; } - if (tooltipEl) { tooltipEl.remove(); tooltipEl = null; } - } - - function updateHighlight(el) { - if (!highlightEl || !tooltipEl) return; - const rect = el.getBoundingClientRect(); - - highlightEl.style.top = rect.top + 'px'; - highlightEl.style.left = rect.left + 'px'; - highlightEl.style.width = rect.width + 'px'; - highlightEl.style.height = rect.height + 'px'; - highlightEl.style.display = 'block'; - - // Build tooltip text: .classes WxH - const tag = el.tagName.toLowerCase(); - const classes = el.className && typeof el.className === 'string' - ? '.' + el.className.trim().split(/\s+/).join('.') - : ''; - const dims = `${Math.round(rect.width)}x${Math.round(rect.height)}`; - tooltipEl.textContent = `<${tag}> ${classes} ${dims}`.trim(); - - // Position tooltip above element, or below if no room - const tooltipHeight = 24; - const gap = 6; - let tooltipTop = rect.top - tooltipHeight - gap; - if (tooltipTop < 4) tooltipTop = rect.bottom + gap; - let tooltipLeft = rect.left; - if (tooltipLeft < 4) tooltipLeft = 4; - - tooltipEl.style.top = tooltipTop + 'px'; - tooltipEl.style.left = tooltipLeft + 'px'; - tooltipEl.style.display = 'block'; - } - - // ─── Selector Generation ──────────────────────────────────────── - - function buildSelector(el) { - // If element has an id, use it directly - if (el.id) { - const sel = '#' + CSS.escape(el.id); - if (isUnique(sel)) return sel; - } - - // Build path from element up to nearest ancestor with id or body - const parts = []; - let current = el; - - while (current && current !== document.body && current !== document.documentElement) { - let part = current.tagName.toLowerCase(); - - // If current has an id, use it and stop - if (current.id) { - part = '#' + CSS.escape(current.id); - parts.unshift(part); - break; - } - - // Add classes - if (current.className && typeof current.className === 'string') { - const classes = current.className.trim().split(/\s+/).filter(c => c.length > 0); - if (classes.length > 0) { - part += '.' + classes.map(c => CSS.escape(c)).join('.'); - } - } - - // Add nth-child if needed to disambiguate - const parent = current.parentElement; - if (parent) { - const siblings = Array.from(parent.children).filter( - s => s.tagName === current.tagName - ); - if (siblings.length > 1) { - const idx = siblings.indexOf(current) + 1; - part += `:nth-child(${Array.from(parent.children).indexOf(current) + 1})`; - } - } - - parts.unshift(part); - current = current.parentElement; - } - - // If we didn't reach an id, prepend body - if (parts.length > 0 && !parts[0].startsWith('#')) { - // Don't prepend body, just use the path as-is - } - - const selector = parts.join(' > '); - - // Verify uniqueness - if (isUnique(selector)) return selector; - - // Fallback: add nth-child at each level until unique - return selector; - } - - function isUnique(selector) { - try { - return document.querySelectorAll(selector).length === 1; - } catch (e) { - if (!(e instanceof TypeError) && !(e instanceof DOMException)) throw e; - return false; - } - } - - // ─── Basic Mode Data Capture ──────────────────────────────────── - - const KEY_PROPERTIES = [ - 'display', 'position', 'top', 'right', 'bottom', 'left', - 'width', 'height', 'min-width', 'max-width', 'min-height', 'max-height', - 'margin-top', 'margin-right', 'margin-bottom', 'margin-left', - 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', - 'border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width', - 'border-top-style', 'border-right-style', 'border-bottom-style', 'border-left-style', - 'border-top-color', 'border-right-color', 'border-bottom-color', 'border-left-color', - 'color', 'background-color', 'background-image', - 'font-family', 'font-size', 'font-weight', 'line-height', 'letter-spacing', - 'text-align', 'text-decoration', 'text-transform', - 'overflow', 'overflow-x', 'overflow-y', - 'opacity', 'z-index', - 'flex-direction', 'justify-content', 'align-items', 'flex-wrap', 'gap', - 'grid-template-columns', 'grid-template-rows', - 'box-shadow', 'border-radius', - 'transition', 'transform', - ]; - - function captureBasicData(el) { - const computed = getComputedStyle(el); - const rect = el.getBoundingClientRect(); - - // Capture key computed properties - const computedStyles = {}; - for (const prop of KEY_PROPERTIES) { - computedStyles[prop] = computed.getPropertyValue(prop); - } - - // Box model from computed - const boxModel = { - content: { width: rect.width, height: rect.height }, - padding: { - top: parseFloat(computed.paddingTop) || 0, - right: parseFloat(computed.paddingRight) || 0, - bottom: parseFloat(computed.paddingBottom) || 0, - left: parseFloat(computed.paddingLeft) || 0, - }, - border: { - top: parseFloat(computed.borderTopWidth) || 0, - right: parseFloat(computed.borderRightWidth) || 0, - bottom: parseFloat(computed.borderBottomWidth) || 0, - left: parseFloat(computed.borderLeftWidth) || 0, - }, - margin: { - top: parseFloat(computed.marginTop) || 0, - right: parseFloat(computed.marginRight) || 0, - bottom: parseFloat(computed.marginBottom) || 0, - left: parseFloat(computed.marginLeft) || 0, - }, - }; - - // Matched CSS rules via CSSOM (same-origin only) - const matchedRules = []; - try { - for (const sheet of document.styleSheets) { - try { - const rules = sheet.cssRules || sheet.rules; - if (!rules) continue; - for (const rule of rules) { - if (rule.type !== CSSRule.STYLE_RULE) continue; - try { - if (el.matches(rule.selectorText)) { - const properties = []; - for (let i = 0; i < rule.style.length; i++) { - const prop = rule.style[i]; - properties.push({ - name: prop, - value: rule.style.getPropertyValue(prop), - priority: rule.style.getPropertyPriority(prop), - }); - } - matchedRules.push({ - selector: rule.selectorText, - properties, - source: sheet.href || 'inline', - }); - } - } catch (e) { if (!(e instanceof TypeError) && !(e instanceof DOMException)) throw e; } - } - } catch (e) { if (!(e instanceof DOMException)) throw e; } - } - } catch (e) { if (!(e instanceof TypeError) && !(e instanceof DOMException)) throw e; } - - return { computedStyles, boxModel, matchedRules }; - } - - // ─── Picker Event Handlers ────────────────────────────────────── - - function onMouseMove(e) { - if (!pickerActive) return; - // Ignore our own overlay elements - const target = e.target; - if (target === highlightEl || target === tooltipEl) return; - if (target.id === 'gstack-inspector-highlight' || target.id === 'gstack-inspector-tooltip') return; - - updateHighlight(target); - } - - function onClick(e) { - if (!pickerActive) return; - - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - - // Debounce - const now = Date.now(); - if (now - lastPickTime < PICK_DEBOUNCE_MS) return; - lastPickTime = now; - - const target = e.target; - if (target === highlightEl || target === tooltipEl) return; - if (target.id === 'gstack-inspector-highlight' || target.id === 'gstack-inspector-tooltip') return; - - const selector = buildSelector(target); - const basicData = captureBasicData(target); - - // Frame detection - const frameInfo = {}; - if (window !== window.top) { - try { - frameInfo.frameSrc = window.location.href; - frameInfo.frameName = window.name || null; - } catch (e) { if (!(e instanceof DOMException)) throw e; } - } - - chrome.runtime.sendMessage({ - type: 'elementPicked', - selector, - tagName: target.tagName.toLowerCase(), - classes: target.className && typeof target.className === 'string' - ? target.className.trim().split(/\s+/).filter(c => c.length > 0) - : [], - id: target.id || null, - dimensions: { - width: Math.round(target.getBoundingClientRect().width), - height: Math.round(target.getBoundingClientRect().height), - }, - basicData, - ...frameInfo, - }); - - // Keep highlight on the picked element - } - - function onKeyDown(e) { - if (!pickerActive) return; - if (e.key === 'Escape') { - e.preventDefault(); - e.stopPropagation(); - stopPicker(); - chrome.runtime.sendMessage({ type: 'pickerCancelled' }); - } - } - - // ─── Picker Start/Stop ────────────────────────────────────────── - - function startPicker() { - if (pickerActive) return; - pickerActive = true; - createHighlight(); - document.addEventListener('mousemove', onMouseMove, true); - document.addEventListener('click', onClick, true); - document.addEventListener('keydown', onKeyDown, true); - } - - function stopPicker() { - if (!pickerActive) return; - pickerActive = false; - removeHighlight(); - document.removeEventListener('mousemove', onMouseMove, true); - document.removeEventListener('click', onClick, true); - document.removeEventListener('keydown', onKeyDown, true); - } - - // ─── Page Alteration Handlers ─────────────────────────────────── - - function findElement(selector) { - try { - return document.querySelector(selector); - } catch (e) { - if (!(e instanceof TypeError) && !(e instanceof DOMException)) throw e; - return null; - } - } - - function applyStyle(selector, property, value) { - // Validate property name: alphanumeric + hyphens only - if (!/^[a-zA-Z-]+$/.test(property)) return { error: 'Invalid property name' }; - // Validate CSS value: block exfiltration vectors (url(), expression(), @import, javascript:, data:) - if (/url\s*\(|expression\s*\(|@import|javascript:|data:/i.test(value)) { - return { error: 'CSS value contains blocked pattern' }; - } - - const el = findElement(selector); - if (!el) return { error: 'Element not found' }; - - // Track original value for resetAll - if (!originalStyles.has(el)) { - originalStyles.set(el, new Map()); - } - const origMap = originalStyles.get(el); - if (!origMap.has(property)) { - origMap.set(property, el.style.getPropertyValue(property)); - } - - el.style.setProperty(property, value, 'important'); - return { ok: true }; - } - - function toggleClass(selector, className, action) { - if (!/^[a-zA-Z0-9_-]+$/.test(className)) { - return { error: 'Invalid class name' }; - } - const el = findElement(selector); - if (!el) return { error: 'Element not found' }; - - if (action === 'add') { - el.classList.add(className); - } else if (action === 'remove') { - el.classList.remove(className); - } else { - el.classList.toggle(className); - } - return { ok: true }; - } - - function injectCSS(id, css) { - if (!/^[a-zA-Z0-9_-]+$/.test(id)) { - return { error: 'Invalid CSS injection id' }; - } - if (/url\s*\(|expression\s*\(|@import|javascript:|data:/i.test(css)) { - return { error: 'CSS contains blocked pattern (url, expression, @import)' }; - } - const styleId = `gstack-inject-${id}`; - let styleEl = document.getElementById(styleId); - if (!styleEl) { - styleEl = document.createElement('style'); - styleEl.id = styleId; - document.head.appendChild(styleEl); - } - styleEl.textContent = css; - injectedStyleIds.add(styleId); - return { ok: true }; - } - - function resetAll() { - // Restore original inline styles - for (const [el, propMap] of originalStyles) { - for (const [prop, origVal] of propMap) { - if (origVal) { - el.style.setProperty(prop, origVal); - } else { - el.style.removeProperty(prop); - } - } - } - originalStyles.clear(); - - // Remove injected style elements - for (const id of injectedStyleIds) { - const el = document.getElementById(id); - if (el) el.remove(); - } - injectedStyleIds.clear(); - - return { ok: true }; - } - - // ─── Message Listener ────────────────────────────────────────── - - chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { - if (msg.type === 'startPicker') { - startPicker(); - sendResponse({ ok: true }); - return; - } - if (msg.type === 'stopPicker') { - stopPicker(); - sendResponse({ ok: true }); - return; - } - if (msg.type === 'applyStyle') { - const result = applyStyle(msg.selector, msg.property, msg.value); - sendResponse(result); - return; - } - if (msg.type === 'toggleClass') { - const result = toggleClass(msg.selector, msg.className, msg.action); - sendResponse(result); - return; - } - if (msg.type === 'injectCSS') { - const result = injectCSS(msg.id, msg.css); - sendResponse(result); - return; - } - if (msg.type === 'resetAll') { - const result = resetAll(); - sendResponse(result); - return; - } - }); -})(); diff --git a/extension/manifest.json b/extension/manifest.json deleted file mode 100644 index 962562646..000000000 --- a/extension/manifest.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "manifest_version": 3, - "name": "gstack browse", - "version": "0.1.0", - "description": "Live activity feed and @ref overlays for gstack browse", - "permissions": ["sidePanel", "storage", "activeTab", "scripting", "tabs"], - "host_permissions": ["http://127.0.0.1:*/", "ws://127.0.0.1:*/"], - "action": { - "default_icon": { - "16": "icons/icon-16.png", - "48": "icons/icon-48.png", - "128": "icons/icon-128.png" - } - }, - "side_panel": { - "default_path": "sidepanel.html" - }, - "background": { - "service_worker": "background.js" - }, - "content_scripts": [{ - "matches": [""], - "js": ["content.js"], - "css": ["content.css"] - }], - "icons": { - "16": "icons/icon-16.png", - "48": "icons/icon-48.png", - "128": "icons/icon-128.png" - } -} diff --git a/extension/popup.html b/extension/popup.html deleted file mode 100644 index e9959915e..000000000 --- a/extension/popup.html +++ /dev/null @@ -1,98 +0,0 @@ - - - - - - - -

gstack

- - - - -
-
- Disconnected -
-
- - - - - - diff --git a/extension/popup.js b/extension/popup.js deleted file mode 100644 index 68fa25af0..000000000 --- a/extension/popup.js +++ /dev/null @@ -1,60 +0,0 @@ -const portInput = document.getElementById('port'); -const dot = document.getElementById('dot'); -const statusText = document.getElementById('status-text'); -const details = document.getElementById('details'); -const sidePanelBtn = document.getElementById('side-panel-btn'); - -// Load saved port -chrome.runtime.sendMessage({ type: 'getPort' }, (resp) => { - if (resp && resp.port) { - portInput.value = resp.port; - updateStatus(resp.connected); - } -}); - -// Save port on change -let saveTimeout; -portInput.addEventListener('input', () => { - clearTimeout(saveTimeout); - saveTimeout = setTimeout(() => { - const port = parseInt(portInput.value, 10); - if (port > 0 && port < 65536) { - chrome.runtime.sendMessage({ type: 'setPort', port }); - } - }, 500); -}); - -// Listen for health updates -chrome.runtime.onMessage.addListener((msg) => { - if (msg.type === 'health') { - updateStatus(!!msg.data, msg.data); - } -}); - -function updateStatus(connected, data) { - dot.className = `dot ${connected ? 'connected' : ''}`; - statusText.className = `status-text ${connected ? 'connected' : ''}`; - statusText.textContent = connected ? 'Connected' : 'Disconnected'; - - if (connected && data) { - const parts = []; - if (data.tabs) parts.push(`${data.tabs} tabs`); - if (data.mode) parts.push(`Mode: ${data.mode}`); - details.textContent = parts.join(' \u00b7 '); - } else { - details.textContent = ''; - } -} - -// Open side panel -sidePanelBtn.addEventListener('click', async () => { - try { - const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); - if (tab) { - await chrome.sidePanel.open({ tabId: tab.id }); - window.close(); - } - } catch (err) { - details.textContent = `Side panel error: ${err.message}`; - } -}); diff --git a/extension/sidepanel-terminal.js b/extension/sidepanel-terminal.js deleted file mode 100644 index e6287abca..000000000 --- a/extension/sidepanel-terminal.js +++ /dev/null @@ -1,1024 +0,0 @@ -/** - * Terminal sidebar tab — interactive Claude Code PTY in xterm.js. - * - * Lifecycle (per plan + codex review): - * 1. Sidebar opens. Terminal is the default-active tab. - * 2. Bootstrap card shows "Press any key to start Claude Code." - * 3. On first keystroke (lazy spawn — codex finding #8): the extension - * a) POSTs /pty-session on the browse server with the AUTH_TOKEN to - * mint a short-lived HttpOnly cookie scoped to the terminal-agent. - * b) Opens ws://127.0.0.1:/ws — the cookie travels - * automatically. Terminal-agent validates the cookie + the - * chrome-extension:// Origin (codex finding #9), then spawns - * claude in a PTY. - * 4. Bytes pump both ways. Resize observer sends {type:"resize"} text - * frames; tab-switch hooks send {type:"tabSwitch"} frames. - * 5. PTY exits or WS closes -> we show "Session ended" with a restart - * button. We do NOT auto-reconnect (codex finding #8: auto-reconnect - * = burn fresh claude session every time). - * - * Keep this file dependency-free. xterm.js + xterm-addon-fit are loaded - * via - - - - - diff --git a/extension/sidepanel.js b/extension/sidepanel.js deleted file mode 100644 index 5856ebdfb..000000000 --- a/extension/sidepanel.js +++ /dev/null @@ -1,1415 +0,0 @@ -/** - * gstack browse — Side Panel - * - * Terminal pane (default): live claude PTY via xterm.js, driven by - * sidepanel-terminal.js. The chat queue + sidebar-agent.ts were ripped - * in favor of the interactive REPL — no more one-shot claude -p. - * - * Debug tabs (behind the `debug` toggle): activity feed (SSE) + refs + - * inspector. Quick-actions toolbar (Cleanup / Screenshot / Cookies) - * lives at the top of the Terminal pane. - */ - -const NAV_COMMANDS = new Set(['goto', 'back', 'forward', 'reload']); -const INTERACTION_COMMANDS = new Set(['click', 'fill', 'select', 'hover', 'type', 'press', 'scroll', 'wait', 'upload']); -const OBSERVE_COMMANDS = new Set(['snapshot', 'screenshot', 'diff', 'console', 'network', 'text', 'html', 'links', 'forms', 'accessibility', 'cookies', 'storage', 'perf']); - -let lastId = 0; -let eventSource = null; -let serverUrl = null; -let serverToken = null; -let connState = 'disconnected'; // disconnected | connected | reconnecting | dead -let reconnectAttempts = 0; -let reconnectTimer = null; -const MAX_RECONNECT_ATTEMPTS = 30; // 30 * 2s = 60s before showing "dead" - -// Auth headers for sidebar endpoints -function authHeaders() { - const h = { 'Content-Type': 'application/json' }; - if (serverToken) h['Authorization'] = `Bearer ${serverToken}`; - return h; -} - -// ─── Connection State Machine ───────────────────────────────────── - -function setConnState(state) { - const prev = connState; - connState = state; - const banner = document.getElementById('conn-banner'); - const bannerText = document.getElementById('conn-banner-text'); - const bannerActions = document.getElementById('conn-banner-actions'); - - if (state === 'connected') { - if (prev === 'reconnecting' || prev === 'dead') { - // Show "reconnected" toast that fades - banner.style.display = ''; - banner.className = 'conn-banner reconnected'; - bannerText.textContent = 'Reconnected'; - bannerActions.style.display = 'none'; - setTimeout(() => { banner.style.display = 'none'; }, 5000); - } else { - banner.style.display = 'none'; - } - reconnectAttempts = 0; - if (reconnectTimer) { clearInterval(reconnectTimer); reconnectTimer = null; } - } else if (state === 'reconnecting') { - banner.style.display = ''; - banner.className = 'conn-banner reconnecting'; - bannerText.textContent = `Reconnecting... (${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`; - bannerActions.style.display = 'none'; - } else if (state === 'dead') { - banner.style.display = ''; - banner.className = 'conn-banner dead'; - bannerText.textContent = 'Server offline'; - bannerActions.style.display = ''; - if (reconnectTimer) { clearInterval(reconnectTimer); reconnectTimer = null; } - } else { - banner.style.display = 'none'; - } -} - -function startReconnect() { - if (reconnectTimer) return; - setConnState('reconnecting'); - reconnectTimer = setInterval(() => { - reconnectAttempts++; - if (reconnectAttempts > MAX_RECONNECT_ATTEMPTS) { - setConnState('dead'); - return; - } - setConnState('reconnecting'); - tryConnect(); - }, 2000); -} - - -// ─── Chat path ripped ──────────────────────────────────────────── -// Chat queue + sendMessage + pollChat + switchChatTab + browser-tabs -// strip + security banner all lived here. Replaced by the interactive -// claude PTY in sidepanel-terminal.js (and terminal-agent.ts on the -// server side). - -// ─── Reload Sidebar ───────────────────────────────────────────── -document.getElementById('reload-sidebar').addEventListener('click', () => { - location.reload(); -}); - -// ─── Copy Cookies ─────────────────────────────────────────────── -document.getElementById('chat-cookies-btn').addEventListener('click', async () => { - if (!serverUrl) return; - // Navigate the browser to the cookie picker page hosted by the browse server - try { - await fetch(`${serverUrl}/command`, { - method: 'POST', - headers: authHeaders(), - body: JSON.stringify({ command: 'goto', args: [`${serverUrl}/cookie-picker`] }), - }); - } catch (err) { - console.error('[gstack sidebar] Failed to open cookie picker:', err.message); - } -}); - -// ─── Debug Tabs ───────────────────────────────────────────────── - -const debugToggle = document.getElementById('debug-toggle'); -const debugTabs = document.getElementById('debug-tabs'); -const closeDebug = document.getElementById('close-debug'); -let debugOpen = false; - -// The Terminal pane is the only primary surface; Activity / Refs / Inspector -// are debug overlays behind the `debug` toggle. Closing debug returns to -// the Terminal pane, which is always present. -const PRIMARY_PANE_ID = 'tab-terminal'; - -function showPrimaryPane() { - document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); - document.getElementById(PRIMARY_PANE_ID).classList.add('active'); - document.querySelectorAll('.debug-tabs .tab').forEach(t => t.classList.remove('active')); -} - -debugToggle.addEventListener('click', () => { - debugOpen = !debugOpen; - debugToggle.classList.toggle('active', debugOpen); - debugTabs.style.display = debugOpen ? 'flex' : 'none'; - if (!debugOpen) showPrimaryPane(); -}); - -closeDebug.addEventListener('click', () => { - debugOpen = false; - debugToggle.classList.remove('active'); - debugTabs.style.display = 'none'; - showPrimaryPane(); -}); - -document.querySelectorAll('.debug-tabs .tab:not(.close-debug)').forEach(tab => { - tab.addEventListener('click', () => { - document.querySelectorAll('.debug-tabs .tab').forEach(t => t.classList.remove('active')); - document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); - tab.classList.add('active'); - document.getElementById(`tab-${tab.dataset.tab}`).classList.add('active'); - - if (tab.dataset.tab === 'refs') fetchRefs(); - }); -}); - -// ─── Activity Feed ────────────────────────────────────────────── - -function getEntryClass(entry) { - if (entry.status === 'error') return 'error'; - if (entry.type === 'command_start') return 'pending'; - const cmd = entry.command || ''; - if (NAV_COMMANDS.has(cmd)) return 'nav'; - if (INTERACTION_COMMANDS.has(cmd)) return 'interaction'; - if (OBSERVE_COMMANDS.has(cmd)) return 'observe'; - return ''; -} - -function formatTime(ts) { - const d = new Date(ts); - return d.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }); -} - -let pendingEntries = new Map(); - -function createEntryElement(entry) { - const div = document.createElement('div'); - div.className = `activity-entry ${getEntryClass(entry)}`; - div.setAttribute('role', 'article'); - div.tabIndex = 0; - - const argsText = entry.args ? entry.args.join(' ') : ''; - const statusIcon = entry.status === 'ok' ? '\u2713' : entry.status === 'error' ? '\u2717' : ''; - const statusClass = entry.status === 'ok' ? 'ok' : entry.status === 'error' ? 'err' : ''; - const duration = entry.duration ? `${entry.duration}ms` : ''; - - div.innerHTML = ` -
- ${formatTime(entry.timestamp)} - ${escapeHtml(entry.command || entry.type)} -
- ${argsText ? `
${escapeHtml(argsText)}
` : ''} - ${entry.type === 'command_end' ? ` -
- ${statusIcon} - ${duration} -
- ` : ''} - ${entry.result ? ` -
-
${escapeHtml(entry.result)}
-
- ` : ''} - `; - - div.addEventListener('click', () => div.classList.toggle('expanded')); - return div; -} - -function addEntry(entry) { - const feed = document.getElementById('activity-feed'); - const empty = document.getElementById('empty-state'); - if (empty) empty.style.display = 'none'; - - if (entry.type === 'command_end') { - for (const [id, el] of pendingEntries) { - if (el.querySelector('.entry-command')?.textContent === entry.command) { - el.remove(); - pendingEntries.delete(id); - break; - } - } - } - - const el = createEntryElement(entry); - feed.appendChild(el); - if (entry.type === 'command_start') pendingEntries.set(entry.id, el); - el.scrollIntoView({ behavior: 'smooth', block: 'end' }); - - if (entry.url) document.getElementById('footer-url')?.textContent && (document.getElementById('footer-url').textContent = new URL(entry.url).hostname); - lastId = Math.max(lastId, entry.id); -} - -function escapeHtml(str) { - const div = document.createElement('div'); - div.textContent = str; - // DOM text-node serialization escapes &, <, > but NOT " or '. Call sites - // that interpolate escapeHtml output inside an attribute value (title="...", - // data-x="...") need those escaped too or an attacker-controlled value can - // break out of the attribute. Add both manually. - return div.innerHTML - .replace(/"/g, '"') - .replace(/'/g, '''); -} - -// ─── SSE Connection ───────────────────────────────────────────── - -// Fetch a view-only SSE session cookie before opening EventSource. -// EventSource can't send Authorization headers, and putting the root -// token in the URL (the old ?token= path) leaks it to logs, referer -// headers, and browser history. POST /sse-session issues an HttpOnly -// SameSite=Strict cookie scoped to SSE reads only; withCredentials:true -// on EventSource makes the browser send it back. -async function ensureSseSessionCookie() { - if (!serverUrl || !serverToken) return false; - try { - const resp = await fetch(`${serverUrl}/sse-session`, { - method: 'POST', - credentials: 'include', - headers: { 'Authorization': `Bearer ${serverToken}` }, - }); - return resp.ok; - } catch (err) { - console.warn('[gstack sidebar] Failed to mint SSE session cookie:', err && err.message); - return false; - } -} - -async function connectSSE() { - if (!serverUrl) return; - if (eventSource) { eventSource.close(); eventSource = null; } - - await ensureSseSessionCookie(); - const url = `${serverUrl}/activity/stream?after=${lastId}`; - eventSource = new EventSource(url, { withCredentials: true }); - - eventSource.addEventListener('activity', (e) => { - try { addEntry(JSON.parse(e.data)); } catch (err) { - console.error('[gstack sidebar] Failed to parse activity event:', err.message); - } - }); - - eventSource.addEventListener('gap', (e) => { - try { - const data = JSON.parse(e.data); - const feed = document.getElementById('activity-feed'); - const banner = document.createElement('div'); - banner.className = 'gap-banner'; - banner.textContent = `Missed ${data.availableFrom - data.gapFrom} events`; - feed.appendChild(banner); - } catch (err) { - console.error('[gstack sidebar] Failed to parse gap event:', err.message); - } - }); -} - -// ─── Memory Footer Readout ────────────────────────────────────── -// -// Polls /memory every 30s and renders "RSS: 1.4 GB · 12 tabs" in the -// footer. Backs off to 5min if a poll takes > 2s (Codex flag — diagnostic -// shouldn't add load when the browser is already unhealthy). Uses Bearer -// auth like /refs above; /memory is a plain GET so EventSource semantics -// don't apply. - -const MEM_POLL_FAST_MS = 30_000; -const MEM_POLL_SLOW_MS = 5 * 60_000; -const MEM_POLL_TIMEOUT_MS = 8_000; -const MEM_POLL_SLOW_THRESHOLD_MS = 2_000; -let memPollTimer = null; -let memPollMode = 'fast'; // 'fast' | 'slow' - -function fmtBytesShort(n) { - if (typeof n !== 'number' || isNaN(n)) return '?'; - if (n < 1024) return n + ' B'; - if (n < 1024 * 1024) return (n / 1024).toFixed(0) + ' KB'; - if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(0) + ' MB'; - return (n / 1024 / 1024 / 1024).toFixed(2) + ' GB'; -} - -function renderMemFooter(snapshot) { - const el = document.getElementById('footer-mem'); - if (!el) return; - const bunRss = snapshot?.bunServer?.rss ?? 0; - const tabCount = Array.isArray(snapshot?.tabs) ? snapshot.tabs.length : 0; - el.textContent = `${fmtBytesShort(bunRss)} · ${tabCount} tabs`; - // Color thresholds: ~2 GB Bun RSS or 50 tabs is "watch this"; ~8 GB or - // 200 tabs is "this is the cliff" (matches the 200-tab guardrail). - el.classList.remove('warn', 'bad'); - if (bunRss > 8 * 1024 * 1024 * 1024 || tabCount > 200) el.classList.add('bad'); - else if (bunRss > 2 * 1024 * 1024 * 1024 || tabCount > 50) el.classList.add('warn'); -} - -async function pollMemoryOnce() { - if (!serverUrl || !serverToken) return { ok: false, slow: false }; - const start = Date.now(); - try { - const resp = await fetch(`${serverUrl}/memory`, { - headers: { 'Authorization': `Bearer ${serverToken}` }, - signal: AbortSignal.timeout(MEM_POLL_TIMEOUT_MS), - credentials: 'include', - }); - const elapsed = Date.now() - start; - if (!resp.ok) return { ok: false, slow: elapsed > MEM_POLL_SLOW_THRESHOLD_MS }; - const snapshot = await resp.json(); - renderMemFooter(snapshot); - // Evaluate guardrail triggers (single-heavy-tab OR tab-count crossing 200). - // Toast is hidden when no trigger fires; snooze state suppresses re-fire. - try { evaluateMemToast(snapshot); } catch (err) { - console.debug('[gstack sidebar] mem-toast evaluation failed:', err && err.message); - } - return { ok: true, slow: elapsed > MEM_POLL_SLOW_THRESHOLD_MS }; - } catch (err) { - const elapsed = Date.now() - start; - // Don't log every poll failure — common during browser restarts / restoring - // sessions. Only log on the slow path so the user sees something in the - // console if the diagnostic itself is misbehaving. - if (elapsed > MEM_POLL_SLOW_THRESHOLD_MS) { - console.debug('[gstack sidebar] /memory poll slow/failed:', elapsed, 'ms', err && err.message); - } - return { ok: false, slow: elapsed > MEM_POLL_SLOW_THRESHOLD_MS }; - } -} - -function scheduleNextMemPoll(delayMs) { - if (memPollTimer) clearTimeout(memPollTimer); - memPollTimer = setTimeout(async () => { - const { ok, slow } = await pollMemoryOnce(); - if (!ok || slow) { - memPollMode = 'slow'; - scheduleNextMemPoll(MEM_POLL_SLOW_MS); - } else { - // Successful + fast → back to fast cadence. - if (memPollMode === 'slow') memPollMode = 'fast'; - scheduleNextMemPoll(MEM_POLL_FAST_MS); - } - }, delayMs); -} - -function startMemPolling() { - if (memPollTimer) return; // already running - // Kick off an immediate poll so the footer populates within ~1s of sidebar - // open, instead of waiting 30s for the first cycle. - scheduleNextMemPoll(500); -} - -function stopMemPolling() { - if (memPollTimer) { - clearTimeout(memPollTimer); - memPollTimer = null; - } -} - -// ─── Tab guardrail toast (D5 + Codex single-tab flag) ─────── -// -// Each /memory poll evaluates two trigger conditions: -// 1. Tab count crossed 200 — show "top 5 tabs by max(jsHeap, ...)" with -// Close-selected + Snooze. -// 2. Any single tab over 4 GB JS heap — show one-tab toast (catches the -// Codex case where a runaway WebGL/video page balloons one tab). -// Snooze persists in chrome.storage.session: next warn fires at tabCount + -// snoozeBumpTabs OR when a single tab crosses (snoozedJsHeapBytes + 1). -// -// "Close selected" runs $B closetab via the existing /command path — -// no chrome.tabs.remove bridge needed. - -const HEAVY_TAB_HEAP_BYTES = 4 * 1024 * 1024 * 1024; // 4 GB per Codex flag -const TOAST_SNOOZE_TAB_BUMP = 50; // re-warn at 200+50 -const TOAST_SNOOZE_HEAP_BUMP = 2 * 1024 * 1024 * 1024; - -const memToastSnooze = { - tabsAbove: 0, // suppress the count-toast until tabs strictly exceeds this - heapAbove: 0, // suppress the single-tab toast until heap strictly exceeds this -}; - -async function loadSnoozeState() { - if (!chrome?.storage?.session) return; - try { - const stored = await chrome.storage.session.get(['memToastSnooze']); - if (stored?.memToastSnooze) { - memToastSnooze.tabsAbove = stored.memToastSnooze.tabsAbove | 0; - memToastSnooze.heapAbove = stored.memToastSnooze.heapAbove | 0; - } - } catch (err) { - console.debug('[gstack sidebar] mem-toast snooze load failed:', err && err.message); - } -} - -async function saveSnoozeState() { - if (!chrome?.storage?.session) return; - try { - await chrome.storage.session.set({ memToastSnooze: { ...memToastSnooze } }); - } catch (err) { - console.debug('[gstack sidebar] mem-toast snooze save failed:', err && err.message); - } -} - -function dismissMemToast() { - const toast = document.getElementById('mem-toast'); - if (toast) toast.style.display = 'none'; -} - -/** - * Sort key for "RAM-heavy" tabs. JS heap × 4 is a rough proxy for total - * tab footprint (renderers tend to spend ~4× their JS heap on native + - * Skia + cache); when a tab is heavy via WebGL/video the JS heap is - * small but listeners/nodes spike. Take the max. - */ -function tabRamScore(tab) { - const heap = tab?.jsHeapUsed || 0; - const nodes = tab?.nodes || 0; - const listeners = tab?.listeners || 0; - // ~1 KB per DOM node + ~200 bytes per listener as a back-of-envelope - // native-memory estimate. Keeps the sort meaningful when JS heap is small. - const nativeEstimate = nodes * 1024 + listeners * 200; - return Math.max(heap, nativeEstimate); -} - -function showMemToast(title, body, tabsForClose) { - const toast = document.getElementById('mem-toast'); - const titleEl = document.getElementById('mem-toast-title'); - const bodyEl = document.getElementById('mem-toast-body'); - const closeBtn = document.getElementById('mem-toast-close-selected'); - if (!toast || !titleEl || !bodyEl || !closeBtn) return; - - titleEl.textContent = title; - bodyEl.innerHTML = ''; - - for (const t of tabsForClose) { - const row = document.createElement('div'); - row.className = 'mem-toast-row'; - const cb = document.createElement('input'); - cb.type = 'checkbox'; - cb.id = `mem-toast-tab-${t.id}`; - cb.value = String(t.id); - cb.checked = true; // default-selected so a fast user just hits Close - const label = document.createElement('label'); - label.htmlFor = cb.id; - const urlShort = (t.url || '').length > 50 ? t.url.slice(0, 47) + '...' : (t.url || '(no url)'); - label.textContent = `tab #${t.id} — ${urlShort}`; - const size = document.createElement('span'); - size.className = 'mem-toast-size'; - size.textContent = fmtBytesShort(tabRamScore(t)); - row.appendChild(cb); - row.appendChild(label); - row.appendChild(size); - bodyEl.appendChild(row); - } - - toast.style.display = ''; - - closeBtn.onclick = async () => { - const ids = tabsForClose - .filter((t) => document.getElementById(`mem-toast-tab-${t.id}`)?.checked) - .map((t) => t.id); - dismissMemToast(); - for (const id of ids) { - try { - await fetch(`${serverUrl}/command`, { - method: 'POST', - headers: authHeaders(), - body: JSON.stringify({ command: 'closetab', args: [String(id)] }), - }); - } catch (err) { - console.warn('[gstack sidebar] mem-toast closetab failed:', id, err && err.message); - } - } - }; -} - -/** - * Driven by every successful /memory poll. Decides whether to surface - * the toast and which payload to show. - */ -function evaluateMemToast(snapshot) { - if (!snapshot || !Array.isArray(snapshot.tabs)) return; - const tabs = snapshot.tabs; - - // Trigger 1: any single tab over 4 GB JS heap. Catches the WebGL/video - // case before the tab count threshold ever fires. - const heavyTab = tabs.find((t) => (t.jsHeapUsed || 0) > HEAVY_TAB_HEAP_BYTES); - if (heavyTab && (heavyTab.jsHeapUsed || 0) > memToastSnooze.heapAbove) { - showMemToast( - `Heavy tab: ${fmtBytesShort(heavyTab.jsHeapUsed)} JS heap`, - '', - [heavyTab], - ); - return; - } - - // Trigger 2: tab count crossed the hard guardrail (200) and isn't snoozed. - if (tabs.length >= 200 && tabs.length > memToastSnooze.tabsAbove) { - const top5 = [...tabs].sort((a, b) => tabRamScore(b) - tabRamScore(a)).slice(0, 5); - showMemToast( - `${tabs.length} tabs open — close some?`, - '', - top5, - ); - return; - } - - // No trigger: keep toast hidden. -} - -function setupMemToastWiring() { - const close = document.getElementById('mem-toast-close'); - if (close) close.addEventListener('click', dismissMemToast); - const snooze = document.getElementById('mem-toast-snooze'); - if (snooze) { - snooze.addEventListener('click', async () => { - // Snooze logic: bump the thresholds above the current snapshot so the - // toast won't re-fire until the user has accumulated MORE tabs or one - // tab has grown ANOTHER 2 GB beyond what we just warned about. Stored - // in chrome.storage.session so a sidebar reload doesn't lose the - // snooze (but a Chrome restart does). - try { - const resp = await fetch(`${serverUrl}/memory`, { - headers: { 'Authorization': `Bearer ${serverToken}` }, - signal: AbortSignal.timeout(MEM_POLL_TIMEOUT_MS), - credentials: 'include', - }); - if (resp.ok) { - const snap = await resp.json(); - const tabs = Array.isArray(snap.tabs) ? snap.tabs : []; - memToastSnooze.tabsAbove = tabs.length + TOAST_SNOOZE_TAB_BUMP; - const maxHeap = tabs.reduce((m, t) => Math.max(m, t.jsHeapUsed || 0), 0); - memToastSnooze.heapAbove = maxHeap + TOAST_SNOOZE_HEAP_BUMP; - await saveSnoozeState(); - } - } catch (err) { - console.debug('[gstack sidebar] mem-toast snooze fetch failed:', err && err.message); - } - dismissMemToast(); - }); - } - void loadSnoozeState(); -} - -// Wire the toast on DOM ready. -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', setupMemToastWiring); -} else { - setupMemToastWiring(); -} - -// ─── Refs Tab ─────────────────────────────────────────────────── - -async function fetchRefs() { - if (!serverUrl) return; - try { - const headers = {}; - if (serverToken) headers['Authorization'] = `Bearer ${serverToken}`; - const resp = await fetch(`${serverUrl}/refs`, { signal: AbortSignal.timeout(3000), headers }); - if (!resp.ok) return; - const data = await resp.json(); - - const list = document.getElementById('refs-list'); - const empty = document.getElementById('refs-empty'); - const footer = document.getElementById('refs-footer'); - - if (!data.refs || data.refs.length === 0) { - empty.style.display = ''; - list.innerHTML = ''; - footer.textContent = ''; - return; - } - - empty.style.display = 'none'; - list.innerHTML = data.refs.map(r => ` -
- ${escapeHtml(r.ref)} - ${escapeHtml(r.role)} - "${escapeHtml(r.name)}" -
- `).join(''); - footer.textContent = `${data.refs.length} refs`; - } catch (err) { - console.error('[gstack sidebar] Failed to fetch refs:', err.message); - } -} - -// ─── Inspector Tab ────────────────────────────────────────────── - -let inspectorPickerActive = false; -let inspectorData = null; // last inspect result -let inspectorModifications = []; // tracked style changes -let inspectorSSE = null; - -// Inspector DOM refs -const inspectorPickBtn = document.getElementById('inspector-pick-btn'); -const inspectorSelected = document.getElementById('inspector-selected'); -const inspectorModeBadge = document.getElementById('inspector-mode-badge'); -const inspectorEmpty = document.getElementById('inspector-empty'); -const inspectorLoading = document.getElementById('inspector-loading'); -const inspectorError = document.getElementById('inspector-error'); -const inspectorPanels = document.getElementById('inspector-panels'); -const inspectorBoxmodel = document.getElementById('inspector-boxmodel'); -const inspectorRules = document.getElementById('inspector-rules'); -const inspectorRuleCount = document.getElementById('inspector-rule-count'); -const inspectorComputed = document.getElementById('inspector-computed'); -const inspectorQuickedit = document.getElementById('inspector-quickedit'); -const inspectorSend = document.getElementById('inspector-send'); -const inspectorSendBtn = document.getElementById('inspector-send-btn'); - -// Pick button -inspectorPickBtn.addEventListener('click', () => { - if (inspectorPickerActive) { - inspectorPickerActive = false; - inspectorPickBtn.classList.remove('active'); - chrome.runtime.sendMessage({ type: 'stopInspector' }); - } else { - inspectorPickerActive = true; - inspectorPickBtn.classList.add('active'); - inspectorShowLoading(false); // don't show loading yet, just activate - chrome.runtime.sendMessage({ type: 'startInspector' }, (result) => { - if (result?.error) { - inspectorPickerActive = false; - inspectorPickBtn.classList.remove('active'); - inspectorShowError(result.error); - } - }); - } -}); - -function inspectorShowEmpty() { - inspectorEmpty.style.display = ''; - inspectorLoading.style.display = 'none'; - inspectorError.style.display = 'none'; - inspectorPanels.style.display = 'none'; - inspectorSend.style.display = 'none'; -} - -function inspectorShowLoading(show) { - if (show) { - inspectorEmpty.style.display = 'none'; - inspectorLoading.style.display = ''; - inspectorError.style.display = 'none'; - inspectorPanels.style.display = 'none'; - } else { - inspectorLoading.style.display = 'none'; - } -} - -function inspectorShowError(message) { - inspectorEmpty.style.display = 'none'; - inspectorLoading.style.display = 'none'; - inspectorError.style.display = ''; - inspectorError.textContent = message; - inspectorPanels.style.display = 'none'; -} - -function inspectorShowData(data) { - inspectorData = data; - inspectorModifications = []; - inspectorEmpty.style.display = 'none'; - inspectorLoading.style.display = 'none'; - inspectorError.style.display = 'none'; - inspectorPanels.style.display = ''; - inspectorSend.style.display = ''; - - // Update toolbar - const tag = data.tagName || '?'; - const cls = data.classes && data.classes.length > 0 ? '.' + data.classes.join('.') : ''; - const idStr = data.id ? '#' + data.id : ''; - inspectorSelected.textContent = `<${tag}>${idStr}${cls}`; - inspectorSelected.title = data.selector; - - // Mode badge - if (data.mode === 'basic') { - inspectorModeBadge.textContent = 'Basic mode'; - inspectorModeBadge.style.display = ''; - inspectorModeBadge.className = 'inspector-mode-badge basic'; - } else if (data.mode === 'cdp') { - inspectorModeBadge.textContent = 'CDP'; - inspectorModeBadge.style.display = ''; - inspectorModeBadge.className = 'inspector-mode-badge cdp'; - } else { - inspectorModeBadge.style.display = 'none'; - } - - // Render sections - renderBoxModel(data); - renderMatchedRules(data); - renderComputedStyles(data); - renderQuickEdit(data); - updateSendButton(); -} - -// ─── Box Model Rendering ──────────────────────────────────────── - -function renderBoxModel(data) { - const box = data.basicData?.boxModel || data.boxModel; - if (!box) { inspectorBoxmodel.innerHTML = 'No box model data'; return; } - - const m = box.margin || {}; - const b = box.border || {}; - const p = box.padding || {}; - const c = box.content || {}; - - inspectorBoxmodel.innerHTML = ` -
- margin - ${fmtBoxVal(m.top)} - ${fmtBoxVal(m.right)} - ${fmtBoxVal(m.bottom)} - ${fmtBoxVal(m.left)} -
- border - ${fmtBoxVal(b.top)} - ${fmtBoxVal(b.right)} - ${fmtBoxVal(b.bottom)} - ${fmtBoxVal(b.left)} -
- padding - ${fmtBoxVal(p.top)} - ${fmtBoxVal(p.right)} - ${fmtBoxVal(p.bottom)} - ${fmtBoxVal(p.left)} -
- ${Math.round(c.width || 0)} x ${Math.round(c.height || 0)} -
-
-
-
- `; -} - -function fmtBoxVal(v) { - if (v === undefined || v === null) return '-'; - const n = typeof v === 'number' ? v : parseFloat(v); - if (isNaN(n) || n === 0) return '0'; - return Math.round(n * 10) / 10; -} - -// ─── Matched Rules Rendering ──────────────────────────────────── - -function renderMatchedRules(data) { - const rules = data.matchedRules || data.basicData?.matchedRules || []; - inspectorRuleCount.textContent = rules.length > 0 ? `(${rules.length})` : ''; - - if (rules.length === 0) { - inspectorRules.innerHTML = '
No matched rules
'; - return; - } - - // Separate UA rules from author rules - const authorRules = []; - const uaRules = []; - for (const rule of rules) { - if (rule.origin === 'user-agent' || rule.isUA) { - uaRules.push(rule); - } else { - authorRules.push(rule); - } - } - - let html = ''; - - // Author rules (expanded) - for (const rule of authorRules) { - html += renderRule(rule, false); - } - - // UA rules (collapsed by default) - if (uaRules.length > 0) { - html += ` -
- -
'; - } - - inspectorRules.innerHTML = html; - - // Bind UA toggle - const uaToggle = inspectorRules.querySelector('.inspector-ua-toggle'); - if (uaToggle) { - uaToggle.addEventListener('click', () => { - const body = inspectorRules.querySelector('.inspector-ua-body'); - const isCollapsed = uaToggle.classList.contains('collapsed'); - uaToggle.classList.toggle('collapsed', !isCollapsed); - uaToggle.setAttribute('aria-expanded', isCollapsed); - uaToggle.querySelector('.inspector-toggle-arrow').innerHTML = isCollapsed ? '▼' : '▶'; - body.classList.toggle('collapsed', !isCollapsed); - }); - } -} - -function renderRule(rule, isUA) { - const selectorText = escapeHtml(rule.selector || ''); - const truncatedSelector = selectorText.length > 35 ? selectorText.slice(0, 35) + '...' : selectorText; - const source = rule.source || ''; - const sourceDisplay = source.includes('/') ? source.split('/').pop() : source; - const specificity = rule.specificity || ''; - - let propsHtml = ''; - const props = rule.properties || []; - for (const prop of props) { - const overridden = prop.overridden ? ' overridden' : ''; - const nameHtml = escapeHtml(prop.name); - const valText = escapeHtml(prop.value || ''); - const truncatedVal = valText.length > 30 ? valText.slice(0, 30) + '...' : valText; - const priority = prop.priority === 'important' ? ' !important' : ''; - propsHtml += `
${nameHtml}: ${truncatedVal}${priority};
`; - } - - return ` -
-
- ${truncatedSelector} - ${specificity ? `${escapeHtml(specificity)}` : ''} -
-
${propsHtml}
- ${sourceDisplay ? `
${escapeHtml(sourceDisplay)}
` : ''} -
- `; -} - -// ─── Computed Styles Rendering ────────────────────────────────── - -function renderComputedStyles(data) { - const styles = data.computedStyles || data.basicData?.computedStyles || {}; - const keys = Object.keys(styles); - - if (keys.length === 0) { - inspectorComputed.innerHTML = '
No computed styles
'; - return; - } - - let html = ''; - for (const key of keys) { - const val = styles[key]; - if (!val || val === 'none' || val === 'normal' || val === 'auto' || val === '0px' || val === 'rgba(0, 0, 0, 0)') continue; - html += `
${escapeHtml(key)}: ${escapeHtml(val)}
`; - } - - if (!html) { - html = '
All values are defaults
'; - } - - inspectorComputed.innerHTML = html; -} - -// ─── Quick Edit ───────────────────────────────────────────────── - -function renderQuickEdit(data) { - const selector = data.selector; - if (!selector) { inspectorQuickedit.innerHTML = ''; return; } - - // Show common editable properties with current values - const editableProps = ['color', 'background-color', 'font-size', 'padding', 'margin', 'border', 'display', 'opacity']; - const computed = data.computedStyles || data.basicData?.computedStyles || {}; - - let html = '
'; - for (const prop of editableProps) { - const val = computed[prop] || ''; - html += ` -
- ${escapeHtml(prop)}: - ${escapeHtml(val || '(none)')} -
- `; - } - html += '
'; - inspectorQuickedit.innerHTML = html; - - // Bind click-to-edit - inspectorQuickedit.querySelectorAll('.inspector-quickedit-value').forEach(el => { - el.addEventListener('click', () => startQuickEdit(el)); - el.addEventListener('keydown', (e) => { - if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); startQuickEdit(el); } - }); - }); -} - -function startQuickEdit(valueEl) { - if (valueEl.querySelector('input')) return; // already editing - - const currentVal = valueEl.textContent === '(none)' ? '' : valueEl.textContent; - const prop = valueEl.dataset.prop; - const selector = valueEl.dataset.selector; - - const input = document.createElement('input'); - input.type = 'text'; - input.className = 'inspector-quickedit-input'; - input.value = currentVal; - valueEl.textContent = ''; - valueEl.appendChild(input); - input.focus(); - input.select(); - - function commit() { - const newVal = input.value.trim(); - valueEl.textContent = newVal || '(none)'; - if (newVal && newVal !== currentVal) { - chrome.runtime.sendMessage({ - type: 'applyStyle', - selector, - property: prop, - value: newVal, - }); - inspectorModifications.push({ property: prop, value: newVal, selector }); - updateSendButton(); - } - } - - function cancel() { - valueEl.textContent = currentVal || '(none)'; - } - - input.addEventListener('blur', commit); - input.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { e.preventDefault(); input.blur(); } - if (e.key === 'Escape') { e.preventDefault(); input.removeEventListener('blur', commit); cancel(); } - }); -} - -// ─── Send to Agent ────────────────────────────────────────────── - -function updateSendButton() { - if (inspectorModifications.length > 0) { - inspectorSendBtn.textContent = 'Send to Code'; - inspectorSendBtn.title = `${inspectorModifications.length} modification(s) to send`; - } else { - inspectorSendBtn.textContent = 'Send to Agent'; - inspectorSendBtn.title = 'Send full inspector data'; - } -} - -inspectorSendBtn.addEventListener('click', async () => { - if (!inspectorData) return; - - let message; - if (inspectorModifications.length > 0) { - // Format modification diff - const diffs = inspectorModifications.map(m => - ` ${m.property}: ${m.value} (selector: ${m.selector})` - ).join('\n'); - message = `CSS Inspector modifications:\n\nSelector: ${inspectorData.selector}\n\nChanges:\n${diffs}`; - - // Include source file info if available - const rules = inspectorData.matchedRules || inspectorData.basicData?.matchedRules || []; - const sources = rules.filter(r => r.source && r.source !== 'inline').map(r => r.source); - if (sources.length > 0) { - message += `\n\nSource files:\n${[...new Set(sources)].map(s => ` ${s}`).join('\n')}`; - } - } else { - // Send full inspector data - message = `CSS Inspector data for: ${inspectorData.selector}\n\n${JSON.stringify(inspectorData, null, 2)}`; - } - - // Inject into the running claude PTY so the user can ask claude to act - // on the inspector data. Replaces the old `sidebar-command` route which - // spawned a one-shot claude -p (sidebar-agent.ts is gone). - // - // Pre-scan via /pty-inject-scan before injection (D6, closes #1370). - // gstackScanForPTYInject is async; gstackInjectToTerminal stays sync. - const verdict = await window.gstackScanForPTYInject?.(message + '\n', 'inspector-send'); - if (verdict?.verdict === 'BLOCK') { - console.warn('[gstack sidebar] Inspector send BLOCKED by /pty-inject-scan:', verdict.reasons); - return; - } - if (verdict?.verdict === 'WARN') { - const confirmed = window.confirm( - `Inspector send flagged as suspicious (${(verdict.reasons || []).join(', ')}). Inject anyway?`, - ); - if (!confirmed) return; - } - const ok = window.gstackInjectToTerminal?.(message + '\n'); - if (!ok) { - console.warn('[gstack sidebar] Inspector send needs an active Terminal session.'); - } -}); - -// ─── Quick Action Helpers (toolbar buttons) ────────────────────── - -/** - * "Cleanup" injects a prompt into the running claude PTY. claude takes the - * prompt, snapshots the page, hides ads/banners/popups, leaves article - * content. The user watches it happen in the Terminal pane. - * - * Replaced the old chat-queue path (sidebar-agent.ts spawning a one-shot - * claude -p) — we have a live REPL now, so route through that instead. - */ -async function runCleanup(...buttons) { - buttons.forEach(b => b?.classList.add('loading')); - const cleanupPrompt = [ - 'Clean up the active browser page for reading. Run:', - '$B cleanup --all', - 'then $B snapshot -i, identify any remaining ads, cookie/consent banners,', - 'newsletter popups, login walls, video autoplay, sidebar widgets, share', - 'buttons, floating chat widgets, and hide each via $B eval. Keep the site', - 'header/masthead, headline, article body, images, byline, and date. Also', - 'unlock scrolling if the page is scroll-locked.', - ].join('\n'); - // Pre-scan via /pty-inject-scan before injection (D6, closes #1370). - // The cleanup prompt is a STATIC template (no page-derived content), so - // it will always PASS, but we still route it through the scan path so - // the invariant test in test/extension-pty-inject-invariant.test.ts - // confirms every call site goes through gstackScanForPTYInject first. - const verdict = await window.gstackScanForPTYInject?.(cleanupPrompt + '\n', 'cleanup-button'); - if (verdict?.verdict === 'BLOCK') { - console.warn('[gstack sidebar] Cleanup BLOCKED by /pty-inject-scan:', verdict.reasons); - setTimeout(() => buttons.forEach(b => b?.classList.remove('loading')), 200); - return; - } - if (verdict?.verdict === 'WARN') { - const confirmed = window.confirm( - `Cleanup flagged as suspicious (${(verdict.reasons || []).join(', ')}). Inject anyway?`, - ); - if (!confirmed) { - setTimeout(() => buttons.forEach(b => b?.classList.remove('loading')), 200); - return; - } - } - const sent = window.gstackInjectToTerminal?.(cleanupPrompt + '\n'); - if (!sent) { - console.warn('[gstack sidebar] Cleanup needs an active Terminal session.'); - } - setTimeout(() => buttons.forEach(b => b?.classList.remove('loading')), 1200); -} - -async function runScreenshot(...buttons) { - if (!serverUrl || !serverToken) return; - buttons.forEach(b => b?.classList.add('loading')); - try { - const resp = await fetch(`${serverUrl}/command`, { - method: 'POST', - headers: { ...authHeaders(), 'Content-Type': 'application/json' }, - body: JSON.stringify({ command: 'screenshot', args: [] }), - signal: AbortSignal.timeout(15000), - }); - const text = await resp.text(); - if (!resp.ok) { - console.warn('[gstack sidebar] Screenshot failed:', text); - } else { - console.log('[gstack sidebar] Screenshot:', text); - } - } catch (err) { - console.error('[gstack sidebar] Screenshot error:', err.message); - } finally { - buttons.forEach(b => b?.classList.remove('loading')); - } -} - -// ─── Wire up all cleanup/screenshot buttons (inspector + chat toolbar) ── - -const inspectorCleanupBtn = document.getElementById('inspector-cleanup-btn'); -const inspectorScreenshotBtn = document.getElementById('inspector-screenshot-btn'); -const chatCleanupBtn = document.getElementById('chat-cleanup-btn'); -const chatScreenshotBtn = document.getElementById('chat-screenshot-btn'); - -if (inspectorCleanupBtn) inspectorCleanupBtn.addEventListener('click', () => runCleanup(inspectorCleanupBtn, chatCleanupBtn)); -if (inspectorScreenshotBtn) inspectorScreenshotBtn.addEventListener('click', () => runScreenshot(inspectorScreenshotBtn, chatScreenshotBtn)); -if (chatCleanupBtn) chatCleanupBtn.addEventListener('click', () => runCleanup(chatCleanupBtn, inspectorCleanupBtn)); -if (chatScreenshotBtn) chatScreenshotBtn.addEventListener('click', () => runScreenshot(chatScreenshotBtn, inspectorScreenshotBtn)); - -// ─── Section Toggles ──────────────────────────────────────────── - -document.querySelectorAll('.inspector-section-toggle').forEach(toggle => { - toggle.addEventListener('click', () => { - const section = toggle.dataset.section; - const body = document.getElementById(`inspector-${section}`); - const isCollapsed = toggle.classList.contains('collapsed'); - - toggle.classList.toggle('collapsed', !isCollapsed); - toggle.setAttribute('aria-expanded', isCollapsed); - toggle.querySelector('.inspector-toggle-arrow').innerHTML = isCollapsed ? '▼' : '▶'; - body.classList.toggle('collapsed', !isCollapsed); - }); -}); - -// ─── Inspector SSE ────────────────────────────────────────────── - -async function connectInspectorSSE() { - if (!serverUrl || !serverToken) return; - if (inspectorSSE) { inspectorSSE.close(); inspectorSSE = null; } - - // Same session-cookie pattern as connectSSE. ?token= is gone (see N1 - // in the v1.6.0.0 security wave plan). - await ensureSseSessionCookie(); - const url = `${serverUrl}/inspector/events?_=${Date.now()}`; - - try { - inspectorSSE = new EventSource(url, { withCredentials: true }); - - inspectorSSE.addEventListener('inspectResult', (e) => { - try { - const data = JSON.parse(e.data); - inspectorShowData(data); - } catch (err) { - console.error('[gstack sidebar] Failed to parse inspectResult:', err.message); - } - }); - - inspectorSSE.addEventListener('error', () => { - // SSE connection failed — inspector works without it (basic mode) - if (inspectorSSE) { inspectorSSE.close(); inspectorSSE = null; } - }); - } catch (err) { - console.debug('[gstack sidebar] Inspector SSE not available:', err.message); - } -} - -// ─── Server Discovery ─────────────────────────────────────────── - -function setActionButtonsEnabled(enabled) { - const btns = document.querySelectorAll('.quick-action-btn, .inspector-action-btn'); - btns.forEach(btn => { - btn.disabled = !enabled; - btn.classList.toggle('disabled', !enabled); - }); -} - -function updateConnection(url, token) { - const wasConnected = !!serverUrl; - serverUrl = url; - serverToken = token || null; - // Expose for sidepanel-terminal.js (PTY surface). The terminal pane needs - // the bootstrap token to POST /pty-session and the port to derive the WS - // URL. We never expose the PTY token — it lives in an HttpOnly cookie. - if (url) { - try { window.gstackServerPort = parseInt(new URL(url).port, 10); } catch {} - window.gstackAuthToken = token || null; - } else { - window.gstackServerPort = null; - window.gstackAuthToken = null; - } - if (url) { - document.getElementById('footer-dot').className = 'dot connected'; - const port = new URL(url).port; - document.getElementById('footer-port').textContent = `:${port}`; - setConnState('connected'); - setActionButtonsEnabled(true); - // Tell the active tab's content script the sidebar is open — this hides - // the welcome page arrow hint. Only fires on actual sidebar connection. - chrome.runtime.sendMessage({ type: 'sidebarOpened' }).catch(() => {}); - connectSSE(); - connectInspectorSSE(); - startMemPolling(); - } else { - document.getElementById('footer-dot').className = 'dot'; - document.getElementById('footer-port').textContent = ''; - const memEl = document.getElementById('footer-mem'); - if (memEl) { - memEl.textContent = ''; - memEl.classList.remove('warn', 'bad'); - } - stopMemPolling(); - setActionButtonsEnabled(false); - if (wasConnected) startReconnect(); - } -} - -// ─── Port Configuration ───────────────────────────────────────── - -const portLabel = document.getElementById('footer-port'); -const portInput = document.getElementById('port-input'); - -portLabel.addEventListener('click', () => { - portLabel.style.display = 'none'; - portInput.style.display = ''; - chrome.runtime.sendMessage({ type: 'getPort' }, (resp) => { - portInput.value = resp?.port || ''; - portInput.focus(); - portInput.select(); - }); -}); - -function savePort() { - const port = parseInt(portInput.value, 10); - if (port > 0 && port < 65536) { - chrome.runtime.sendMessage({ type: 'setPort', port }); - } - portInput.style.display = 'none'; - portLabel.style.display = ''; -} -portInput.addEventListener('blur', savePort); -portInput.addEventListener('keydown', (e) => { - if (e.key === 'Enter') savePort(); - if (e.key === 'Escape') { portInput.style.display = 'none'; portLabel.style.display = ''; } -}); - -// ─── Reconnect / Copy Buttons ──────────────────────────────────── - -document.getElementById('conn-reconnect').addEventListener('click', () => { - reconnectAttempts = 0; - startReconnect(); -}); - -document.getElementById('conn-copy').addEventListener('click', () => { - navigator.clipboard.writeText('/open-gstack-browser').then(() => { - const btn = document.getElementById('conn-copy'); - btn.textContent = 'copied!'; - setTimeout(() => { btn.textContent = '/open-gstack-browser'; }, 2000); - }); -}); - -// Try to connect immediately, retry every 2s until connected. -// Show exactly what's happening at each step so the user is never -// staring at a blank "Connecting..." with no info. -let connectAttempts = 0; -function setLoadingStatus(msg, debug) { - // The status line lives inside the Terminal bootstrap card now — - // sidepanel-terminal.js owns it. We only update the debug pre block, - // and trust the terminal pane to surface the human-readable status. - const dbg = document.getElementById('loading-debug'); - if (dbg && debug !== undefined) dbg.textContent = debug; -} - -async function tryConnect() { - connectAttempts++; - setLoadingStatus( - `Looking for browse server... (attempt ${connectAttempts})`, - `Asking background.js for server port...` - ); - - // Step 1: Ask background for the port - const resp = await new Promise(resolve => { - chrome.runtime.sendMessage({ type: 'getPort' }, (r) => { - if (chrome.runtime.lastError) { - resolve({ error: chrome.runtime.lastError.message }); - } else { - resolve(r || {}); - } - }); - }); - - if (resp.error) { - setLoadingStatus( - `Extension error (attempt ${connectAttempts})`, - `chrome.runtime.sendMessage failed:\n${resp.error}` - ); - setTimeout(tryConnect, 2000); - return; - } - - const port = resp.port || 34567; - - // Step 2: If background says connected + has token, use that - if (resp.port && resp.connected && resp.token) { - setLoadingStatus( - `Server found on port ${port}, connecting...`, - `token: yes\nStarting SSE + chat polling...` - ); - updateConnection(`http://127.0.0.1:${port}`, resp.token); - return; - } - - // Step 3: Background not connected yet. Try hitting /health directly. - // This bypasses the background.js health poll timing gap. - setLoadingStatus( - `Checking server directly... (attempt ${connectAttempts})`, - `port: ${port}\nbackground connected: ${resp.connected || false}\nTrying GET http://127.0.0.1:${port}/health ...` - ); - - try { - const healthResp = await fetch(`http://127.0.0.1:${port}/health`, { - signal: AbortSignal.timeout(2000) - }); - if (healthResp.ok) { - const data = await healthResp.json(); - if (data.status === 'healthy' && data.token) { - setLoadingStatus( - `Server healthy on port ${port}, connecting...`, - `token: yes (from /health)\nStarting SSE + activity feed...` - ); - updateConnection(`http://127.0.0.1:${port}`, data.token); - // The SEC shield used to drive off /health.security via the chat - // path's classifier; with the chat path ripped, the indicator is - // not driven yet. Leaving the shield element hidden by default. - return; - } - setLoadingStatus( - `Server responded but not healthy (attempt ${connectAttempts})`, - `status: ${data.status}\ntoken: ${data.token ? 'yes' : 'no'}` - ); - } else { - setLoadingStatus( - `Server returned ${healthResp.status} (attempt ${connectAttempts})`, - `GET /health → ${healthResp.status} ${healthResp.statusText}` - ); - } - } catch (e) { - setLoadingStatus( - `Server not reachable on port ${port} (attempt ${connectAttempts})`, - `GET /health failed: ${e.message}\n\nThe browse server may still be starting.\nRun /open-gstack-browser in Claude Code.` - ); - } - - setTimeout(tryConnect, 2000); -} -tryConnect(); - -// ─── Message Listener ─────────────────────────────────────────── - -chrome.runtime.onMessage.addListener((msg) => { - if (msg.type === 'health') { - if (msg.data) { - const url = `http://127.0.0.1:${msg.data.port || 34567}`; - // Request token via targeted sendResponse (not broadcast) to limit exposure - chrome.runtime.sendMessage({ type: 'getToken' }, (resp) => { - updateConnection(url, resp?.token || null); - }); - } else { - updateConnection(null); - } - } - if (msg.type === 'refs') { - if (document.querySelector('.tab[data-tab="refs"].active')) { - fetchRefs(); - } - } - if (msg.type === 'inspectResult') { - inspectorPickerActive = false; - inspectorPickBtn.classList.remove('active'); - if (msg.data) { - inspectorShowData(msg.data); - } else { - inspectorShowError('Element not found, try picking again'); - } - } - if (msg.type === 'pickerCancelled') { - inspectorPickerActive = false; - inspectorPickBtn.classList.remove('active'); - } - // browserTabState: full snapshot of all open tabs + the active one, - // pushed by background.js on chrome.tabs events. We forward it as a - // custom event so sidepanel-terminal.js can relay to terminal-agent.ts. - // Result: claude's /tabs.json + active-tab.json stay live. - if (msg.type === 'browserTabState') { - document.dispatchEvent(new CustomEvent('gstack:tab-state', { - detail: { active: msg.active, tabs: msg.tabs, reason: msg.reason }, - })); - } -}); - -// ─── v1.44 pagehide: explicit PTY dispose on sidebar close ────────── -// -// Codex T3 of the eng review: WS close codes alone can't distinguish -// "intentional close" (sidebar closed, browser quit, extension reload) -// from "transient blip" (wifi hiccup) reliably — Chrome routes the -// former through code 1001 (going-away) and the latter through 1006 -// (abnormal), but neither is a load-bearing contract across browsers -// and extension lifecycles. -// -// pagehide fires reliably for tab close, panel close, extension reload, -// and navigation-away. We use it to fire-and-forget a /pty-dispose POST -// so the server can synchronously dispose the PtySession instead of -// waiting for the 60s detach window (Commit 3) to time out. Zombie -// claude processes lingering for 60s on every browser quit was the -// codex-flagged failure mode. -// -// sendBeacon is the only fetch primitive that survives a closing page — -// it doesn't accept custom headers, which is why the server's -// /pty-dispose route accepts the auth token in the BODY (see -// server-pty-lease-routes.test.ts test 4). -window.addEventListener('pagehide', () => { - const sessionId = window.gstackPtySession; - const authToken = window.gstackAuthToken; - const port = window.gstackServerPort; - if (!sessionId || !authToken || !port) return; - try { - const blob = new Blob([JSON.stringify({ sessionId, authToken })], { - type: 'application/json', - }); - navigator.sendBeacon(`http://127.0.0.1:${port}/pty-dispose`, blob); - } catch { - // Best-effort — the 60s detach timer will catch any session we miss. - } -}); diff --git a/package.json b/package.json index 1fc3ff76b..111e586fc 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,6 @@ "scripts": { "build": "bash scripts/build.sh", "build:runtime": "bash scripts/build.sh --runtime-only", - "vendor:xterm": "mkdir -p extension/lib && cp node_modules/xterm/lib/xterm.js extension/lib/xterm.js && cp node_modules/xterm/css/xterm.css extension/lib/xterm.css && cp node_modules/xterm-addon-fit/lib/xterm-addon-fit.js extension/lib/xterm-addon-fit.js", "dev:make-pdf": "bun run make-pdf/src/cli.ts", "dev:design": "bun run design/src/cli.ts", "build:diagram-render": "cd lib/diagram-render && bun install && bun run scripts/build.ts", @@ -77,9 +76,7 @@ "marked": "^18.0.2", "playwright": "npm:playwright-core@^1.58.2", "sharp": "^0.34.5", - "socks": "^2.8.8", - "xterm": "5", - "xterm-addon-fit": "^0.8.0" + "socks": "^2.8.8" }, "engines": { "bun": ">=1.0.0", diff --git a/runtime/install.js b/runtime/install.js index 9046a2a6f..6002ee027 100644 --- a/runtime/install.js +++ b/runtime/install.js @@ -272,7 +272,6 @@ export const DEFAULT_RUNTIME_BUNDLE = Object.freeze([ // Keep its small, audited dependency closure explicit instead of copying all // node_modules or introducing a cloud browser. entry("browse/src"), - entry("extension"), entry("node_modules/playwright"), entry(managedBunRelativePath(), "managed-bun", true), entry(".gstack-runtime-browsers", "browser"), @@ -317,7 +316,7 @@ export const DEFAULT_CAPABILITY_LAUNCHERS = Object.freeze({ const CAPABILITY_PATH_PREFIXES = Object.freeze({ browser: Object.freeze([ - "browse/", "extension/", ".gstack-runtime-browsers", "node_modules/playwright", "node_modules/diff", "node_modules/socks", + "browse/", ".gstack-runtime-browsers", "node_modules/playwright", "node_modules/diff", "node_modules/socks", "node_modules/smart-buffer", "node_modules/ip-address", "node_modules/sharp", "node_modules/@img/", "node_modules/@ngrok/", "node_modules/detect-libc", "node_modules/semver", ]), diff --git a/scripts/build-app.sh b/scripts/build-app.sh index 7d3d0dab3..74c5ce3f7 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -4,7 +4,6 @@ # Creates a self-contained .app with: # - Compiled browse binary # - Playwright's bundled Chromium -# - Chrome extension (sidebar) # - Info.plist with bundle ID # # Output: dist/GStack Browser.app and dist/GStack-Browser.dmg @@ -63,11 +62,6 @@ chmod +x "$APP_DIR/Contents/MacOS/gstack-browser" cp "$BUILD_DIR/browse-app" "$APP_DIR/Contents/Resources/browse" chmod +x "$APP_DIR/Contents/Resources/browse" -# Extension -cp -r "$ROOT/extension" "$APP_DIR/Contents/Resources/extension" -# Remove .auth.json if present (auth now via /health endpoint) -rm -f "$APP_DIR/Contents/Resources/extension/.auth.json" - # Server source (needed for `bun run server.ts` subprocess) # The launcher sets BROWSE_SERVER_SCRIPT to point at this. # Copy the full src/ directory since server.ts imports other modules. @@ -163,7 +157,6 @@ echo "" echo " $APP_NAME.app: $APP_SIZE" echo " Contents/MacOS/gstack-browser (launcher)" echo " Contents/Resources/browse ($(du -sh "$APP_DIR/Contents/Resources/browse" | cut -f1))" -echo " Contents/Resources/extension/ ($(du -sh "$APP_DIR/Contents/Resources/extension" | cut -f1))" echo " Contents/Resources/chromium/ ($(du -sh "$APP_DIR/Contents/Resources/chromium" | cut -f1))" # ─── Step 6: DMG (optional) ───────────────────────────────────── diff --git a/scripts/build.sh b/scripts/build.sh index 0678a052c..ec9982fb8 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -32,7 +32,6 @@ case "$(uname -s)" in ;; esac -"$BUN_CMD" run vendor:xterm "$BUN_CMD" build --compile browse/src/cli.ts --outfile browse/dist/browse "$BUN_CMD" build --compile browse/src/find-browse.ts --outfile browse/dist/find-browse "$BUN_CMD" build --compile design/src/cli.ts --outfile design/dist/design