// Verstak Bridge for Firefox — Background Service Worker // Uses browser.* WebExtensions API with chrome.* polyfill layer. // // IMPORTANT: All 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. // Firefox uses browser.* namespace; provide chrome.* aliases for shared code. if (typeof browser !== 'undefined' && typeof chrome === 'undefined') { globalThis.chrome = browser; } 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 DEFAULT_DEVICE_ID = generateDeviceId(); // Session state let session = { url: '', title: '', domain: '', tabId: -1, startedAt: null, }; // --- Global event listeners (registered once at script load) --- chrome.runtime.onInstalled.addListener(() => { setupAlarms(); // Generate a stable device ID on first install chrome.storage.local.get(BRIDGE_KEY, (data) => { const cfg = data[BRIDGE_KEY] || {}; if (!cfg.deviceId) { chrome.storage.local.set({ [BRIDGE_KEY]: { ...DEFAULT_CONFIG, ...cfg, deviceId: DEFAULT_DEVICE_ID } }); } }); }); 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 === 'FORCE_PING') { pingBridge(); 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 < 3) { session.startedAt = Date.now(); return; } const event = { id: generateId(), type: 'page_visit', url: session.url, title: session.title, domain: session.domain, active_seconds: duration, 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 deviceId = config.deviceId || DEFAULT_DEVICE_ID; const payload = { version: 1, device_id: 'firefox-' + deviceId, 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), signal: AbortSignal.timeout(5000), }) .then((res) => { if (res.ok) { for (const ev of queue) { updateRecent(ev); } chrome.storage.local.set({ [STORAGE_KEY]: [] }); chrome.storage.local.set({ [BRIDGE_KEY]: { ...config, bridgeReachable: true, lastPing: Date.now() } }); chrome.runtime.sendMessage({ type: 'UI_UPDATE' }).catch(() => {}); } else if (res.status === 401) { console.warn('[verstak] bridge auth failed, check secret'); chrome.storage.local.set({ [BRIDGE_KEY]: { ...config, bridgeReachable: false, lastPing: Date.now() } }); } else { console.warn('[verstak] bridge error', res.status); chrome.storage.local.set({ [BRIDGE_KEY]: { ...config, bridgeReachable: false, lastPing: Date.now() } }); } }) .catch((err) => { console.warn('[verstak] bridge unreachable:', err.message); chrome.storage.local.set({ [BRIDGE_KEY]: { ...config, bridgeReachable: false, lastPing: Date.now() } }); }); }); } 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`, { signal: AbortSignal.timeout(3000), }) .then((res) => res.json()) .then((info) => { chrome.storage.local.set({ [BRIDGE_KEY]: { ...config, ...info, bridgeReachable: true, lastPing: Date.now() } }); chrome.runtime.sendMessage({ type: 'UI_UPDATE' }).catch(() => {}); }) .catch(() => { chrome.storage.local.set({ [BRIDGE_KEY]: { ...config, bridgeReachable: false, lastPing: Date.now() } }); chrome.runtime.sendMessage({ type: 'UI_UPDATE' }).catch(() => {}); }); }); } // --- 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) { return url.startsWith('about:') || url.startsWith('moz-extension://') || url.startsWith('chrome://') || url.startsWith('chrome-extension://') || url.startsWith('edge://') || url.startsWith('file://') || url.startsWith('data:') || url.startsWith('blob:') || url.startsWith('view-source:') || url.startsWith('resource:'); } function generateId() { const ts = Date.now().toString(36); const rand = Math.random().toString(36).substring(2, 8); return `evt_${ts}_${rand}`; } function generateDeviceId() { const buf = new Uint8Array(6); crypto.getRandomValues(buf); return Array.from(buf, b => b.toString(16).padStart(2, '0')).join(''); }