verstak/extension/background.js

256 lines
6.6 KiB
JavaScript

// 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}`;
}