import { CONFIG } from "./config.js"; const HOST = "si.komelt.local_history_archive"; const LEGACY_QUEUE_KEY = "pendingVisits"; const LOCAL_QUEUE_KEY = "pendingLocalVisits"; const REMOTE_QUEUE_KEY = "pendingRemoteVisits"; const MAX_QUEUE = 2000; const MAX_REMOTE_URL_LENGTH = 2083; let operationTail = Promise.resolve(); let runtimeSettings = CONFIG; let flushInProgress = false; let flushRequested = false; let activeFlush = null; async function refreshRuntimeSettings() { try { const managed = await chrome.storage.managed.get(["collectorUrl", "apiToken", "deviceId"]); runtimeSettings = { collectorUrl: typeof managed.collectorUrl === "string" ? managed.collectorUrl : CONFIG.collectorUrl, apiToken: typeof managed.apiToken === "string" ? managed.apiToken : CONFIG.apiToken, deviceId: typeof managed.deviceId === "string" ? managed.deviceId : CONFIG.deviceId }; } catch (error) { console.warn("Managed extension settings unavailable", error?.message || error); } } function runSerialized(operation) { const next = operationTail.then(operation, operation); operationTail = next.catch((error) => { console.warn("Queued operation failed", error?.message || error); }); return next; } function capped(visits) { return visits.slice(-MAX_QUEUE); } async function readQueues() { const values = await chrome.storage.local.get([ LEGACY_QUEUE_KEY, LOCAL_QUEUE_KEY, REMOTE_QUEUE_KEY ]); return { legacy: Array.isArray(values[LEGACY_QUEUE_KEY]) ? values[LEGACY_QUEUE_KEY] : [], local: Array.isArray(values[LOCAL_QUEUE_KEY]) ? values[LOCAL_QUEUE_KEY] : [], remote: Array.isArray(values[REMOTE_QUEUE_KEY]) ? values[REMOTE_QUEUE_KEY] : [] }; } async function snapshotQueues() { return runSerialized(async () => { const { legacy, local, remote } = await readQueues(); const localVisits = capped(legacy.length ? [...legacy, ...local] : local); const remoteVisits = capped( legacy.length && remoteEnabled() ? [...legacy.filter(isRemoteVisit), ...remote] : remote ); if ( legacy.length || localVisits.length !== local.length || remoteVisits.length !== remote.length ) { const update = { [LOCAL_QUEUE_KEY]: localVisits, [REMOTE_QUEUE_KEY]: remoteVisits }; if (legacy.length) update[LEGACY_QUEUE_KEY] = []; await chrome.storage.local.set(update); } return { localVisits, remoteVisits }; }); } function sameQueueItem(left, right) { return JSON.stringify(left) === JSON.stringify(right); } function deliveredItemsStillQueued(current, snapshot, offset, deliveredCount) { const confirmed = snapshot.slice(offset, offset + deliveredCount); if (confirmed.length !== deliveredCount || current.length < deliveredCount) return 0; for (let index = 0; index < deliveredCount; index += 1) { if (!sameQueueItem(current[index], confirmed[index])) return 0; } return deliveredCount; } async function acknowledgeQueuePrefix(key, snapshot, offset, deliveredCount) { if (!deliveredCount) return; try { await runSerialized(async () => { const { [key]: current = [] } = await chrome.storage.local.get(key); const visits = Array.isArray(current) ? current : []; const removeCount = deliveredItemsStillQueued(visits, snapshot, offset, deliveredCount); if (removeCount) { await chrome.storage.local.set({ [key]: capped(visits.slice(removeCount)) }); } }); } catch (error) { console.warn("Could not acknowledge queued visits", error?.message || error); } } function remoteEnabled() { return Boolean(runtimeSettings.collectorUrl && runtimeSettings.apiToken); } function isRemoteVisit(visit) { return isHttpUrl(visit?.url) && visit.url.length <= MAX_REMOTE_URL_LENGTH; } async function enqueueVisit(visit) { await refreshRuntimeSettings(); try { await runSerialized(async () => { const { legacy, local, remote } = await readQueues(); const localVisits = [...(legacy.length ? legacy : []), ...local, visit]; const remoteVisits = [ ...(legacy.length && remoteEnabled() ? legacy.filter(isRemoteVisit) : []), ...remote ]; if (remoteEnabled() && isRemoteVisit(visit)) remoteVisits.push(visit); const update = { [LOCAL_QUEUE_KEY]: capped(localVisits), [REMOTE_QUEUE_KEY]: capped(remoteVisits) }; if (legacy.length) update[LEGACY_QUEUE_KEY] = []; await chrome.storage.local.set(update); }); requestFlush(); } catch (error) { console.warn("Could not queue visit", error?.message || error); } } async function archiveLocally(visits) { if (!visits.length) return true; try { const reply = await chrome.runtime.sendNativeMessage(HOST, { type: "visits", visits }); return reply?.ok === true; } catch (error) { console.warn("Local archive unavailable", error.message); return false; } } function isHttpUrl(url) { if (typeof url !== "string") return false; try { const protocol = new URL(url).protocol; return protocol === "http:" || protocol === "https:"; } catch { return false; } } async function sendToCollector(visits, onChunkDelivered) { // Empty settings deliberately disable sending; the local SQLite archive still works. if (!remoteEnabled()) return false; const remoteVisits = visits .filter(isRemoteVisit) .map((visit) => ({ ...visit, title: String(visit.title ?? "").slice(0, 1000) })); try { for (let offset = 0; offset < remoteVisits.length; offset += 100) { const chunk = remoteVisits.slice(offset, offset + 100); const response = await fetch(runtimeSettings.collectorUrl, { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${runtimeSettings.apiToken}` }, body: JSON.stringify({ device_id: runtimeSettings.deviceId, visits: chunk }) }); if (!response.ok) { const detail = (await response.text()).trim().slice(0, 1000); throw new Error(`HTTP ${response.status}${detail ? `: ${detail}` : ""}`); } if (onChunkDelivered) await onChunkDelivered(offset, chunk.length); } return true; } catch (error) { console.warn("Collector unavailable", error.message); return false; } } async function flushOnce() { await refreshRuntimeSettings(); const { localVisits, remoteVisits } = await snapshotQueues(); if (!localVisits.length && !remoteVisits.length) return; const [localOk, remoteOk] = await Promise.all([ archiveLocally(localVisits), sendToCollector( remoteVisits, (offset, deliveredCount) => acknowledgeQueuePrefix( REMOTE_QUEUE_KEY, remoteVisits, offset, deliveredCount ) ) ]); if (localOk && localVisits.length) { await acknowledgeQueuePrefix(LOCAL_QUEUE_KEY, localVisits, 0, localVisits.length); } if (remoteOk && remoteVisits.length) { await acknowledgeQueuePrefix(REMOTE_QUEUE_KEY, remoteVisits, 0, remoteVisits.length); } } async function flushLoop() { while (flushRequested) { flushRequested = false; try { await flushOnce(); } catch (error) { console.warn("Queue flush failed", error?.message || error); } } } function requestFlush() { flushRequested = true; if (flushInProgress) return activeFlush; flushInProgress = true; activeFlush = flushLoop().catch((error) => { console.warn("Queue flush loop failed", error?.message || error); }).then(() => { flushInProgress = false; if (flushRequested) return requestFlush(); return undefined; }); return activeFlush; } chrome.history.onVisited.addListener((item) => { return enqueueVisit({ url: item.url, title: item.title || "", visit_time: new Date(item.lastVisitTime || Date.now()).toISOString(), transition: "observed", source: "chrome-extension" }); }); chrome.downloads.onCreated.addListener((item) => { if (!isHttpUrl(item?.url)) return; const startTime = new Date(item.startTime || Date.now()); return enqueueVisit({ url: item.url, title: "", visit_time: Number.isNaN(startTime.getTime()) ? new Date().toISOString() : startTime.toISOString(), transition: "download", source: "chrome-extension" }); }); chrome.runtime.onStartup.addListener(() => requestFlush()); chrome.runtime.onInstalled.addListener(() => requestFlush()); chrome.alarms.create("local-history-archive-flush", { periodInMinutes: 1 }); chrome.alarms.onAlarm.addListener(() => requestFlush());