This repository has been archived on 2026-10-29. You can view files and clone it, but you cannot make any changes to its state, such as pushing and creating new issues, pull requests or comments.
Loki/extension/service-worker.js
2026-06-13 19:46:10 +01:00

495 lines
16 KiB
JavaScript

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:<port>/ingest/chrome-extension or localhost.",
};
}
await chrome.storage.local.set({ [CONFIG_KEY]: { endpoint: cleanEndpoint } });
return syncTabs("endpoint-updated");
}