mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-02 11:45:20 +02:00
e348957adf
When the user navigates manually in headed Chrome, Playwright's page.url() stays on the old page. The sidebar agent was using this stale URL in its system prompt, causing it to navigate to the wrong page (e.g., Hacker News instead of the user's current page). The Chrome extension now captures the active tab URL via chrome.tabs.query() and sends it as activeTabUrl in the /sidebar-command POST body. The server prefers this over Playwright's URL. The URL is sanitized (http/https only, control chars stripped, 2048 char limit) to prevent prompt injection. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
244 lines
7.4 KiB
JavaScript
244 lines
7.4 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;
|
|
}
|
|
|
|
// ─── Health Polling ────────────────────────────────────────────
|
|
|
|
async function checkHealth() {
|
|
const base = getBaseUrl();
|
|
if (!base) {
|
|
setDisconnected();
|
|
return;
|
|
}
|
|
|
|
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') {
|
|
// Capture auth token from health response
|
|
if (data.token) authToken = data.token;
|
|
// 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
|
|
chrome.runtime.sendMessage({ type: 'health', data: healthData }).catch(() => {});
|
|
|
|
// Notify content scripts on connection change
|
|
if (wasDisconnected) {
|
|
notifyContentScripts('connected');
|
|
}
|
|
}
|
|
|
|
function setDisconnected() {
|
|
const wasConnected = isConnected;
|
|
isConnected = false;
|
|
authToken = null;
|
|
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 resp = await fetch(`${base}/refs`, { signal: AbortSignal.timeout(3000) });
|
|
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 {}
|
|
}
|
|
|
|
// ─── Message Handling ──────────────────────────────────────────
|
|
|
|
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|
if (msg.type === 'getPort') {
|
|
sendResponse({ port: serverPort, connected: isConnected });
|
|
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;
|
|
}
|
|
|
|
if (msg.type === 'getToken') {
|
|
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(() => {});
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 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 on install/update — zero friction
|
|
chrome.runtime.onInstalled.addListener(async () => {
|
|
// Small delay to let the browser window fully initialize
|
|
setTimeout(async () => {
|
|
try {
|
|
const [win] = await chrome.windows.getAll({ windowTypes: ['normal'] });
|
|
if (win && chrome.sidePanel?.open) {
|
|
await chrome.sidePanel.open({ windowId: win.id });
|
|
}
|
|
} catch {}
|
|
}, 1000);
|
|
});
|
|
|
|
// ─── Startup ────────────────────────────────────────────────────
|
|
|
|
loadPort().then(() => {
|
|
checkHealth();
|
|
healthInterval = setInterval(checkHealth, 10000);
|
|
});
|