// Verstak Bridge — Background Service Worker (Chrome) // Tracks active tab changes and queues browser events. // Pushes events to the Verstak HTTP bridge when available. // // IMPORTANT: All chrome.* event listeners are registered at global scope, // NOT inside onInstalled/onStartup. MV3 service workers can be restarted at // any time; listeners inside lifecycle hooks are lost on restart. // See: https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/events const STORAGE_KEY = 'verstak_queue'; const BRIDGE_KEY = 'verstak_bridge_config'; const RECENT_KEY = 'verstak_recent'; const DEFAULT_CONFIG = { port: 9786, secret: '', autoGenPort: false }; const IDLE_RESET_MS = 60_000; // Session state let session = { url: '', title: '', domain: '', tabId: -1, startedAt: null, }; // --- Global event listeners (registered once at script load) --- chrome.runtime.onInstalled.addListener(() => { setupAlarms(); }); chrome.runtime.onStartup.addListener(() => { setupAlarms(); }); // Tab tracking — global scope chrome.tabs.onActivated.addListener(onTabActivated); chrome.tabs.onUpdated.addListener(onTabUpdated); chrome.windows.onFocusChanged.addListener(onWindowFocusChanged); // Alarm handler — global scope chrome.alarms.onAlarm.addListener((alarm) => { if (alarm.name === 'flushEvents') flushSession(); if (alarm.name === 'pingBridge') pingBridge(); }); // Message handler — global scope chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { if (msg.type === 'FORCE_FLUSH') { flushSession(); flushQueue(); sendResponse({ ok: true }); } if (msg.type === 'SET_TRACKING') { chrome.storage.local.set({ 'verstak_tracking_enabled': msg.enabled }); sendResponse({ ok: true }); } return true; }); // --- Alarm setup --- function setupAlarms() { chrome.alarms.create('flushEvents', { periodInMinutes: 0.5 }); chrome.alarms.create('pingBridge', { periodInMinutes: 1 }); } // --- Tab tracking --- function onTabActivated(activeInfo) { flushSession(); startTracking(activeInfo.tabId); } function onTabUpdated(tabId, changeInfo, tab) { if (changeInfo.status === 'complete' && tab.active) { flushSession(); startTracking(tabId); } } function onWindowFocusChanged(windowId) { if (windowId === chrome.windows.WINDOW_ID_NONE) { flushSession(); session = { url: '', title: '', domain: '', tabId: -1, startedAt: null }; } else { chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { if (tabs && tabs.length > 0) { startTracking(tabs[0].id); } }); } } function startTracking(tabId) { chrome.tabs.get(tabId, (tab) => { if (chrome.runtime.lastError || !tab || !tab.url) return; if (isInternalPage(tab.url)) return; session = { url: tab.url, title: tab.title || '', domain: extractDomain(tab.url), tabId, startedAt: Date.now(), }; }); } // --- Session flush --- function flushSession() { if (!session.url || !session.startedAt) return; const duration = Math.round((Date.now() - session.startedAt) / 1000); if (duration < 2) { session.startedAt = Date.now(); return; } const event = { id: generateId(), type: 'page_visit', url: session.url, title: session.title, domain: session.domain, active_seconds: Math.min(duration, IDLE_RESET_MS / 1000), ts_start: new Date(session.startedAt).toISOString(), ts_end: new Date().toISOString(), }; queueEvent(event); session.startedAt = Date.now(); } function queueEvent(event) { chrome.storage.local.get(STORAGE_KEY, (data) => { const queue = data[STORAGE_KEY] || []; queue.push(event); if (queue.length > 500) { queue.splice(0, queue.length - 500); } chrome.storage.local.set({ [STORAGE_KEY]: queue }, () => { if (queue.length >= 5) { flushQueue(); } }); }); } // --- Bridge communication --- function flushQueue() { chrome.storage.local.get([STORAGE_KEY, BRIDGE_KEY], (data) => { const queue = data[STORAGE_KEY] || []; if (queue.length === 0) return; const config = data[BRIDGE_KEY] || DEFAULT_CONFIG; const port = config.port || 9786; const secret = config.secret || ''; const payload = { version: 1, device_id: 'chrome-' + (config.deviceId || 'unknown'), events: queue, }; fetch(`http://127.0.0.1:${port}/api/events`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Verstak-Secret': secret, }, body: JSON.stringify(payload), }) .then((res) => { if (res.ok) { for (const ev of queue) { updateRecent(ev); } chrome.storage.local.set({ [STORAGE_KEY]: [] }); chrome.runtime.sendMessage({ type: 'UI_UPDATE' }).catch(() => {}); } else if (res.status === 401) { console.warn('[verstak] bridge auth failed, check secret'); } }) .catch(() => { // Bridge not available — keep events in queue }); }); } function pingBridge() { chrome.storage.local.get(BRIDGE_KEY, (data) => { const config = data[BRIDGE_KEY] || DEFAULT_CONFIG; const port = config.port || 9786; fetch(`http://127.0.0.1:${port}/api/ping`) .then((res) => res.json()) .then((info) => { chrome.storage.local.set({ [BRIDGE_KEY]: { ...config, ...info, bridgeReachable: true, lastPing: Date.now() } }); }) .catch(() => { chrome.storage.local.set({ [BRIDGE_KEY]: { ...config, bridgeReachable: false, lastPing: Date.now() } }); }); }); } // --- Recent events --- function updateRecent(event) { chrome.storage.local.get(RECENT_KEY, (data) => { const recent = data[RECENT_KEY] || []; recent.push({ domain: event.domain, title: event.title, url: event.url, active_seconds: event.active_seconds, ts: new Date().toISOString(), }); if (recent.length > 50) { recent.splice(0, recent.length - 50); } chrome.storage.local.set({ [RECENT_KEY]: recent }); }); } // --- Helpers --- function extractDomain(url) { try { return new URL(url).hostname.replace(/^www\./, ''); } catch { return ''; } } function isInternalPage(url) { const internal = [ 'chrome://', 'chrome-extension://', 'about:', 'edge://', 'file://', 'data:', 'blob:', 'view-source:', 'devtools:', ]; return internal.some((p) => url.startsWith(p)); } function generateId() { const ts = Date.now().toString(36); const rand = Math.random().toString(36).substring(2, 8); return `evt_${ts}_${rand}`; }