265 lines
7.1 KiB
JavaScript
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}`;
|
|
}
|