mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-21 20:30:47 +02:00
refactor: remove browser sidebar extension
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
a84a6e233d
commit
ab4e9ae520
+10
-114
@@ -17,7 +17,6 @@
|
|||||||
|
|
||||||
import { chromium, type Browser, type BrowserContext, type BrowserContextOptions, type Page, type Locator, type Cookie } from 'playwright';
|
import { chromium, type Browser, type BrowserContext, type BrowserContextOptions, type Page, type Locator, type Cookie } from 'playwright';
|
||||||
import { readdirSync } from 'node:fs';
|
import { readdirSync } from 'node:fs';
|
||||||
import { writeSecureFile, mkdirSecure } from './file-permissions';
|
|
||||||
import { addConsoleEntry, addNetworkEntry, addDialogEntry, networkBuffer, type DialogEntry } from './buffers';
|
import { addConsoleEntry, addNetworkEntry, addDialogEntry, networkBuffer, type DialogEntry } from './buffers';
|
||||||
import { emitActivity } from './activity';
|
import { emitActivity } from './activity';
|
||||||
import { validateNavigationUrl } from './url-validation';
|
import { validateNavigationUrl } from './url-validation';
|
||||||
@@ -243,9 +242,8 @@ export class BrowserManager {
|
|||||||
// back below. Pre-guardrail, nothing tracked tab count growth and a
|
// back below. Pre-guardrail, nothing tracked tab count growth and a
|
||||||
// user could accumulate hundreds of tabs (each holding 50–300 MB of
|
// user could accumulate hundreds of tabs (each holding 50–300 MB of
|
||||||
// Chromium-side RSS) without warning until the OS OOM-killer fired.
|
// Chromium-side RSS) without warning until the OS OOM-killer fired.
|
||||||
// The toast UX lives in the sidebar (extension/sidepanel.js); the
|
// The server-side responsibility is the audit-trail activity entry that
|
||||||
// server-side responsibility is the audit-trail activity entry that
|
// appears in the activity feed.
|
||||||
// appears in the activity feed even when the sidebar is closed.
|
|
||||||
private static readonly TAB_GUARDRAIL_SOFT = 50;
|
private static readonly TAB_GUARDRAIL_SOFT = 50;
|
||||||
private static readonly TAB_GUARDRAIL_HARD = 200;
|
private static readonly TAB_GUARDRAIL_HARD = 200;
|
||||||
private tabGuardrailSoftHit = false;
|
private tabGuardrailSoftHit = false;
|
||||||
@@ -265,7 +263,7 @@ export class BrowserManager {
|
|||||||
}
|
}
|
||||||
if (!this.tabGuardrailHardHit && total >= BrowserManager.TAB_GUARDRAIL_HARD) {
|
if (!this.tabGuardrailHardHit && total >= BrowserManager.TAB_GUARDRAIL_HARD) {
|
||||||
this.tabGuardrailHardHit = true;
|
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}`);
|
console.error(`[browse] ${msg}`);
|
||||||
emitActivity({ type: 'error', command: 'tab-guardrail', error: msg, tabs: total });
|
emitActivity({ type: 'error', command: 'tab-guardrail', error: msg, tabs: total });
|
||||||
}
|
}
|
||||||
@@ -320,43 +318,6 @@ export class BrowserManager {
|
|||||||
this.watchSnapshots.push(snapshot);
|
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
|
* Set the proxy config applied to chromium.launch() in launch() and
|
||||||
* launchHeaded(). Called by server.ts at startup once the (optional) SOCKS5
|
* launchHeaded(). Called by server.ts at startup once the (optional) SOCKS5
|
||||||
@@ -378,14 +339,9 @@ export class BrowserManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async launch() {
|
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 { STEALTH_LAUNCH_ARGS, buildGStackLaunchArgs } = await import('./stealth');
|
||||||
const launchArgs: string[] = [...STEALTH_LAUNCH_ARGS, ...buildGStackLaunchArgs()];
|
const launchArgs: string[] = [...STEALTH_LAUNCH_ARGS, ...buildGStackLaunchArgs()];
|
||||||
let useHeadless = true;
|
const useHeadless = true;
|
||||||
const executablePath = configuredChromiumExecutable();
|
const executablePath = configuredChromiumExecutable();
|
||||||
|
|
||||||
// Docker/CI/root: Chromium sandbox requires unprivileged user namespaces which
|
// Docker/CI/root: Chromium sandbox requires unprivileged user namespaces which
|
||||||
@@ -396,21 +352,6 @@ export class BrowserManager {
|
|||||||
launchArgs.push('--no-sandbox');
|
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({
|
this.browser = await chromium.launch({
|
||||||
headless: useHeadless,
|
headless: useHeadless,
|
||||||
...(executablePath
|
...(executablePath
|
||||||
@@ -468,23 +409,18 @@ export class BrowserManager {
|
|||||||
|
|
||||||
// ─── Headed Mode ─────────────────────────────────────────────
|
// ─── Headed Mode ─────────────────────────────────────────────
|
||||||
/**
|
/**
|
||||||
* Launch Playwright's bundled Chromium in headed mode with the gstack
|
* Launch Playwright's bundled Chromium in headed mode.
|
||||||
* Chrome extension auto-loaded. Uses launchPersistentContext() which
|
|
||||||
* is required for extension loading (launch() + newContext() can't
|
|
||||||
* load extensions).
|
|
||||||
*
|
*
|
||||||
* The browser launches headed with a visible window — the user sees
|
* The browser launches headed with a visible window — the user sees
|
||||||
* every action Claude takes in real time.
|
* every action Claude takes in real time.
|
||||||
*/
|
*/
|
||||||
async launchHeaded(authToken?: string): Promise<void> {
|
async launchHeaded(_authToken?: string): Promise<void> {
|
||||||
assertHeadedBrowserProvider();
|
assertHeadedBrowserProvider();
|
||||||
// Clear old state before repopulating
|
// Clear old state before repopulating
|
||||||
this.pages.clear();
|
this.pages.clear();
|
||||||
this.tabSessions.clear();
|
this.tabSessions.clear();
|
||||||
this.nextTabId = 1;
|
this.nextTabId = 1;
|
||||||
|
|
||||||
// Find the gstack extension directory for auto-loading
|
|
||||||
const extensionPath = this.findExtensionPath();
|
|
||||||
const { STEALTH_LAUNCH_ARGS, buildGStackLaunchArgs } = await import('./stealth');
|
const { STEALTH_LAUNCH_ARGS, buildGStackLaunchArgs } = await import('./stealth');
|
||||||
const launchArgs = [
|
const launchArgs = [
|
||||||
'--hide-crash-restore-bubble',
|
'--hide-crash-restore-bubble',
|
||||||
@@ -499,38 +435,9 @@ export class BrowserManager {
|
|||||||
// Chromium too.
|
// Chromium too.
|
||||||
...buildGStackLaunchArgs(),
|
...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.
|
// Launch headed Chromium via Playwright's persistent context so the
|
||||||
// Extensions REQUIRE launchPersistentContext (not launch + newContext).
|
// profile (cookies, storage) persists across runs.
|
||||||
// Real Chrome (executablePath/channel) silently blocks --load-extension,
|
|
||||||
// so we use Playwright's bundled Chromium which reliably loads extensions.
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const userDataDir = resolveChromiumProfile();
|
const userDataDir = resolveChromiumProfile();
|
||||||
@@ -1600,27 +1507,16 @@ export class BrowserManager {
|
|||||||
const state = await this.saveState();
|
const state = await this.saveState();
|
||||||
const currentUrl = this.getCurrentUrl();
|
const currentUrl = this.getCurrentUrl();
|
||||||
|
|
||||||
// 2. Launch new headed browser with extension (same as launchHeaded)
|
// 2. Launch new headed browser (same as launchHeaded).
|
||||||
// Uses launchPersistentContext so the extension auto-loads.
|
// Uses launchPersistentContext so the profile persists.
|
||||||
let newContext: BrowserContext;
|
let newContext: BrowserContext;
|
||||||
try {
|
try {
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
|
||||||
const extensionPath = this.findExtensionPath();
|
|
||||||
const { STEALTH_LAUNCH_ARGS, buildGStackLaunchArgs } = await import('./stealth');
|
const { STEALTH_LAUNCH_ARGS, buildGStackLaunchArgs } = await import('./stealth');
|
||||||
// Same blink-level stealth flags as launch()/launchHeaded(). Without
|
// Same blink-level stealth flags as launch()/launchHeaded(). Without
|
||||||
// STEALTH_LAUNCH_ARGS the handed-off browser kept the AutomationControlled
|
// STEALTH_LAUNCH_ARGS the handed-off browser kept the AutomationControlled
|
||||||
// tell that the other two paths strip.
|
// tell that the other two paths strip.
|
||||||
const launchArgs: string[] = ['--hide-crash-restore-bubble', ...STEALTH_LAUNCH_ARGS, ...buildGStackLaunchArgs()];
|
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();
|
const userDataDir = resolveChromiumProfile();
|
||||||
fs.mkdirSync(userDataDir, { recursive: true });
|
fs.mkdirSync(userDataDir, { recursive: true });
|
||||||
|
|||||||
@@ -13,8 +13,6 @@
|
|||||||
"playwright": "npm:playwright-core@^1.58.2",
|
"playwright": "npm:playwright-core@^1.58.2",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"socks": "^2.8.8",
|
"socks": "^2.8.8",
|
||||||
"xterm": "5",
|
|
||||||
"xterm-addon-fit": "^0.8.0",
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "0.2.117",
|
"@anthropic-ai/claude-agent-sdk": "0.2.117",
|
||||||
@@ -521,10 +519,6 @@
|
|||||||
|
|
||||||
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
"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": ["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=="],
|
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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 = `<span class="gstack-pill-dot"></span> 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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.8 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 400 B |
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1,29 +0,0 @@
|
|||||||
/* gstack browse — CSS Inspector overlay styles
|
|
||||||
* Injected alongside inspector.js into the active tab.
|
|
||||||
* Design system: amber accent, zinc neutrals.
|
|
||||||
*/
|
|
||||||
|
|
||||||
#gstack-inspector-highlight {
|
|
||||||
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 ease, left 50ms ease, width 50ms ease, height 50ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
#gstack-inspector-tooltip {
|
|
||||||
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);
|
|
||||||
line-height: 18px;
|
|
||||||
}
|
|
||||||
@@ -1,474 +0,0 @@
|
|||||||
/**
|
|
||||||
* gstack browse — CSS Inspector content script
|
|
||||||
*
|
|
||||||
* Dynamically injected via chrome.scripting.executeScript.
|
|
||||||
* Provides element picker, selector generation, basic computed style capture,
|
|
||||||
* and page alteration handlers for agent-pushed CSS changes.
|
|
||||||
*/
|
|
||||||
|
|
||||||
(() => {
|
|
||||||
// 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<property, value>
|
|
||||||
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: <tag> .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;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
@@ -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": ["<all_urls>"],
|
|
||||||
"js": ["content.js"],
|
|
||||||
"css": ["content.css"]
|
|
||||||
}],
|
|
||||||
"icons": {
|
|
||||||
"16": "icons/icon-16.png",
|
|
||||||
"48": "icons/icon-48.png",
|
|
||||||
"128": "icons/icon-128.png"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<style>
|
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
width: 240px;
|
|
||||||
background: #0C0C0C;
|
|
||||||
color: #e0e0e0;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
|
||||||
font-size: 13px;
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
h1 {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #FAFAFA;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
letter-spacing: -0.3px;
|
|
||||||
}
|
|
||||||
label {
|
|
||||||
display: block;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #A1A1AA;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 8px;
|
|
||||||
background: #141414;
|
|
||||||
border: 1px solid #262626;
|
|
||||||
border-radius: 8px;
|
|
||||||
color: #FAFAFA;
|
|
||||||
font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
|
|
||||||
font-size: 13px;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 150ms;
|
|
||||||
}
|
|
||||||
input:focus { border-color: #F59E0B; }
|
|
||||||
.status {
|
|
||||||
margin: 12px 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.dot {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #3f3f46;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.dot.connected { background: #22C55E; }
|
|
||||||
.dot.error { background: #EF4444; }
|
|
||||||
.dot.reconnecting {
|
|
||||||
background: #F59E0B;
|
|
||||||
animation: pulse 2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
@keyframes pulse {
|
|
||||||
0%, 100% { opacity: 0.4; }
|
|
||||||
50% { opacity: 1; }
|
|
||||||
}
|
|
||||||
.status-text { color: #A1A1AA; font-size: 12px; }
|
|
||||||
.status-text.connected { color: #22C55E; }
|
|
||||||
.details { color: #52525B; font-size: 11px; margin-top: 2px; }
|
|
||||||
button {
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 12px;
|
|
||||||
padding: 8px;
|
|
||||||
background: rgba(245, 158, 11, 0.1);
|
|
||||||
border: 1px solid #F59E0B;
|
|
||||||
border-radius: 8px;
|
|
||||||
color: #FBBF24;
|
|
||||||
font-size: 13px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 150ms;
|
|
||||||
}
|
|
||||||
button:hover { background: rgba(245, 158, 11, 0.2); }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>gstack</h1>
|
|
||||||
|
|
||||||
<label>Port</label>
|
|
||||||
<input type="text" id="port" placeholder="34567" autocomplete="off">
|
|
||||||
|
|
||||||
<div class="status">
|
|
||||||
<div class="dot" id="dot"></div>
|
|
||||||
<span class="status-text" id="status-text">Disconnected</span>
|
|
||||||
</div>
|
|
||||||
<div class="details" id="details"></div>
|
|
||||||
|
|
||||||
<button id="side-panel-btn">Open Side Panel</button>
|
|
||||||
|
|
||||||
<script src="popup.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -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}`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,202 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<link rel="stylesheet" href="sidepanel.css">
|
|
||||||
<link rel="stylesheet" href="lib/xterm.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<!-- Security shield — reflects ~/.gstack/security/session-state.json status.
|
|
||||||
Hidden until the sidebar knows its state (avoids flicker on first load).
|
|
||||||
Consumes /health.security — see browse/src/security.ts getStatus(). -->
|
|
||||||
<div class="security-shield" id="security-shield" role="status" aria-label="Security status: unknown" style="display:none" title="Security">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
|
||||||
</svg>
|
|
||||||
<span class="security-shield-label" id="security-shield-label">SEC</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Connection status banner -->
|
|
||||||
<div class="conn-banner" id="conn-banner" style="display:none">
|
|
||||||
<span class="conn-banner-text" id="conn-banner-text">Reconnecting...</span>
|
|
||||||
<div class="conn-banner-actions" id="conn-banner-actions" style="display:none">
|
|
||||||
<button class="conn-btn" id="conn-reconnect">Reconnect</button>
|
|
||||||
<button class="conn-btn conn-copy" id="conn-copy" title="Copy command">/open-gstack-browser</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Browser tab bar -->
|
|
||||||
<div class="browser-tabs" id="browser-tabs" style="display:none"></div>
|
|
||||||
|
|
||||||
<!-- Terminal pane is now the sole primary surface. Activity / Refs /
|
|
||||||
Inspector still exist behind the `debug` toggle in the footer. -->
|
|
||||||
<main id="tab-terminal" class="tab-content active" role="tabpanel" aria-label="Terminal">
|
|
||||||
<!-- Toolbar with browser quick-actions on the left, Restart on the right.
|
|
||||||
Restart is always visible so the user can force a fresh claude any
|
|
||||||
time, not just from the ENDED state. -->
|
|
||||||
<div class="terminal-toolbar" id="terminal-toolbar">
|
|
||||||
<div class="terminal-toolbar-actions">
|
|
||||||
<button id="chat-cleanup-btn" class="terminal-toolbar-btn" title="Remove ads, banners, popups">🧹 Cleanup</button>
|
|
||||||
<button id="chat-screenshot-btn" class="terminal-toolbar-btn" title="Take a screenshot">📸 Screenshot</button>
|
|
||||||
<button id="chat-cookies-btn" class="terminal-toolbar-btn" title="Import cookies from your browser">🍪 Cookies</button>
|
|
||||||
</div>
|
|
||||||
<button class="terminal-toolbar-btn" id="terminal-restart-now" title="Restart Claude Code session">↻ Restart</button>
|
|
||||||
</div>
|
|
||||||
<div class="terminal-bootstrap" id="terminal-bootstrap">
|
|
||||||
<div class="terminal-bootstrap-icon">▸</div>
|
|
||||||
<p id="terminal-bootstrap-status">Starting Claude Code...</p>
|
|
||||||
<p class="muted" id="terminal-bootstrap-hint">Real PTY. Real terminal. Real claude.</p>
|
|
||||||
<pre id="loading-debug" class="muted" style="font-size:11px; font-family:'JetBrains Mono',monospace; white-space:pre-wrap; margin-top:8px; color:#71717A;"></pre>
|
|
||||||
</div>
|
|
||||||
<div class="terminal-install-card" id="terminal-install-card" style="display:none">
|
|
||||||
<p><strong>Claude Code not found</strong></p>
|
|
||||||
<p class="muted">Install: <a href="https://docs.anthropic.com/en/docs/claude-code" target="_blank">docs.anthropic.com/en/docs/claude-code</a></p>
|
|
||||||
<button class="install-retry-btn" id="terminal-install-retry">I installed it — try again</button>
|
|
||||||
</div>
|
|
||||||
<div class="terminal-mount" id="terminal-mount" style="display:none"></div>
|
|
||||||
<div class="terminal-ended" id="terminal-ended" style="display:none">
|
|
||||||
<p>Session ended.</p>
|
|
||||||
<button class="install-retry-btn" id="terminal-restart">Start a new session</button>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<!-- Debug: Activity Tab (hidden by default) -->
|
|
||||||
<main id="tab-activity" class="tab-content" role="log" aria-live="polite">
|
|
||||||
<div class="empty-state" id="empty-state">
|
|
||||||
<p>Waiting for commands...</p>
|
|
||||||
<p class="muted">Run a browse command to see activity here.</p>
|
|
||||||
</div>
|
|
||||||
<div id="activity-feed"></div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<!-- Debug: Refs Tab (hidden by default) -->
|
|
||||||
<main id="tab-refs" class="tab-content">
|
|
||||||
<div class="empty-state" id="refs-empty">
|
|
||||||
<p>No refs yet</p>
|
|
||||||
<p class="muted">Run <code>snapshot</code> to see element refs.</p>
|
|
||||||
</div>
|
|
||||||
<div id="refs-list"></div>
|
|
||||||
<div class="refs-footer" id="refs-footer"></div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<!-- Debug: Inspector Tab (hidden by default) -->
|
|
||||||
<main id="tab-inspector" class="tab-content">
|
|
||||||
<!-- Toolbar: always visible -->
|
|
||||||
<div class="inspector-toolbar" id="inspector-toolbar">
|
|
||||||
<button class="inspector-pick-btn" id="inspector-pick-btn" title="Pick an element (click, then click any element on the page)">
|
|
||||||
<span class="inspector-pick-icon">✛</span> Pick
|
|
||||||
</button>
|
|
||||||
<span class="inspector-selected" id="inspector-selected"></span>
|
|
||||||
<span class="inspector-mode-badge" id="inspector-mode-badge" style="display:none"></span>
|
|
||||||
<div style="flex:1"></div>
|
|
||||||
<button id="inspector-cleanup-btn" class="inspector-action-btn" title="Remove ads, banners, popups">🧹</button>
|
|
||||||
<button id="inspector-screenshot-btn" class="inspector-action-btn" title="Take a screenshot">📸</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Inspector content area -->
|
|
||||||
<div class="inspector-content" id="inspector-content">
|
|
||||||
<!-- Empty state (before first pick) -->
|
|
||||||
<div class="inspector-empty" id="inspector-empty">
|
|
||||||
<div class="inspector-empty-icon">✛</div>
|
|
||||||
<p>Pick an element to inspect</p>
|
|
||||||
<p class="muted">Click the button above, then click any element on the page</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Loading state -->
|
|
||||||
<div class="inspector-loading" id="inspector-loading" style="display:none">
|
|
||||||
<div class="inspector-loading-text">Inspecting...</div>
|
|
||||||
<div class="inspector-skeleton">
|
|
||||||
<div class="inspector-skeleton-bar"></div>
|
|
||||||
<div class="inspector-skeleton-bar"></div>
|
|
||||||
<div class="inspector-skeleton-bar"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Error state -->
|
|
||||||
<div class="inspector-error" id="inspector-error" style="display:none"></div>
|
|
||||||
|
|
||||||
<!-- Inspector data panels -->
|
|
||||||
<div class="inspector-panels" id="inspector-panels" style="display:none">
|
|
||||||
<!-- Box Model -->
|
|
||||||
<div class="inspector-section" id="inspector-boxmodel-section">
|
|
||||||
<div class="inspector-section-header">Box Model</div>
|
|
||||||
<div class="inspector-boxmodel" id="inspector-boxmodel"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Matched Rules -->
|
|
||||||
<div class="inspector-section" id="inspector-rules-section">
|
|
||||||
<button class="inspector-section-toggle" data-section="rules" aria-expanded="true">
|
|
||||||
<span class="inspector-toggle-arrow">▼</span>
|
|
||||||
<span>Matched Rules</span>
|
|
||||||
<span class="inspector-rule-count" id="inspector-rule-count"></span>
|
|
||||||
</button>
|
|
||||||
<div class="inspector-section-body" id="inspector-rules" role="tree"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Computed Styles -->
|
|
||||||
<div class="inspector-section" id="inspector-computed-section">
|
|
||||||
<button class="inspector-section-toggle collapsed" data-section="computed" aria-expanded="false">
|
|
||||||
<span class="inspector-toggle-arrow">▶</span>
|
|
||||||
<span>Computed</span>
|
|
||||||
</button>
|
|
||||||
<div class="inspector-section-body collapsed" id="inspector-computed"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Quick Edit -->
|
|
||||||
<div class="inspector-section" id="inspector-quickedit-section">
|
|
||||||
<button class="inspector-section-toggle collapsed" data-section="quickedit" aria-expanded="false">
|
|
||||||
<span class="inspector-toggle-arrow">▶</span>
|
|
||||||
<span>Quick Edit</span>
|
|
||||||
</button>
|
|
||||||
<div class="inspector-section-body collapsed" id="inspector-quickedit"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Send to Agent: sticky bottom -->
|
|
||||||
<div class="inspector-send" id="inspector-send" style="display:none">
|
|
||||||
<button class="inspector-send-btn" id="inspector-send-btn">Send to Agent</button>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<!-- Tab guardrail toast (hidden until /memory poll trips a threshold) -->
|
|
||||||
<div class="mem-toast" id="mem-toast" role="dialog" aria-label="Memory pressure warning" style="display:none">
|
|
||||||
<div class="mem-toast-header">
|
|
||||||
<strong id="mem-toast-title">High memory pressure</strong>
|
|
||||||
<button class="mem-toast-close" id="mem-toast-close" aria-label="Dismiss">×</button>
|
|
||||||
</div>
|
|
||||||
<div class="mem-toast-body" id="mem-toast-body"></div>
|
|
||||||
<div class="mem-toast-actions">
|
|
||||||
<button class="mem-toast-btn primary" id="mem-toast-close-selected">Close selected</button>
|
|
||||||
<button class="mem-toast-btn" id="mem-toast-snooze">Snooze</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Footer with connection + debug toggle -->
|
|
||||||
<footer>
|
|
||||||
<div class="footer-left">
|
|
||||||
<button class="debug-toggle" id="debug-toggle" title="Toggle debug panels">debug</button>
|
|
||||||
<button class="footer-btn" id="reload-sidebar" title="Reload sidebar">reload</button>
|
|
||||||
</div>
|
|
||||||
<div class="footer-right">
|
|
||||||
<span class="footer-mem" id="footer-mem" title="Process memory + tab count from $B memory (polled every 30s, paused if slow)"></span>
|
|
||||||
<span class="dot" id="footer-dot"></span>
|
|
||||||
<span class="footer-port" id="footer-port" title="Click to change port"></span>
|
|
||||||
<input type="text" class="port-input" id="port-input" placeholder="34567" autocomplete="off" style="display:none">
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<!-- Debug tab bar (hidden by default) -->
|
|
||||||
<nav class="tabs debug-tabs" id="debug-tabs" role="tablist" style="display:none">
|
|
||||||
<button class="tab" role="tab" data-tab="activity">Activity</button>
|
|
||||||
<button class="tab" role="tab" data-tab="refs">Refs</button>
|
|
||||||
<button class="tab" role="tab" data-tab="inspector">Inspector</button>
|
|
||||||
<button class="tab close-debug" id="close-debug" title="Close debug">×</button>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<script src="lib/xterm.js"></script>
|
|
||||||
<script src="lib/xterm-addon-fit.js"></script>
|
|
||||||
<script src="sidepanel.js"></script>
|
|
||||||
<script src="sidepanel-terminal.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
File diff suppressed because it is too large
Load Diff
+1
-4
@@ -23,7 +23,6 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "bash scripts/build.sh",
|
"build": "bash scripts/build.sh",
|
||||||
"build:runtime": "bash scripts/build.sh --runtime-only",
|
"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:make-pdf": "bun run make-pdf/src/cli.ts",
|
||||||
"dev:design": "bun run design/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",
|
"build:diagram-render": "cd lib/diagram-render && bun install && bun run scripts/build.ts",
|
||||||
@@ -77,9 +76,7 @@
|
|||||||
"marked": "^18.0.2",
|
"marked": "^18.0.2",
|
||||||
"playwright": "npm:playwright-core@^1.58.2",
|
"playwright": "npm:playwright-core@^1.58.2",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"socks": "^2.8.8",
|
"socks": "^2.8.8"
|
||||||
"xterm": "5",
|
|
||||||
"xterm-addon-fit": "^0.8.0"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"bun": ">=1.0.0",
|
"bun": ">=1.0.0",
|
||||||
|
|||||||
+1
-2
@@ -272,7 +272,6 @@ export const DEFAULT_RUNTIME_BUNDLE = Object.freeze([
|
|||||||
// Keep its small, audited dependency closure explicit instead of copying all
|
// Keep its small, audited dependency closure explicit instead of copying all
|
||||||
// node_modules or introducing a cloud browser.
|
// node_modules or introducing a cloud browser.
|
||||||
entry("browse/src"),
|
entry("browse/src"),
|
||||||
entry("extension"),
|
|
||||||
entry("node_modules/playwright"),
|
entry("node_modules/playwright"),
|
||||||
entry(managedBunRelativePath(), "managed-bun", true),
|
entry(managedBunRelativePath(), "managed-bun", true),
|
||||||
entry(".gstack-runtime-browsers", "browser"),
|
entry(".gstack-runtime-browsers", "browser"),
|
||||||
@@ -317,7 +316,7 @@ export const DEFAULT_CAPABILITY_LAUNCHERS = Object.freeze({
|
|||||||
|
|
||||||
const CAPABILITY_PATH_PREFIXES = Object.freeze({
|
const CAPABILITY_PATH_PREFIXES = Object.freeze({
|
||||||
browser: 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/smart-buffer", "node_modules/ip-address", "node_modules/sharp", "node_modules/@img/",
|
||||||
"node_modules/@ngrok/", "node_modules/detect-libc", "node_modules/semver",
|
"node_modules/@ngrok/", "node_modules/detect-libc", "node_modules/semver",
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
# Creates a self-contained .app with:
|
# Creates a self-contained .app with:
|
||||||
# - Compiled browse binary
|
# - Compiled browse binary
|
||||||
# - Playwright's bundled Chromium
|
# - Playwright's bundled Chromium
|
||||||
# - Chrome extension (sidebar)
|
|
||||||
# - Info.plist with bundle ID
|
# - Info.plist with bundle ID
|
||||||
#
|
#
|
||||||
# Output: dist/GStack Browser.app and dist/GStack-Browser.dmg
|
# 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"
|
cp "$BUILD_DIR/browse-app" "$APP_DIR/Contents/Resources/browse"
|
||||||
chmod +x "$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)
|
# Server source (needed for `bun run server.ts` subprocess)
|
||||||
# The launcher sets BROWSE_SERVER_SCRIPT to point at this.
|
# The launcher sets BROWSE_SERVER_SCRIPT to point at this.
|
||||||
# Copy the full src/ directory since server.ts imports other modules.
|
# Copy the full src/ directory since server.ts imports other modules.
|
||||||
@@ -163,7 +157,6 @@ echo ""
|
|||||||
echo " $APP_NAME.app: $APP_SIZE"
|
echo " $APP_NAME.app: $APP_SIZE"
|
||||||
echo " Contents/MacOS/gstack-browser (launcher)"
|
echo " Contents/MacOS/gstack-browser (launcher)"
|
||||||
echo " Contents/Resources/browse ($(du -sh "$APP_DIR/Contents/Resources/browse" | cut -f1))"
|
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))"
|
echo " Contents/Resources/chromium/ ($(du -sh "$APP_DIR/Contents/Resources/chromium" | cut -f1))"
|
||||||
|
|
||||||
# ─── Step 6: DMG (optional) ─────────────────────────────────────
|
# ─── Step 6: DMG (optional) ─────────────────────────────────────
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ case "$(uname -s)" in
|
|||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
"$BUN_CMD" run vendor:xterm
|
|
||||||
"$BUN_CMD" build --compile browse/src/cli.ts --outfile browse/dist/browse
|
"$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 browse/src/find-browse.ts --outfile browse/dist/find-browse
|
||||||
"$BUN_CMD" build --compile design/src/cli.ts --outfile design/dist/design
|
"$BUN_CMD" build --compile design/src/cli.ts --outfile design/dist/design
|
||||||
|
|||||||
Reference in New Issue
Block a user