verstak/extension/background.js

265 lines
7.1 KiB
JavaScript

// Verstak Bridge — Background Service Worker
// Tracks active tab changes and queues browser events.
// Pushes events to the Verstak HTTP bridge when available.
const STORAGE_KEY = 'verstak_queue';
const BRIDGE_KEY = 'verstak_bridge_config';
const DEFAULT_CONFIG = { port: 9786, secret: '', autoGenPort: true };
const FLUSH_INTERVAL_MS = 30_000; // flush every 30s
const IDLE_RESET_MS = 60_000; // reset session after 60s idle
// Session state
let session = {
url: '',
title: '',
domain: '',
tabId: -1,
startedAt: null,
};
// Start tracking on install
chrome.runtime.onInstalled.addListener(() => {
setupAlarms();
setupListeners();
});
// Wake up on browser start
chrome.runtime.onStartup.addListener(() => {
setupAlarms();
setupListeners();
});
function setupAlarms() {
chrome.alarms.create('flushEvents', { periodInMinutes: 0.5 });
chrome.alarms.create('pingBridge', { periodInMinutes: 1 });
}
function setupListeners() {
// Track active tab changes
chrome.tabs.onActivated.addListener(onTabActivated);
chrome.tabs.onUpdated.addListener(onTabUpdated);
chrome.windows.onFocusChanged.addListener(onWindowFocusChanged);
// Listen for flush alarm
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === 'flushEvents') flushSession();
if (alarm.name === 'pingBridge') pingBridge();
});
}
// Called when user switches tabs
function onTabActivated(activeInfo) {
flushSession();
startTracking(activeInfo.tabId);
}
// Called when a tab's URL or title changes
function onTabUpdated(tabId, changeInfo, tab) {
if (changeInfo.status === 'complete' && tab.active) {
flushSession();
startTracking(tabId);
}
}
// Called when window focus changes
function onWindowFocusChanged(windowId) {
if (windowId === chrome.windows.WINDOW_ID_NONE) {
// Window lost focus — flush and stop session
flushSession();
session = { url: '', title: '', domain: '', tabId: -1, startedAt: null };
} else {
// Window gained focus — get active tab and start tracking
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: tabId,
startedAt: Date.now(),
};
});
}
function flushSession() {
if (!session.url || !session.startedAt) return;
const duration = Math.round((Date.now() - session.startedAt) / 1000);
if (duration < 2) {
// Skip very short visits
session.startedAt = Date.now(); // reset timer
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(); // reset for next segment
}
function queueEvent(event) {
chrome.storage.local.get(STORAGE_KEY, (data) => {
const queue = data[STORAGE_KEY] || [];
queue.push(event);
// Keep max 500 events in queue
if (queue.length > 500) {
queue.splice(0, queue.length - 500);
}
chrome.storage.local.set({ [STORAGE_KEY]: queue }, () => {
// Immediately try to flush if we have enough events
if (queue.length >= 5) {
flushQueue();
}
});
});
}
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) {
// Update recent events
for (const ev of queue) {
updateRecent(ev);
}
// Clear queue on success
chrome.storage.local.set({ [STORAGE_KEY]: [] });
// Notify popup if open
chrome.runtime.sendMessage({ type: 'UI_UPDATE' }).catch(() => {});
} else if (res.status === 401) {
console.warn('[verstak] bridge auth failed, check secret');
}
})
.catch((err) => {
// Bridge not available — keep events in queue
if (queue.length % 10 === 0) {
console.debug('[verstak] bridge unavailable:', err.message);
}
});
});
}
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() }
});
});
});
}
// Listen for messages from popup
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === 'FORCE_FLUSH') {
flushSession();
flushQueue();
sendResponse({ ok: true });
}
if (msg.type === 'SET_TRACKING') {
// Tracking is always on at the background level;
// the popup just shows the setting.
chrome.storage.local.set({ 'verstak_tracking_enabled': msg.enabled });
sendResponse({ ok: true });
}
});
// Update recent events from flushed queue entries.
// This is called by flushQueue on successful delivery.
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(),
});
// Keep last 50
if (recent.length > 50) {
recent.splice(0, recent.length - 50);
}
chrome.storage.local.set({ [RECENT_KEY]: recent });
});
}
const RECENT_KEY = 'verstak_recent';
// ... existing code continues below ...
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}`;
}