From 3df9f870a46ae773e066d8f63bc3e17cc6feac67 Mon Sep 17 00:00:00 2001 From: tdgao Date: Mon, 20 Jul 2026 20:53:10 -0700 Subject: [PATCH] fix: US variant of consent popup --- apps/app/src/api/ads-init.js | 487 ++++++++++++++++++++++++++++++----- apps/app/src/api/ads.rs | 3 + 2 files changed, 428 insertions(+), 62 deletions(-) diff --git a/apps/app/src/api/ads-init.js b/apps/app/src/api/ads-init.js index 40480eeff8..109fcbe924 100644 --- a/apps/app/src/api/ads-init.js +++ b/apps/app/src/api/ads-init.js @@ -6,14 +6,19 @@ document.addEventListener( window.top.postMessage({ modrinthAdClick: true }, MODRINTH_ORIGIN) let target = e.target - if (target?.closest?.('.qc-cmp2-close-icon')) { - if (modrinthAdsConsentReprompt) { + const uspCloseIcon = target?.closest?.('.qc-usp-close-icon') + if (target?.closest?.('.qc-cmp2-close-icon') || uspCloseIcon) { + if (isAdsConsentReprompt()) { setTimeout(finishAdsConsentReprompt) } else if (document.documentElement.classList.contains('modrinth-ads-consent-preferences')) { setTimeout(() => void restoreAdsConsentNotification()) } } + if (target?.closest?.('#qc-cmp2-usp .qc-usp-ui-form-content button[mode="primary"]')) { + beginUspConsentCommit() + } + while (target != null) { if (target.matches('a')) { e.preventDefault() @@ -32,14 +37,54 @@ window.open = (url, target, features) => { window.top.postMessage({ modrinthOpenUrl: url }, MODRINTH_ORIGIN) } -let modrinthAdsConsentOverlayShown = false +const modrinthAdsConsentState = { + phase: 'idle', + variant: null, + commitTimeout: null, +} let modrinthTcfListenerInstalled = false let modrinthTcfListenerAttempts = 0 -let modrinthAdsConsentReprompt = false -let modrinthAdsConsentRepromptManaging = false +let modrinthGppListenerInstalled = false +let modrinthGppListenerAttempts = 0 let modrinthAdsConsentActionRequestId = 0 const modrinthAdsConsentActionResolvers = new Map() +function transitionAdsConsent(event) { + const phase = modrinthAdsConsentState.phase + + if (event === 'prompt-detected') { + if (phase === 'idle') modrinthAdsConsentState.phase = 'initial' + } else if (event === 'reprompt-started') { + modrinthAdsConsentState.phase = 'reprompt' + } else if (event === 'commit-started') { + modrinthAdsConsentState.phase = isAdsConsentReprompt() + ? 'reprompt-committing' + : 'initial-committing' + } else if (event === 'commit-timed-out') { + if (phase === 'reprompt-committing') { + modrinthAdsConsentState.phase = 'reprompt' + } else if (phase === 'initial-committing') { + modrinthAdsConsentState.phase = 'initial' + } + } else if (event === 'completed') { + modrinthAdsConsentState.phase = 'complete' + } +} + +function isAdsConsentReprompt() { + return ( + modrinthAdsConsentState.phase === 'reprompt' || + modrinthAdsConsentState.phase === 'reprompt-committing' + ) +} + +function isUspConsentCommitPending() { + return ( + modrinthAdsConsentState.phase === 'initial-committing' || + modrinthAdsConsentState.phase === 'reprompt-committing' + ) +} + function installAdsRailStyle() { if (document.getElementById('modrinth-ads-rail-style')) { return @@ -65,6 +110,10 @@ function installAdsConsentOverlayStyle() { html.modrinth-ads-consent-preferences:not(.modrinth-ads-consent-preferences-visible) #qc-cmp2-container { display: none !important; } + + #qc-cmp2-usp .qc-usp-close-icon { + display: none !important; + } ` document.documentElement.appendChild(style) } @@ -124,9 +173,36 @@ function isDirectChildFrame(source) { ) } -function findAdsConsentButton(action) { - const container = document.getElementById('qc-cmp2-container') - if (!container) return null +function displayUspConsentUi() { + if (typeof window.__uspapi !== 'function') return false + + try { + window.__uspapi('displayUspUi', 1, () => {}) + return true + } catch { + return false + } +} + +function detectAdsConsentVariant() { + let variant = null + + if (document.getElementById('qc-cmp2-usp')) { + variant = 'usp' + } else if (document.getElementById('qc-cmp2-ui')) { + variant = 'tcf' + } + + if (variant) { + modrinthAdsConsentState.variant = variant + } + + return variant +} + +function findTcfConsentButton(action) { + const dialog = document.getElementById('qc-cmp2-ui') + if (!dialog) return null const buttonIds = { accept: 'accept-btn', @@ -136,29 +212,142 @@ function findAdsConsentButton(action) { const buttonId = buttonIds[action] if (!buttonId) return null - const button = container.querySelector(`#${buttonId}`) + const button = dialog.querySelector(`#${buttonId}`) return button && !button.disabled ? button : null } -function clickAdsConsentButtonWhenReady(action, timeoutMs, onButtonFound) { - const deadline = Date.now() + timeoutMs +function getUspConsentControls() { + const dialog = document.getElementById('qc-cmp2-usp') + if (!dialog) return null + const toggles = Array.from( + dialog.querySelectorAll('.qc-usp-container button.qc-cmp2-toggle[role="switch"]'), + ) + const confirmButton = dialog.querySelector('.qc-usp-ui-form-content button[mode="primary"]') + + if ( + toggles.length === 0 || + !confirmButton || + confirmButton.disabled || + toggles.some( + (toggle) => + toggle.disabled || !['true', 'false'].includes(toggle.getAttribute('aria-checked')), + ) + ) { + return null + } + + return { toggles, confirmButton } +} + +function waitForUspToggleState(index, checked, expectedCount, deadline) { return new Promise((resolve) => { - function tryClick() { - const button = findAdsConsentButton(action) - if (button) { - // CMP navigation can replace this document during the click, so acknowledge it first. - onButtonFound?.() - resolve(true) - button.click() + function checkState() { + const controls = getUspConsentControls() + if ( + controls && + controls.toggles.length === expectedCount && + controls.toggles[index]?.getAttribute('aria-checked') === String(checked) + ) { + resolve(controls) } else if (Date.now() >= deadline) { - resolve(false) + resolve(null) } else { - setTimeout(tryClick, 50) + setTimeout(checkState, 50) } } - tryClick() + checkState() + }) +} + +async function setUspToggleStates(checked, controls, timeoutMs) { + const expectedCount = controls.toggles.length + const deadline = Date.now() + timeoutMs + + for (let index = 0; index < expectedCount; index += 1) { + controls = getUspConsentControls() + if (!controls || controls.toggles.length !== expectedCount) return null + + const toggle = controls.toggles[index] + if (toggle.getAttribute('aria-checked') !== String(checked)) { + toggle.click() + controls = await waitForUspToggleState(index, checked, expectedCount, deadline) + if (!controls) return null + } + } + + return controls +} + +async function performAdsConsentActionInDocument(action, onHandled) { + const variant = detectAdsConsentVariant() + const unknownDialog = document.querySelector('#qc-cmp2-container [role="dialog"]') + + if (action === 'show') { + if (variant || unknownDialog) { + onHandled?.() + return 'handled' + } + return 'not-ready' + } + + if (variant === 'usp') { + if (action === 'manage') { + onHandled?.() + return 'handled' + } + + if (!['accept', 'reject'].includes(action)) return 'failed' + + const controls = getUspConsentControls() + if (!controls) return 'not-ready' + + const shouldOptOut = action === 'reject' + const settledControls = await setUspToggleStates(shouldOptOut, controls, 2000) + if (!settledControls) return 'failed' + + // CMP navigation can replace this document during the click, so acknowledge it first. + onHandled?.() + beginUspConsentCommit() + settledControls.confirmButton.click() + return 'handled' + } + + if (variant === 'tcf') { + const button = findTcfConsentButton(action) + if (!button) return 'not-ready' + + // CMP navigation can replace this document during the click, so acknowledge it first. + onHandled?.() + button.click() + return 'handled' + } + + if (action === 'manage' && unknownDialog) { + onHandled?.() + return 'handled' + } + + return 'not-ready' +} + +function performAdsConsentActionWhenReady(action, timeoutMs, onHandled) { + const deadline = Date.now() + timeoutMs + + return new Promise((resolve) => { + async function tryAction() { + const result = await performAdsConsentActionInDocument(action, onHandled) + if (result === 'handled') { + resolve(true) + } else if (result === 'failed' || Date.now() >= deadline) { + resolve(false) + } else { + setTimeout(tryAction, 50) + } + } + + void tryAction() }) } @@ -178,9 +367,7 @@ function performAdsConsentActionAcrossFrames(action, timeoutMs) { const timeout = setTimeout(() => settle(false), timeoutMs) modrinthAdsConsentActionResolvers.set(requestId, () => settle(true)) sendAdsConsentCommandToChildFrames({ type: 'perform', action, requestId, timeoutMs }) - clickAdsConsentButtonWhenReady(action, timeoutMs, () => settle(true)).then((clicked) => { - if (!clicked) settle(false) - }) + void performAdsConsentActionWhenReady(action, timeoutMs, () => settle(true)) }) } @@ -198,18 +385,9 @@ async function restoreAdsConsentNotification() { } } -function finishAdsConsentReprompt() { - modrinthAdsConsentReprompt = false - modrinthAdsConsentRepromptManaging = false - modrinthAdsConsentOverlayShown = false - document.documentElement.classList.remove('modrinth-ads-consent-overlay') - concealAdsConsentPreferences() - sendAdsConsentCommandToChildFrames({ type: 'conceal' }) - invokeAdsConsentOverlayCommand(false) -} - -async function openAdsConsentPreferences() { +async function showNativeAdsConsentUi() { prepareAdsConsentPreferences() + await waitForAdsConsentLayout() sendAdsConsentCommandToChildFrames({ type: 'prepare' }) await expandAdsConsentWebview() await waitForAdsConsentLayout() @@ -219,10 +397,28 @@ async function openAdsConsentPreferences() { window.dispatchEvent(new Event('resize')) sendAdsConsentCommandToChildFrames({ type: 'resize' }) - const clicked = await performAdsConsentActionAcrossFrames('manage', 2500) - if (!clicked) { + const shown = await performAdsConsentActionAcrossFrames('show', 2500) + if (!shown) { await restoreAdsConsentNotification() } + + return shown +} + +function finishAdsConsentReprompt() { + transitionAdsConsent('completed') + clearTimeout(modrinthAdsConsentState.commitTimeout) + modrinthAdsConsentState.commitTimeout = null + document.documentElement.classList.remove('modrinth-ads-consent-overlay') + concealAdsConsentPreferences() + sendAdsConsentCommandToChildFrames({ type: 'conceal' }) + invokeAdsConsentOverlayCommand(false) +} + +async function openAdsConsentPreferences() { + if (!(await showNativeAdsConsentUi())) return + + await performAdsConsentActionAcrossFrames('manage', 2500) } async function performAdsConsentAction(action) { @@ -237,16 +433,96 @@ async function performAdsConsentAction(action) { return } - await performAdsConsentActionAcrossFrames(action, 1000) + const handled = await performAdsConsentActionAcrossFrames(action, 2500) + if (!handled) { + try { + await showNativeAdsConsentUi() + } catch { + await restoreAdsConsentNotification() + } + } } window.modrinthAdsConsentAction = (action) => { void performAdsConsentAction(action) } +function isUspConsentApplicable() { + if (detectAdsConsentVariant() === 'usp') return Promise.resolve(true) + if (typeof window.__uspapi !== 'function') return Promise.resolve(false) + + return new Promise((resolve) => { + let settled = false + const settle = (applicable) => { + if (settled) return + settled = true + clearTimeout(timeout) + resolve(applicable) + } + const timeout = setTimeout(() => settle(false), 500) + + try { + window.__uspapi('uspPing', 1, (data, success) => { + if (!success || !data) { + settle(false) + return + } + + const modes = Array.isArray(data.mode) ? data.mode : [data.mode] + const jurisdictions = Array.isArray(data.jurisdiction) + ? data.jurisdiction + : [data.jurisdiction] + const location = String(data.location ?? '').toUpperCase() + const hasUspMode = modes.some((mode) => + String(mode ?? '') + .toUpperCase() + .includes('USP'), + ) + const locationApplies = + !location || + jurisdictions.some((jurisdiction) => + String(jurisdiction ?? '') + .toUpperCase() + .includes(location), + ) + + settle(hasUspMode && locationApplies) + }) + } catch { + settle(false) + } + }) +} + +async function displayAdsConsentReprompt() { + if ( + (modrinthAdsConsentState.variant === 'usp' || (await isUspConsentApplicable())) && + typeof window.__uspapi === 'function' + ) { + modrinthAdsConsentState.variant = 'usp' + return displayUspConsentUi() + } + + if (typeof window.__tcfapi === 'function') { + modrinthAdsConsentState.variant = 'tcf' + window.__tcfapi('displayConsentUi', 2, () => {}) + return true + } + + return false +} + window.modrinthAdsReopenConsentPreferences = async () => { - modrinthAdsConsentReprompt = true - modrinthAdsConsentRepromptManaging = false + if (document.documentElement.classList.contains('modrinth-ads-consent-overlay')) { + try { + await openAdsConsentPreferences() + } catch { + await restoreAdsConsentNotification() + } + return + } + + transitionAdsConsent('reprompt-started') prepareAdsConsentPreferences() sendAdsConsentCommandToChildFrames({ type: 'prepare' }) @@ -258,11 +534,17 @@ window.modrinthAdsReopenConsentPreferences = async () => { window.dispatchEvent(new Event('resize')) sendAdsConsentCommandToChildFrames({ type: 'resize' }) - if (typeof window.__tcfapi === 'function') { - window.__tcfapi('displayConsentUi', 2, () => {}) - } else { + if (!(await displayAdsConsentReprompt())) { finishAdsConsentReprompt() + return } + + if (!(await performAdsConsentActionAcrossFrames('show', 2500))) { + finishAdsConsentReprompt() + return + } + + await performAdsConsentActionAcrossFrames('manage', 2500) } catch { finishAdsConsentReprompt() } @@ -303,16 +585,15 @@ window.addEventListener('message', (event) => { typeof command.timeoutMs === 'number' ) { sendAdsConsentCommandToChildFrames(command) - clickAdsConsentButtonWhenReady(command.action, command.timeoutMs, () => { + performAdsConsentActionWhenReady(command.action, command.timeoutMs, () => { window.parent.postMessage({ modrinthAdsConsentResult: command.requestId }, '*') }) } }) function setAdsConsentOverlay(shown) { - if (modrinthAdsConsentOverlayShown === shown) return + if (document.documentElement.classList.contains('modrinth-ads-consent-overlay') === shown) return - modrinthAdsConsentOverlayShown = shown installAdsConsentOverlayStyle() document.documentElement.classList.toggle('modrinth-ads-consent-overlay', shown) if (!shown) { @@ -329,36 +610,111 @@ function setAdsConsentOverlay(shown) { if (window.top === window) { window.addEventListener('message', (event) => { - if ( - event.origin === MODRINTH_ORIGIN && - typeof event.data?.modrinthAdsConsentOverlay === 'boolean' - ) { + if (event.origin !== MODRINTH_ORIGIN) return + + if (typeof event.data?.modrinthAdsConsentOverlay === 'boolean') { setAdsConsentOverlay(event.data.modrinthAdsConsentOverlay) } }) } +function finishUspConsentCommit() { + if (!isUspConsentCommitPending()) return + + const wasReprompt = isAdsConsentReprompt() + transitionAdsConsent('completed') + clearTimeout(modrinthAdsConsentState.commitTimeout) + modrinthAdsConsentState.commitTimeout = null + + if (wasReprompt) { + finishAdsConsentReprompt() + } else { + setAdsConsentOverlay(false) + } +} + +function beginUspConsentCommit() { + if (!document.getElementById('qc-cmp2-usp')) return + + modrinthAdsConsentState.variant = 'usp' + transitionAdsConsent('commit-started') + clearTimeout(modrinthAdsConsentState.commitTimeout) + + const deadline = Date.now() + 2500 + function checkForDialogClosure() { + if (!isUspConsentCommitPending()) return + + if (!document.getElementById('qc-cmp2-usp')) { + finishUspConsentCommit() + } else if (Date.now() >= deadline) { + transitionAdsConsent('commit-timed-out') + modrinthAdsConsentState.commitTimeout = null + } else { + modrinthAdsConsentState.commitTimeout = setTimeout(checkForDialogClosure, 50) + } + } + + modrinthAdsConsentState.commitTimeout = setTimeout(checkForDialogClosure, 50) +} + +function syncAdsConsentUi() { + const variant = detectAdsConsentVariant() + + if (variant && !isAdsConsentReprompt() && modrinthAdsConsentState.phase !== 'complete') { + transitionAdsConsent('prompt-detected') + setAdsConsentOverlay(true) + } + + if (isUspConsentCommitPending() && !document.getElementById('qc-cmp2-usp')) { + finishUspConsentCommit() + } +} + +function handleGppConsentEvent(gppData, success) { + if ( + success && + gppData?.eventName === 'sectionChange' && + modrinthAdsConsentState.variant === 'usp' && + isUspConsentCommitPending() + ) { + finishUspConsentCommit() + } +} + +function installGppConsentListener() { + if (modrinthGppListenerInstalled) return + + if (typeof window.__gpp === 'function') { + modrinthGppListenerInstalled = true + window.__gpp('addEventListener', handleGppConsentEvent) + return + } + + if (modrinthGppListenerAttempts < 60) { + modrinthGppListenerAttempts += 1 + setTimeout(installGppConsentListener, 500) + } +} + function handleTcfConsentEvent(tcData, success) { if (!success || !tcData) return + detectAdsConsentVariant() if (tcData.eventStatus === 'cmpuishown') { - if (modrinthAdsConsentReprompt) { - if (!modrinthAdsConsentRepromptManaging) { - modrinthAdsConsentRepromptManaging = true - window.dispatchEvent(new Event('resize')) - sendAdsConsentCommandToChildFrames({ type: 'resize' }) - void performAdsConsentActionAcrossFrames('manage', 2500) - } - return - } + if (isAdsConsentReprompt()) return + transitionAdsConsent('prompt-detected') setAdsConsentOverlay(true) - } else if (tcData.eventStatus === 'useractioncomplete') { - if (modrinthAdsConsentReprompt) { + } else if ( + tcData.eventStatus === 'useractioncomplete' && + modrinthAdsConsentState.variant === 'tcf' + ) { + if (isAdsConsentReprompt()) { finishAdsConsentReprompt() return } + transitionAdsConsent('completed') setAdsConsentOverlay(false) } } @@ -459,10 +815,17 @@ document.addEventListener('DOMContentLoaded', () => { installAdsConsentOverlayStyle() muteVideos() muteAudioContext() + syncAdsConsentUi() installTcfConsentListener() + installGppConsentListener() - const observer = new MutationObserver(muteVideos) + const observer = new MutationObserver(() => { + muteVideos() + syncAdsConsentUi() + }) observer.observe(document.body, { childList: true, subtree: true }) }) +syncAdsConsentUi() installTcfConsentListener() +installGppConsentListener() diff --git a/apps/app/src/api/ads.rs b/apps/app/src/api/ads.rs index 48ad99eab3..3a62ffdd1a 100644 --- a/apps/app/src/api/ads.rs +++ b/apps/app/src/api/ads.rs @@ -483,6 +483,7 @@ pub async fn init_ads_window( let webview = window.add_child( tauri::webview::WebviewBuilder::new("ads-window", webview_url) + .incognito(true) // REMOVE WHEN DONE!! .initialization_script_for_all_frames(include_str!( "ads-init.js" )) @@ -582,6 +583,8 @@ pub async fn init_ads_window( } })?; + webview.open_devtools(); + Some(webview) } else { None