mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-01 19:25:10 +02:00
03973c2fab
* fix(bin): pass search params via env vars (RCE fix) (#819) Replace shell string interpolation with process.env in gstack-learnings-search to prevent arbitrary code execution via crafted learnings entries. Also fixes the CROSS_PROJECT interpolation that the original PR missed. Adds 3 regression tests verifying no shell interpolation remains in the bun -e block. Co-authored-by: garagon <garagon@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(browse): add path validation to upload command (#821) Add isPathWithin() and path traversal checks to the upload command, blocking file exfiltration via crafted upload paths. Uses existing SAFE_DIRECTORIES constant instead of a local copy. Adds 3 regression tests. Co-authored-by: garagon <garagon@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(browse): symlink resolution in meta-commands validateOutputPath (#820) Add realpathSync to validateOutputPath in meta-commands.ts to catch symlink-based directory escapes in screenshot, pdf, and responsive commands. Resolves SAFE_DIRECTORIES through realpathSync to handle macOS /tmp -> /private/tmp symlinks. Existing path validation tests pass with the hardened implementation. Co-authored-by: garagon <garagon@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: add uninstall instructions to README (#812) Community PR #812 by @0531Kim. Adds two uninstall paths: the gstack-uninstall script (handles everything) and manual removal steps for when the repo isn't cloned. Includes CLAUDE.md cleanup note and Playwright cache guidance. Co-Authored-By: 0531Kim <0531Kim@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(browse): Windows launcher extraEnv + headed-mode token (#822) Community PR #822 by @pieterklue. Three fixes: 1. Windows launcher now merges extraEnv into spawned server env (was only passing BROWSE_STATE_FILE, dropping all other env vars) 2. Welcome page fallback serves inline HTML instead of about:blank redirect (avoids ERR_UNSAFE_REDIRECT on Windows) 3. /health returns auth token in headed mode even without Origin header (fixes Playwright Chromium extensions that don't send it) Also adds HOME/USERPROFILE fallback for cross-platform compatibility. Co-Authored-By: pieterklue <pieterklue@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(browse): terminate orphan server when parent process exits (#808) Community PR #808 by @mmporong. Passes BROWSE_PARENT_PID to the spawned server process. The server polls every 15s with signal 0 and calls shutdown() if the parent is gone. Prevents orphaned chrome-headless-shell processes when Claude Code sessions exit abnormally. Co-Authored-By: mmporong <mmporong@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(security): IPv6 ULA blocking, cookie redaction, per-tab cancel, targeted token (#664) Community PR #664 by @mr-k-man (security audit round 1, new parts only). - IPv6 ULA prefix blocking (fc00::/7) in url-validation.ts with false-positive guard for hostnames like fd.example.com - Cookie value redaction for tokens, API keys, JWTs in browse cookies command - Per-tab cancel files in killAgent() replacing broken global kill-signal - design/serve.ts: realpathSync upgrade prevents symlink bypass in /api/reload - extension: targeted getToken handler replaces token-in-health-broadcast - Supabase migration 003: column-level GRANT restricts anon UPDATE scope - Telemetry sync: upsert error logging - 10 new tests for IPv6, cookie redaction, DNS rebinding, path traversal Co-Authored-By: mr-k-man <mr-k-man@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(security): CSS injection guard, timeout clamping, session validation, tests (#806) Community PR #806 by @mr-k-man (security audit round 2, new parts only). - CSS value validation (DANGEROUS_CSS) in cdp-inspector, write-commands, extension inspector - Queue file permissions (0o700/0o600) in cli, server, sidebar-agent - escapeRegExp for frame --url ReDoS fix - Responsive screenshot path validation with validateOutputPath - State load cookie filtering (reject localhost/.internal/metadata cookies) - Session ID format validation in loadSession - /health endpoint: remove currentUrl and currentMessage fields - QueueEntry interface + isValidQueueEntry validator for sidebar-agent - SIGTERM->SIGKILL escalation in timeout handler - Viewport dimension clamping (1-16384), wait timeout clamping (1s-300s) - Cookie domain validation in cookie-import and cookie-import-browser - DocumentFragment-based tab switching (XSS fix in sidepanel) - pollInProgress reentrancy guard for pollChat - toggleClass/injectCSS input validation in extension inspector - Snapshot annotated path validation with realpathSync - 714-line security-audit-r2.test.ts + 33-line learnings-injection.test.ts Co-Authored-By: mr-k-man <mr-k-man@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.15.13.0) Community security wave: 8 PRs from 4 contributors (@garagon, @mr-k-man, @mmporong, @0531Kim, @pieterklue). IPv6 ULA blocking, cookie redaction, per-tab cancel signaling, CSS injection guards, timeout clamping, session validation, DocumentFragment XSS fix, parent process watchdog, uninstall docs, Windows fixes, and 750+ lines of security regression tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: garagon <garagon@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: 0531Kim <0531Kim@users.noreply.github.com> Co-authored-by: pieterklue <pieterklue@users.noreply.github.com> Co-authored-by: mmporong <mmporong@users.noreply.github.com> Co-authored-by: mr-k-man <mr-k-man@users.noreply.github.com>
549 lines
19 KiB
JavaScript
549 lines
19 KiB
JavaScript
/**
|
|
* 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',
|
|
// 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') {
|
|
sendResponse({ port: serverPort, connected: isConnected, token: authToken });
|
|
return true;
|
|
}
|
|
|
|
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 Switch Detection ────────────────────────────────────────
|
|
// Notify sidepanel instantly when the user switches tabs in the browser.
|
|
// This is faster than polling — the sidebar swaps chat context immediately.
|
|
|
|
chrome.tabs.onActivated.addListener((activeInfo) => {
|
|
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(() => {}); // expected: sidepanel may not be open
|
|
});
|
|
});
|
|
|
|
// ─── 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);
|
|
});
|
|
});
|