verstak/extension-firefox/background.js

301 lines
8.2 KiB
JavaScript

// 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('');
}