274 lines
7.0 KiB
JavaScript
274 lines
7.0 KiB
JavaScript
// Verstak Bridge for Firefox — Background Service Worker
|
|
// Uses browser.* WebExtensions API with chrome.* polyfill layer.
|
|
// Functionally identical to the Chrome version.
|
|
|
|
// 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: true };
|
|
const DEFAULT_DEVICE_ID = generateDeviceId();
|
|
|
|
// Session state
|
|
let session = {
|
|
url: '',
|
|
title: '',
|
|
domain: '',
|
|
tabId: -1,
|
|
startedAt: null,
|
|
};
|
|
|
|
// --- Lifecycle ---
|
|
|
|
chrome.runtime.onInstalled.addListener(() => {
|
|
setupAlarms();
|
|
setupListeners();
|
|
// 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();
|
|
setupListeners();
|
|
});
|
|
|
|
function setupAlarms() {
|
|
chrome.alarms.create('flushEvents', { periodInMinutes: 0.5 });
|
|
chrome.alarms.create('pingBridge', { periodInMinutes: 1 });
|
|
}
|
|
|
|
function setupListeners() {
|
|
chrome.tabs.onActivated.addListener(onTabActivated);
|
|
chrome.tabs.onUpdated.addListener(onTabUpdated);
|
|
chrome.windows.onFocusChanged.addListener(onWindowFocusChanged);
|
|
|
|
chrome.alarms.onAlarm.addListener((alarm) => {
|
|
if (alarm.name === 'flushEvents') flushSession();
|
|
if (alarm.name === 'pingBridge') pingBridge();
|
|
});
|
|
|
|
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; // keep channel open for async response
|
|
});
|
|
}
|
|
|
|
// --- 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),
|
|
})
|
|
.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) {
|
|
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('');
|
|
}
|