mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-05 21:25:27 +02:00
7aa3973564
The sidebar called tryConnect() → getPort → got {port, connected} but
NO token. All subsequent requests (SSE, chat poll) failed with 401.
The token only arrived later via the health broadcast, but by then
the SSE connection was already broken.
Fix: include authToken in the getPort response so the sidebar has
the token from its very first connection attempt.
464 lines
15 KiB
JavaScript
464 lines
15 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 {}
|
|
}
|
|
|
|
// ─── 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') {
|
|
// Forward chatEnabled so sidepanel can show/hide chat tab
|
|
setConnected({ ...data, chatEnabled: !!data.chatEnabled });
|
|
} else {
|
|
setDisconnected();
|
|
}
|
|
} catch {
|
|
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 (include token for sidepanel auth)
|
|
chrome.runtime.sendMessage({ type: 'health', data: { ...healthData, token: authToken } }).catch(() => {});
|
|
|
|
// 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(() => {});
|
|
|
|
// 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(() => {});
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
// ─── 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) 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(() => {});
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
// ─── 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 {}
|
|
// Send startPicker to the injected inspector.js
|
|
try {
|
|
await chrome.tabs.sendMessage(tabId, { type: 'startPicker' });
|
|
} catch {}
|
|
inspectorMode = 'full';
|
|
return { ok: true, mode: 'full' };
|
|
} catch {
|
|
// 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 {
|
|
inspectorMode = 'full';
|
|
return { error: 'Cannot inspect this page' };
|
|
}
|
|
}
|
|
}
|
|
|
|
async function stopInspector(tabId) {
|
|
try {
|
|
await chrome.tabs.sendMessage(tabId, { type: 'stopPicker' });
|
|
} catch {}
|
|
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 {
|
|
// No server or timeout — fall back to basic mode
|
|
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', 'fetchRefs',
|
|
'openSidePanel', '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;
|
|
}
|
|
|
|
// getToken handler removed — token distributed via health broadcast
|
|
|
|
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(() => {});
|
|
}
|
|
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(() => {});
|
|
sendResponse({ ok: true });
|
|
});
|
|
});
|
|
return true;
|
|
}
|
|
|
|
// Inspector: picker cancelled
|
|
if (msg.type === 'pickerCancelled') {
|
|
chrome.runtime.sendMessage({ type: 'pickerCancelled' }).catch(() => {});
|
|
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 => r.json())
|
|
.then(data => sendResponse(data))
|
|
.catch(err => 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(() => {});
|
|
}
|
|
|
|
// 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(() => {}); // sidepanel may not be open
|
|
});
|
|
});
|
|
|
|
// ─── Startup ────────────────────────────────────────────────────
|
|
|
|
// Load auth token BEFORE first health poll (token no longer in /health response)
|
|
loadAuthToken().then(() => {
|
|
loadPort().then(() => {
|
|
checkHealth();
|
|
healthInterval = setInterval(checkHealth, 10000);
|
|
});
|
|
});
|