const DEFAULT_ENDPOINT = "http://127.0.0.1:17666/ingest/chrome-extension"; const STATUS_KEY = "lokiStatus"; const CONFIG_KEY = "lokiConfig"; chrome.runtime.onInstalled.addListener(() => { chrome.alarms.create("loki-sync", { periodInMinutes: 1 }); syncTabs("installed"); }); chrome.runtime.onStartup.addListener(() => { chrome.alarms.create("loki-sync", { periodInMinutes: 1 }); syncTabs("startup"); }); chrome.alarms.onAlarm.addListener((alarm) => { if (alarm.name === "loki-sync") syncTabs("alarm"); }); chrome.tabs.onCreated.addListener(() => syncTabs("tab-created")); chrome.tabs.onUpdated.addListener(() => syncTabs("tab-updated")); chrome.tabs.onRemoved.addListener(() => syncTabs("tab-removed")); chrome.tabs.onActivated.addListener(() => syncTabs("tab-activated")); chrome.tabs.onMoved.addListener(() => syncTabs("tab-moved")); chrome.tabs.onAttached.addListener(() => syncTabs("tab-attached")); chrome.tabs.onDetached.addListener(() => syncTabs("tab-detached")); chrome.tabs.onHighlighted.addListener(() => syncTabs("tab-highlighted")); chrome.windows.onFocusChanged.addListener(() => syncTabs("window-focus")); chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { if (message?.type === "loki.sync-now") { syncTabs("manual").then(sendResponse); return true; } if (message?.type === "loki.debug-sweep") { debugSweep().then(sendResponse); return true; } if (message?.type === "loki.snapshot-active-tab") { snapshotActiveTab().then(sendResponse); return true; } if (message?.type === "loki.get-status") { getStatus().then(sendResponse); return true; } if (message?.type === "loki.set-endpoint") { setEndpoint(message.endpoint).then(sendResponse); return true; } return false; }); async function syncTabs(reason, extra = {}) { const statusStartedAt = new Date().toISOString(); try { const endpoint = await getEndpoint(); const tabs = await chrome.tabs.query({}); const report = { schema: "gamecult.loki.chrome_extension_report.v0", extensionVersion: chrome.runtime.getManifest().version, browser: navigator.userAgent, reportedAt: statusStartedAt, reason, tabs: tabs.map(projectTab), summary: { tabCount: tabs.length, activeTabCount: tabs.filter((tab) => tab.active).length, }, redaction: { pageContent: extra.pageSnapshot ? "explicit-active-tab-visible-text" : "not-read", cookies: "not-read", tabMetadata: "title-url-favicon-window-state", debugProbe: extra.debugProbe ? "runtime-frame-performance-security-metadata" : "not-run", pageSnapshot: extra.pageSnapshot ? extra.pageSnapshot.redaction : "not-run", }, ...extra, }; const response = await fetch(endpoint, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(report), }); const responseBody = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(responseBody.error || `HTTP ${response.status}`); } const status = { ok: true, endpoint, reason, lastSyncAt: new Date().toISOString(), tabCount: tabs.length, debugProbe: responseBody.debugProbe ?? null, pageSnapshot: responseBody.pageSnapshot ?? null, activeSource: responseBody.activeSource ?? "unknown", error: null, }; await chrome.storage.local.set({ [STATUS_KEY]: status }); return status; } catch (error) { const endpoint = await getEndpoint(); const status = { ok: false, endpoint, reason, lastSyncAt: new Date().toISOString(), tabCount: 0, debugProbe: null, pageSnapshot: null, activeSource: "none", error: error instanceof Error ? error.message : String(error), }; await chrome.storage.local.set({ [STATUS_KEY]: status }); return status; } } async function snapshotActiveTab() { const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); const startedAt = new Date().toISOString(); if (!tab || !Number.isInteger(tab.id)) { return syncTabs("active-page-snapshot", { pageSnapshot: failedPageSnapshot(startedAt, "No active tab is available."), }); } const pageSnapshot = await capturePageSnapshot(tab, startedAt); return syncTabs("active-page-snapshot", { pageSnapshot }); } async function capturePageSnapshot(tab, startedAt) { const target = { tabId: tab.id }; const pageSnapshot = { schema: "gamecult.loki.chrome_page_snapshot.v0", capturedAt: startedAt, completedAt: null, tab: projectTab(tab), attached: false, attachError: null, error: null, content: null, forms: null, redaction: { trigger: "operator-popup-click", scope: "active-tab-only", pageContent: "visible-text-and-page-structure", cookies: "not-read", passwordValues: "redacted", hiddenInputs: "omitted", responseBodies: "not-read", }, }; try { await chromeDebuggerAttach(target, "1.3"); pageSnapshot.attached = true; await chromeDebuggerSendCommand(target, "Runtime.enable"); const result = await chromeDebuggerSendCommand(target, "Runtime.evaluate", { expression: pageSnapshotExpression(), returnByValue: true, awaitPromise: true, }); const value = result?.result?.value; if (!value || typeof value !== "object") { throw new Error("Page snapshot did not return an object."); } pageSnapshot.content = value.content ?? null; pageSnapshot.forms = value.forms ?? null; } catch (error) { pageSnapshot.error = error instanceof Error ? error.message : String(error); if (!pageSnapshot.attached) pageSnapshot.attachError = pageSnapshot.error; } finally { pageSnapshot.completedAt = new Date().toISOString(); if (pageSnapshot.attached) { await chromeDebuggerDetach(target).catch(() => {}); } } return pageSnapshot; } function failedPageSnapshot(startedAt, error) { return { schema: "gamecult.loki.chrome_page_snapshot.v0", capturedAt: startedAt, completedAt: new Date().toISOString(), tab: null, attached: false, attachError: error, error, content: null, forms: null, redaction: { trigger: "operator-popup-click", scope: "active-tab-only", pageContent: "not-read", cookies: "not-read", passwordValues: "not-read", hiddenInputs: "not-read", responseBodies: "not-read", }, }; } function pageSnapshotExpression() { return `(() => { const limit = (value, max) => String(value ?? "").replace(/\\s+/g, " ").trim().slice(0, max); const selectorFor = (element) => { if (!element) return null; if (element.id) return "#" + CSS.escape(element.id); const name = element.getAttribute("name"); if (name) return element.tagName.toLowerCase() + "[name=" + JSON.stringify(name) + "]"; return element.tagName.toLowerCase(); }; const labelFor = (control) => { const explicit = control.id ? document.querySelector("label[for=" + JSON.stringify(control.id) + "]") : null; const implicit = control.closest("label"); const aria = control.getAttribute("aria-label") || control.getAttribute("title") || control.getAttribute("placeholder"); return limit(explicit?.innerText || implicit?.innerText || aria || "", 300); }; const controls = Array.from(document.querySelectorAll("input, select, textarea, button")) .filter((control) => { const type = (control.getAttribute("type") || control.tagName).toLowerCase(); return type !== "hidden" && type !== "password"; }) .slice(0, 300) .map((control) => { const tag = control.tagName.toLowerCase(); const type = (control.getAttribute("type") || tag).toLowerCase(); const option = tag === "select" ? control.selectedOptions?.[0] : null; const value = type === "checkbox" || type === "radio" ? String(Boolean(control.checked)) : tag === "select" ? limit(option?.innerText || option?.label || "", 500) : limit(control.value || "", 500); return { tag, type, label: labelFor(control), selector: selectorFor(control), name: control.getAttribute("name"), id: control.id || null, disabled: Boolean(control.disabled), required: Boolean(control.required), value, }; }); const links = Array.from(document.links).slice(0, 200).map((link) => ({ text: limit(link.innerText || link.textContent || "", 300), href: link.href, })); const headings = Array.from(document.querySelectorAll("h1,h2,h3,h4,h5,h6")).slice(0, 80).map((heading) => ({ level: heading.tagName.toLowerCase(), text: limit(heading.innerText || heading.textContent || "", 500), })); const visibleText = limit(document.body?.innerText || "", 60000); return { content: { href: location.href, title: document.title, readyState: document.readyState, language: document.documentElement.lang || null, visibleText, visibleTextLength: visibleText.length, headings, links, }, forms: { controlCount: controls.length, controls, }, }; })()`; } async function debugSweep() { const startedAt = new Date().toISOString(); const tabs = await chrome.tabs.query({}); const targets = await chromeDebuggerGetTargets(); const probe = { schema: "gamecult.loki.chrome_debug_probe.v0", startedAt, completedAt: null, protocolVersion: "1.3", targetCount: targets.length, targets: targets.map(projectDebugTarget), tabs: [], summary: { attemptedTabCount: 0, attachedTabCount: 0, failedTabCount: 0, commandCount: 0, commandFailureCount: 0, }, redaction: { pageContent: "not-read", cookies: "not-read", responseBodies: "not-read", domTree: "root-node-only", runtimeEvaluation: "location-title-readyState-visibility-performance-navigator", }, }; for (const tab of tabs) { if (!Number.isInteger(tab.id)) continue; probe.summary.attemptedTabCount += 1; const tabProbe = await probeTab(tab); probe.tabs.push(tabProbe); if (tabProbe.attached) probe.summary.attachedTabCount += 1; else probe.summary.failedTabCount += 1; probe.summary.commandCount += tabProbe.commands.length; probe.summary.commandFailureCount += tabProbe.commands.filter((command) => !command.ok).length; } probe.completedAt = new Date().toISOString(); return syncTabs("debug-sweep", { debugProbe: probe }); } async function probeTab(tab) { const target = { tabId: tab.id }; const tabProbe = { tabId: tab.id, windowId: tab.windowId, title: tab.title ?? null, url: tab.url ?? null, attached: false, attachError: null, commands: [], frameTree: null, runtime: null, performanceMetrics: null, securityState: null, domRoot: null, }; try { await chromeDebuggerAttach(target, "1.3"); tabProbe.attached = true; await recordCommand(tabProbe, target, "Page.enable"); await recordCommand(tabProbe, target, "Runtime.enable"); await recordCommand(tabProbe, target, "Log.enable"); await recordCommand(tabProbe, target, "Performance.enable"); await recordCommand(tabProbe, target, "Security.enable"); await recordCommand(tabProbe, target, "DOM.enable"); tabProbe.frameTree = await recordCommand(tabProbe, target, "Page.getFrameTree"); tabProbe.runtime = await recordCommand(tabProbe, target, "Runtime.evaluate", { expression: `({ href: location.href, title: document.title, readyState: document.readyState, visibilityState: document.visibilityState, referrer: document.referrer, origin: location.origin, navigation: performance.getEntriesByType("navigation").map((entry) => entry.toJSON?.() ?? {}), screen: { width: screen.width, height: screen.height, colorDepth: screen.colorDepth }, navigator: { userAgent: navigator.userAgent, language: navigator.language, languages: navigator.languages, hardwareConcurrency: navigator.hardwareConcurrency, deviceMemory: navigator.deviceMemory ?? null, platform: navigator.platform, cookieEnabled: navigator.cookieEnabled } })`, returnByValue: true, awaitPromise: false, }); tabProbe.performanceMetrics = await recordCommand(tabProbe, target, "Performance.getMetrics"); tabProbe.securityState = await recordCommand(tabProbe, target, "Security.getSecurityState"); tabProbe.domRoot = await recordCommand(tabProbe, target, "DOM.getDocument", { depth: 0, pierce: false }); } catch (error) { tabProbe.attachError = error instanceof Error ? error.message : String(error); } finally { if (tabProbe.attached) { await chromeDebuggerDetach(target).catch(() => {}); } } return tabProbe; } async function recordCommand(tabProbe, target, method, params = undefined) { try { const result = await chromeDebuggerSendCommand(target, method, params); tabProbe.commands.push({ method, ok: true }); return result ?? {}; } catch (error) { tabProbe.commands.push({ method, ok: false, error: error instanceof Error ? error.message : String(error), }); return null; } } function projectDebugTarget(target) { return { id: target.id ?? null, tabId: target.tabId ?? null, type: target.type ?? null, title: target.title ?? null, url: target.url ?? null, attached: Boolean(target.attached), extensionId: target.extensionId ?? null, }; } function projectTab(tab) { return { id: tab.id, windowId: tab.windowId, index: tab.index, active: tab.active, highlighted: tab.highlighted, pinned: tab.pinned, audible: tab.audible ?? false, muted: Boolean(tab.mutedInfo?.muted), incognito: tab.incognito, title: tab.title ?? null, url: tab.url ?? null, favIconUrl: tab.favIconUrl ?? null, }; } async function getStatus() { const values = await chrome.storage.local.get([STATUS_KEY, CONFIG_KEY]); return { endpoint: values[CONFIG_KEY]?.endpoint ?? DEFAULT_ENDPOINT, status: values[STATUS_KEY] ?? null, }; } function chromeDebuggerGetTargets() { return new Promise((resolve, reject) => { chrome.debugger.getTargets((targets) => { const error = chrome.runtime.lastError; if (error) reject(new Error(error.message)); else resolve(targets ?? []); }); }); } function chromeDebuggerAttach(target, protocolVersion) { return new Promise((resolve, reject) => { chrome.debugger.attach(target, protocolVersion, () => { const error = chrome.runtime.lastError; if (error) reject(new Error(error.message)); else resolve(); }); }); } function chromeDebuggerSendCommand(target, method, params) { return new Promise((resolve, reject) => { chrome.debugger.sendCommand(target, method, params ?? {}, (result) => { const error = chrome.runtime.lastError; if (error) reject(new Error(error.message)); else resolve(result); }); }); } function chromeDebuggerDetach(target) { return new Promise((resolve, reject) => { chrome.debugger.detach(target, () => { const error = chrome.runtime.lastError; if (error) reject(new Error(error.message)); else resolve(); }); }); } async function getEndpoint() { const values = await chrome.storage.local.get(CONFIG_KEY); return values[CONFIG_KEY]?.endpoint ?? DEFAULT_ENDPOINT; } async function setEndpoint(endpoint) { const cleanEndpoint = String(endpoint || "").trim(); if (!/^https?:\/\/(127\.0\.0\.1|localhost):\d+\/ingest\/chrome-extension$/.test(cleanEndpoint)) { return { ok: false, error: "Endpoint must be http://127.0.0.1:/ingest/chrome-extension or localhost.", }; } await chrome.storage.local.set({ [CONFIG_KEY]: { endpoint: cleanEndpoint } }); return syncTabs("endpoint-updated"); }