From 1f10389016f6e5e2d2348846e8a9c8f8c02c44a3 Mon Sep 17 00:00:00 2001 From: jensmohr Date: Sun, 14 Jun 2026 18:15:38 +0200 Subject: [PATCH] Implement passive consent evidence workflow with extension-driven capture --- src/background.js | 524 ++++++++++++++++++++++++++++++------ src/content/tcf-listener.js | 78 ++++++ src/injected/tcf-bridge.js | 14 + src/popup/popup.css | 36 +++ src/popup/popup.html | 25 ++ src/popup/popup.js | 153 +++++++++++ 6 files changed, 751 insertions(+), 79 deletions(-) diff --git a/src/background.js b/src/background.js index ddcf94a..c4b3fb8 100644 --- a/src/background.js +++ b/src/background.js @@ -9,11 +9,13 @@ const GVL_AUTO_UPDATE_SOURCE = "extension_startup"; const AUTO_GVL_CHECK_THROTTLE_MS = 24 * 60 * 60 * 1000; const AUTO_GVL_CHECK_STORAGE_KEY = "vendorgetAutoGvlUpdateStatus"; const CONSENT_CAPTURE_SESSION_TIMEOUT_MS = 10 * 60 * 1000; +const CONSENT_CAPTURE_BADGE_BLINK_MS = 700; let isAutoGvlCheckRunning = false; let lastAutoGvlCheckStartedAt = null; let latestGvlUpdateStatus = null; const consentCaptureSessions = new Map(); +const consentCaptureBadgeTimers = new Map(); browser.runtime.onMessage.addListener((message, sender) => handleVendorGetMessage(message, sender) @@ -30,7 +32,7 @@ browser.tabs.onRemoved.addListener((tabId) => { browser.tabs.onUpdated.addListener((tabId, changeInfo) => { if (changeInfo.url) { - cleanupConsentCaptureSessionsForTab(tabId, "page_changed"); + cleanupConsentCaptureSessionsForTab(tabId, "page_changed", changeInfo.url); } }); @@ -133,6 +135,22 @@ async function handleVendorGetMessage(message, sender) { return handleGetLatestConsentStateMessage(); } + if (message.type === "get_pre_consent_capture_status") { + return handleGetPreConsentCaptureStatusMessage(message); + } + + if (message.type === "start_pre_consent_capture") { + return handleStartPreConsentCaptureMessage(message); + } + + if (message.type === "decline_pre_consent_capture") { + return handleDeclinePreConsentCaptureMessage(message); + } + + if (message.type === "probe_pre_consent_capture_for_tab") { + return handleProbePreConsentCaptureForTabMessage(message); + } + if (message.type === "get_consent_evidence_chain") { return handleGetConsentEvidenceChainMessage(message); } @@ -1003,6 +1021,178 @@ async function handleGetLatestConsentStateMessage() { }; } +async function handleGetPreConsentCaptureStatusMessage(message) { + const tabId = normalizeTabId(message?.payload?.tabId); + const session = getOpenConsentCaptureSessionForTab(tabId); + + return { + success: true, + capture: session ? buildPreConsentCaptureStatus(session) : null + }; +} + +async function handleStartPreConsentCaptureMessage(message) { + const tabId = normalizeTabId(message?.payload?.tabId); + const session = getOpenConsentCaptureSessionForTab(tabId); + + if (!session || session.status !== "attention") { + return { + success: false, + error: "pre_consent_capture_not_available" + }; + } + + session.status = "recording"; + session.captureSessionId = createCaptureSessionId(); + updateConsentCaptureBadge(session); + + const bufferedEvents = session.bufferedPreConsentEvents; + session.bufferedPreConsentEvents = []; + + for (const bufferedEvent of bufferedEvents) { + await persistProviderAnnouncementEvent({ + captureSessionId: session.captureSessionId, + rawCapture: bufferedEvent.rawCapture, + sender: bufferedEvent.sender + }); + } + + return { + success: true, + capture: buildPreConsentCaptureStatus(session) + }; +} + +async function handleDeclinePreConsentCaptureMessage(message) { + const tabId = normalizeTabId(message?.payload?.tabId); + const session = getOpenConsentCaptureSessionForTab(tabId); + + if (!session) { + return { + success: false, + error: "pre_consent_capture_not_available" + }; + } + + declineConsentCaptureSession(session); + + return { + success: true, + capture: null + }; +} + +async function handleProbePreConsentCaptureForTabMessage(message) { + const tabId = normalizeTabId(message?.payload?.tabId); + + if (tabId === null || !(await isConsentCaptureEnabled())) { + return { + success: true, + capture: null + }; + } + + if (hasCompletedConsentCaptureSessionForTab(tabId)) { + return { + success: true, + capture: null + }; + } + + const existingSession = getOpenConsentCaptureSessionForTab(tabId); + + if (existingSession) { + return { + success: true, + capture: buildPreConsentCaptureStatus(existingSession) + }; + } + + let tab = null; + + try { + tab = await browser.tabs.get(tabId); + } catch (error) { + return { + success: true, + capture: null + }; + } + + let probe = null; + + try { + probe = await browser.tabs.sendMessage(tabId, { + type: "probe_tcf_state" + }); + } catch (error) { + return { + success: true, + capture: null + }; + } + + const rawCapture = probe?.capture ?? null; + + if (!probe?.success || !isProbeOpenPreConsentCapture(rawCapture, probe.source)) { + return { + success: true, + capture: null + }; + } + + const sender = { + tab, + frameId: 0, + url: tab?.url ?? null + }; + + const session = startAttentionConsentCaptureSession(rawCapture, sender); + + return { + success: true, + capture: session ? buildPreConsentCaptureStatus(session) : null + }; +} + +function normalizeTabId(value) { + const tabId = Number(value); + + return Number.isInteger(tabId) ? tabId : null; +} + +function getOpenConsentCaptureSessionForTab(tabId) { + if (tabId === null) { + return null; + } + + for (const session of consentCaptureSessions.values()) { + if ( + session.tabId === tabId && + (session.status === "attention" || session.status === "recording") + ) { + return session; + } + } + + return null; +} + +function buildPreConsentCaptureStatus(session) { + return { + captureSessionId: session.captureSessionId ?? null, + status: session.status, + tabId: session.tabId, + frameId: session.frameId, + eventCount: session.bufferedPreConsentEvents?.length ?? 0, + firstEventStatus: + session.bufferedPreConsentEvents?.[0]?.rawCapture?.eventStatus ?? null, + page: session.page ?? null, + detectedAt: session.detectedAt ?? null, + updatedAt: session.updatedAt ?? null + }; +} + async function handleGetConsentEvidenceChainMessage(message) { const stateFingerprint = normalizeStateFingerprint( message?.payload?.stateFingerprint @@ -2142,19 +2332,94 @@ function buildConsentEventDiagnostics(rawCapture, extraDiagnostics) { function startConsentCaptureSessionTimeout(session) { session.timeoutId = setTimeout(() => { - cleanupIncompleteConsentCaptureSession(session, "timeout"); + timeoutConsentCaptureSession(session); }, CONSENT_CAPTURE_SESSION_TIMEOUT_MS); } +function isOpenPreConsentCapture(rawCapture) { + const eventStatus = rawCapture?.eventStatus ?? null; + + return eventStatus === "cmpuishown" || eventStatus === "tcloaded"; +} + +function isProbeOpenPreConsentCapture(rawCapture, source) { + if (source === "content_memory") { + return isOpenPreConsentCapture(rawCapture); + } + + const eventStatus = rawCapture?.eventStatus ?? null; + + if (eventStatus === "cmpuishown") { + return true; + } + + if (eventStatus !== "tcloaded") { + return false; + } + + return !rawCapture?.tcString && !rawCapture?.addtlConsent; +} + +function startAttentionConsentCaptureSession(rawCapture, sender) { + if (!isOpenPreConsentCapture(rawCapture)) { + return null; + } + + const sessionKey = getConsentCaptureSessionKey(sender); + + if (!sessionKey) { + return null; + } + + if (hasCompletedConsentCaptureSessionForTab(sender?.tab?.id ?? null)) { + return null; + } + + let session = consentCaptureSessions.get(sessionKey); + + if (session?.status === "aborted" || session?.status === "completed") { + return null; + } + + if (session?.status === "recording") { + return session; + } + + if (!session) { + session = { + key: sessionKey, + tabId: sender?.tab?.id ?? null, + frameId: sender?.frameId ?? null, + status: "attention", + captureSessionId: null, + bufferedPreConsentEvents: [], + timeoutId: null, + page: buildConsentEventPageContext(rawCapture, sender), + detectedAt: new Date().toISOString(), + updatedAt: null + }; + + consentCaptureSessions.set(sessionKey, session); + startConsentCaptureSessionTimeout(session); + } + + session.updatedAt = new Date().toISOString(); + session.bufferedPreConsentEvents.push({ + rawCapture, + sender + }); + updateConsentCaptureBadge(session); + + return session; +} + async function handlePreConsentTcfEvent(rawCapture, sender) { if (isEvidenceWriteSuspended()) { console.info("VG-Observe pre-consent capture skipped: maintenance mode"); return; } - const eventStatus = rawCapture?.eventStatus ?? null; - - if (eventStatus !== "cmpuishown" && eventStatus !== "tcloaded") { + if (!isOpenPreConsentCapture(rawCapture)) { return; } @@ -2164,13 +2429,17 @@ async function handlePreConsentTcfEvent(rawCapture, sender) { return; } - let session = consentCaptureSessions.get(sessionKey); - - if (session?.status === "declined" || session?.status === "completed") { + if (hasCompletedConsentCaptureSessionForTab(sender?.tab?.id ?? null)) { return; } - if (session?.status === "active") { + let session = consentCaptureSessions.get(sessionKey); + + if (session?.status === "aborted" || session?.status === "completed") { + return; + } + + if (session?.status === "recording") { await persistProviderAnnouncementEvent({ captureSessionId: session.captureSessionId, rawCapture, @@ -2179,84 +2448,34 @@ async function handlePreConsentTcfEvent(rawCapture, sender) { return; } - if (!session) { - session = { - key: sessionKey, - tabId: sender?.tab?.id ?? null, - frameId: sender?.frameId ?? null, - status: "prompting", - captureSessionId: null, - bufferedPreConsentEvents: [], - timeoutId: null - }; - - consentCaptureSessions.set(sessionKey, session); - startConsentCaptureSessionTimeout(session); - } - - session.bufferedPreConsentEvents.push({ - rawCapture, - sender - }); - - if (session.promptInProgress) { - return; - } - - session.promptInProgress = true; - - const accepted = await askUserForConsentCapture(sender); - - if (!consentCaptureSessions.has(sessionKey)) { - return; - } - - session.promptInProgress = false; - - if (!accepted) { - session.status = "declined"; - session.bufferedPreConsentEvents = []; - return; - } - - session.status = "active"; - session.captureSessionId = createCaptureSessionId(); - - const bufferedEvents = session.bufferedPreConsentEvents; - session.bufferedPreConsentEvents = []; - - for (const bufferedEvent of bufferedEvents) { - await persistProviderAnnouncementEvent({ - captureSessionId: session.captureSessionId, - rawCapture: bufferedEvent.rawCapture, - sender: bufferedEvent.sender - }); - } -} - -async function askUserForConsentCapture(sender) { - console.info( - "VG-Observe pre-consent provider announcement capture inactive: extension-owned consent prompt is not implemented", - { - tabId: sender?.tab?.id ?? null, - frameId: sender?.frameId ?? null - } - ); - - return false; + startAttentionConsentCaptureSession(rawCapture, sender); } function getActiveConsentCaptureSessionId(sender) { const sessionKey = getConsentCaptureSessionKey(sender); const session = sessionKey ? consentCaptureSessions.get(sessionKey) : null; - if (!session || session.status !== "active") { + if (!session || session.status !== "recording") { return null; } return session.captureSessionId ?? null; } +function hasCompletedConsentCaptureSessionForTab(tabId) { + if (tabId === null || tabId === undefined) { + return false; + } + + for (const session of consentCaptureSessions.values()) { + if (session.tabId === tabId && session.status === "completed") { + return true; + } + } + + return false; +} + function completeConsentCaptureSession(sender) { const sessionKey = getConsentCaptureSessionKey(sender); let session = sessionKey ? consentCaptureSessions.get(sessionKey) : null; @@ -2282,30 +2501,57 @@ function completeConsentCaptureSession(sender) { session.status = "completed"; session.completedAt = new Date().toISOString(); session.bufferedPreConsentEvents = []; - session.promptInProgress = false; if (session.timeoutId) { clearTimeout(session.timeoutId); session.timeoutId = null; } + + resetConsentCaptureBadge(session.tabId); } -function cleanupConsentCaptureSessionsForTab(tabId, reason) { +function cleanupConsentCaptureSessionsForTab(tabId, reason, nextUrl) { consentCaptureSessions.forEach((session) => { if (session.tabId === tabId) { + if ( + reason === "page_changed" && + session.status === "completed" && + isSameConsentCaptureUrl(session.page?.url, nextUrl) + ) { + return; + } + cleanupIncompleteConsentCaptureSession(session, reason); } }); } +function isSameConsentCaptureUrl(left, right) { + if (!left || !right) { + return false; + } + + return String(left) === String(right); +} + function cleanupIncompleteConsentCaptureSession(session, reason) { + const wasCompleted = session.status === "completed"; + if (session.timeoutId) { clearTimeout(session.timeoutId); } - consentCaptureSessions.delete(session.key); + if (!wasCompleted) { + session.status = "aborted"; + session.abortedAt = new Date().toISOString(); + session.abortReason = reason; + session.bufferedPreConsentEvents = []; + } - if (!session.captureSessionId || session.status === "completed") { + consentCaptureSessions.delete(session.key); + resetConsentCaptureBadge(session.tabId); + + if (!session.captureSessionId || wasCompleted) { return; } @@ -2317,6 +2563,126 @@ function cleanupIncompleteConsentCaptureSession(session, reason) { }); } +function declineConsentCaptureSession(session) { + if (session.timeoutId) { + clearTimeout(session.timeoutId); + session.timeoutId = null; + } + + session.status = "aborted"; + session.abortedAt = new Date().toISOString(); + session.abortReason = "user_declined"; + session.bufferedPreConsentEvents = []; + resetConsentCaptureBadge(session.tabId); +} + +function timeoutConsentCaptureSession(session) { + if (!consentCaptureSessions.has(session.key)) { + return; + } + + if (session.status === "completed") { + return; + } + + session.timeoutId = null; + session.status = "aborted"; + session.abortedAt = new Date().toISOString(); + session.abortReason = "timeout"; + session.bufferedPreConsentEvents = []; + resetConsentCaptureBadge(session.tabId); + + if (!session.captureSessionId) { + return; + } + + deleteProviderAnnouncementEventsForSession( + session.captureSessionId, + "timeout" + ).catch((error) => { + console.warn("VG-Observe provider announcement cleanup failed", error); + }); +} + +function getActionApi() { + return browser.action ?? browser.browserAction ?? null; +} + +function updateConsentCaptureBadge(session) { + const actionApi = getActionApi(); + const tabId = session?.tabId; + + if (!actionApi || tabId === null || tabId === undefined) { + return; + } + + const badgeText = session.status === "recording" ? "REC" : "!"; + const badgeColor = session.status === "recording" ? "#2563eb" : "#dc2626"; + + stopConsentCaptureBadgeBlink(tabId); + + let visible = true; + setActionBadgeBackgroundColor(actionApi, { tabId, color: badgeColor }); + setActionBadgeText(actionApi, { tabId, text: badgeText }); + + const timerId = setInterval(() => { + visible = !visible; + setActionBadgeText(actionApi, { + tabId, + text: visible ? badgeText : "" + }); + }, CONSENT_CAPTURE_BADGE_BLINK_MS); + + consentCaptureBadgeTimers.set(tabId, timerId); +} + +function resetConsentCaptureBadge(tabId) { + const actionApi = getActionApi(); + + stopConsentCaptureBadgeBlink(tabId); + + if (!actionApi || tabId === null || tabId === undefined) { + return; + } + + setActionBadgeText(actionApi, { tabId, text: "" }); +} + +function stopConsentCaptureBadgeBlink(tabId) { + const timerId = consentCaptureBadgeTimers.get(tabId); + + if (!timerId) { + return; + } + + clearInterval(timerId); + consentCaptureBadgeTimers.delete(tabId); +} + +function setActionBadgeText(actionApi, details) { + try { + const result = actionApi.setBadgeText(details); + + if (result?.catch) { + result.catch(() => {}); + } + } catch (error) { + // Badge updates are best-effort extension UI only. + } +} + +function setActionBadgeBackgroundColor(actionApi, details) { + try { + const result = actionApi.setBadgeBackgroundColor(details); + + if (result?.catch) { + result.catch(() => {}); + } + } catch (error) { + // Badge updates are best-effort extension UI only. + } +} + function getLatestGvlSnapshotByVendorListVersion(db) { return new Promise((resolve, reject) => { const tx = db.transaction([VENDORGET_STORE_NAMES.gvlSnapshots], "readonly"); diff --git a/src/content/tcf-listener.js b/src/content/tcf-listener.js index bbba45f..07d7b93 100644 --- a/src/content/tcf-listener.js +++ b/src/content/tcf-listener.js @@ -1,9 +1,87 @@ console.log("VendorGet content listener loaded:", window.location.href); +let latestObservedPreConsentCapture = null; +let latestObservedConsentCompleted = false; + +browser.runtime.onMessage.addListener((message) => { + if (message?.type !== "probe_tcf_state") { + return undefined; + } + + return probeTcfState(); +}); + +function probeTcfState() { + if (latestObservedConsentCompleted) { + return Promise.resolve({ + success: true, + capture: null, + source: "content_completed" + }); + } + + if (latestObservedPreConsentCapture && !latestObservedConsentCompleted) { + return Promise.resolve({ + success: true, + capture: latestObservedPreConsentCapture, + source: "content_memory" + }); + } + + return new Promise((resolve) => { + const requestId = [ + "tcf-probe", + Date.now().toString(36), + Math.random().toString(36).slice(2) + ].join("-"); + const timeoutId = setTimeout(() => { + window.removeEventListener("VendorGetTcfProbeResponse", handleResponse); + resolve({ + success: false, + capture: null, + error: "tcf_probe_timeout" + }); + }, 1000); + + function handleResponse(event) { + if (event?.detail?.requestId !== requestId) { + return; + } + + clearTimeout(timeoutId); + window.removeEventListener("VendorGetTcfProbeResponse", handleResponse); + resolve({ + success: event.detail.success === true, + capture: event.detail.capture ?? null, + source: "tcf_get_tc_data" + }); + } + + window.addEventListener("VendorGetTcfProbeResponse", handleResponse); + window.dispatchEvent(new CustomEvent("VendorGetTcfProbeRequest", { + detail: { + requestId + } + })); + }); +} + window.addEventListener("VendorGetFromPage", async (event) => { console.log("VendorGet message from page:", event.detail); + if ( + event.detail?.eventName === "tcf_pre_consent_event" && + !latestObservedConsentCompleted + ) { + latestObservedPreConsentCapture = event.detail.payload ?? null; + } + + if (event.detail?.eventName === "consent_capture") { + latestObservedConsentCompleted = true; + latestObservedPreConsentCapture = null; + } + await browser.runtime.sendMessage({ type: "vendorget_capture", payload: event.detail diff --git a/src/injected/tcf-bridge.js b/src/injected/tcf-bridge.js index b0d2cf0..00bca5c 100644 --- a/src/injected/tcf-bridge.js +++ b/src/injected/tcf-bridge.js @@ -115,6 +115,20 @@ }; } + window.addEventListener("VendorGetTcfProbeRequest", function (event) { + const requestId = event?.detail?.requestId ?? null; + + window.__tcfapi("getTCData", 2, function (tcData, success) { + window.dispatchEvent(new CustomEvent("VendorGetTcfProbeResponse", { + detail: { + requestId: requestId, + success: success, + capture: success && tcData ? buildTcfEventCapture(tcData) : null + } + })); + }); + }); + window.__tcfapi("ping", 2, function (pingData, pingSuccess) { console.log("VendorGet __tcfapi ping:", { diff --git a/src/popup/popup.css b/src/popup/popup.css index ba84238..a0bb29d 100644 --- a/src/popup/popup.css +++ b/src/popup/popup.css @@ -123,6 +123,42 @@ h1 { display: none; } +.pre-consent-capture { + display: grid; + gap: 8px; + margin-bottom: 14px; + padding: 10px; + border: 1px solid #6366f1; + border-radius: 6px; + background: #172033; +} + +.pre-consent-capture[hidden] { + display: none; +} + +.pre-consent-capture h2 { + margin: 0; + font-size: 13px; +} + +.pre-consent-capture p { + margin: 0; + font-size: 12px; + line-height: 1.35; + color: #cbd5e1; +} + +.pre-consent-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.pre-consent-actions button[hidden] { + display: none; +} + .evidence-counts { display: grid; gap: 6px; diff --git a/src/popup/popup.html b/src/popup/popup.html index 7b53c34..cd95c98 100644 --- a/src/popup/popup.html +++ b/src/popup/popup.html @@ -29,6 +29,31 @@ + +

Workspace-Kurzstatus