diff --git a/apps/app/src/api/ads-consent/cmp.js b/apps/app/src/api/ads-consent/cmp.js index ca3fbcc043..670f4156f3 100644 --- a/apps/app/src/api/ads-consent/cmp.js +++ b/apps/app/src/api/ads-consent/cmp.js @@ -190,9 +190,10 @@ async function setUspToggleStates(checked, controls, timeoutMs) { /** * @param {ConsentAction} action * @param {AdsConsentVariant | null} variant + * @param {(() => void) | undefined} onSubmit * @returns {Promise} */ -async function performDocumentConsentAction(action, variant) { +async function performDocumentConsentAction(action, variant, onSubmit) { if (variant === 'usp') { if (action === 'manage') { return 'handled' @@ -204,6 +205,7 @@ async function performDocumentConsentAction(action, variant) { const settledControls = await setUspToggleStates(action === 'reject', controls, 2_000) if (!settledControls) return 'failed' + onSubmit?.() settledControls.confirmButton.click() return 'handled' } @@ -212,6 +214,7 @@ async function performDocumentConsentAction(action, variant) { const button = findTcfConsentButton(action) if (!button) return 'not-ready' + onSubmit?.() button.click() return 'handled' } diff --git a/apps/app/src/api/ads-consent/controller.js b/apps/app/src/api/ads-consent/controller.js index db4a2ca431..31b997e611 100644 --- a/apps/app/src/api/ads-consent/controller.js +++ b/apps/app/src/api/ads-consent/controller.js @@ -1,21 +1,33 @@ const ACTION_TIMEOUT = 10_000 const LAYOUT_DELAY = 100 +const SUBMISSION_TIMEOUT = 10_000 class AdsConsentController { constructor() { /** @type {AdsConsentState} */ this.state = new AdsConsentState() + + /** @type {AdsConsentPhase | null} */ + this.preSubmissionPhase = null + + /** @type {ReturnType | null} */ + this.submissionTimeout = null } /** @returns {void} */ syncConsentPopup() { + // The CMP root persists while its internal views change, so only its removal finishes + // a normal active flow. Submissions wait for the CMP event or the timeout fallback so + // the consent has time to persist before the ads webview is refreshed. const cmpMain = document.getElementById('qc-cmp2-main') - // no cmp container, remove our popup if (!cmpMain) { if (this.state.phase === 'idle') { document.documentElement.classList.remove('modrinth-ads-consent-overlay') - } else if (this.state.phase !== 'complete') { + } else if ( + this.state.phase !== 'submitting-consent' && + this.state.phase !== 'finishing' + ) { this.finishConsentFlow() } return @@ -23,12 +35,11 @@ class AdsConsentController { if (this.state.phase !== 'idle') return - // has cmp container, add our popup document.documentElement.classList.add('modrinth-ads-consent-overlay') const variant = this.detectVariant() if (!areConsentControlsPresent(variant)) return - this.state.setState('initial') + this.state.setState('showing-popup') this.setPopupMode('custom') } @@ -56,13 +67,93 @@ class AdsConsentController { } try { - const result = await performDocumentConsentAction(action, variant) - if (result !== 'handled') this.showNativeCmpFallback() + const result = await performDocumentConsentAction(action, variant, () => + this.beginConsentSubmission(), + ) + if (result !== 'handled') { + this.cancelConsentSubmission() + this.showNativeCmpFallback() + } } catch { + this.cancelConsentSubmission() this.showNativeCmpFallback() } } + /** @returns {void} */ + beginConsentSubmission() { + if ( + !['showing-popup', 'showing-preferences', 'showing-reopened-preferences'].includes( + this.state.phase, + ) + ) { + return + } + + this.preSubmissionPhase = this.state.phase + this.state.setState('submitting-consent') + clearTimeout(this.submissionTimeout ?? undefined) + this.submissionTimeout = setTimeout(() => { + const preSubmissionPhase = this.preSubmissionPhase + this.submissionTimeout = null + const dialogId = this.state.variant === 'usp' ? 'qc-cmp2-usp' : 'qc-cmp2-ui' + + if ( + this.state.phase === 'submitting-consent' && + !document.getElementById(dialogId) + ) { + this.finishConsentFlow() + } else if (this.state.phase === 'submitting-consent' && preSubmissionPhase) { + this.state.setState(preSubmissionPhase) + this.preSubmissionPhase = null + } + }, SUBMISSION_TIMEOUT) + } + + /** @returns {void} */ + cancelConsentSubmission() { + clearTimeout(this.submissionTimeout ?? undefined) + this.submissionTimeout = null + + if (this.state.phase === 'submitting-consent' && this.preSubmissionPhase) { + this.state.setState(this.preSubmissionPhase) + } + this.preSubmissionPhase = null + } + + /** + * @param {{ eventStatus?: string } | null | undefined} tcData + * @param {boolean} success + * @returns {void} + */ + handleTcfConsentEvent(tcData, success) { + if ( + success && + tcData?.eventStatus === 'useractioncomplete' && + this.state.variant === 'tcf' && + this.state.phase !== 'idle' && + this.state.phase !== 'finishing' + ) { + this.finishConsentFlow() + } + } + + /** + * @param {{ eventName?: string } | null | undefined} gppData + * @param {boolean} success + * @returns {void} + */ + handleGppConsentEvent(gppData, success) { + if ( + success && + gppData?.eventName === 'sectionChange' && + this.state.variant === 'usp' && + this.state.phase === 'submitting-consent' + ) { + this.finishConsentFlow() + } + } + /** @returns {Promise} */ async reopenPreferences() { if (document.documentElement.classList.contains('modrinth-ads-consent-overlay')) { @@ -74,7 +165,7 @@ class AdsConsentController { return } - this.state.setState('reopened') + this.state.setState('showing-reopened-preferences') this.preparePreferences() try { @@ -158,6 +249,7 @@ class AdsConsentController { /** @returns {Promise} */ async openPreferences() { + this.state.setState('showing-preferences') await this.showExpandedUi() if (!(await this.openConsentManagerWhenReady(ACTION_TIMEOUT))) { @@ -207,11 +299,14 @@ class AdsConsentController { /** @returns {void} */ finishConsentFlow() { - if (this.state.phase === 'idle' || this.state.phase === 'complete') { + if (this.state.phase === 'idle' || this.state.phase === 'finishing') { return } - this.state.setState('complete') + clearTimeout(this.submissionTimeout ?? undefined) + this.submissionTimeout = null + this.preSubmissionPhase = null + this.state.setState('finishing') this.setPopupMode('hidden') } } diff --git a/apps/app/src/api/ads-consent/index.js b/apps/app/src/api/ads-consent/index.js index 38ea502407..a96d5b2cd7 100644 --- a/apps/app/src/api/ads-consent/index.js +++ b/apps/app/src/api/ads-consent/index.js @@ -1,13 +1,56 @@ const controller = new AdsConsentController() +const CONSENT_LISTENER_RETRY_INTERVAL = 250 +const CONSENT_LISTENER_MAX_ATTEMPTS = 60 + +let tcfListenerInstalled = false +let gppListenerInstalled = false +let consentListenerInstallAttempts = 0 +/** @type {ReturnType | null} */ +let consentListenerRetry = null function isTopFrame() { return window.top === window } +function installConsentListeners() { + if (!tcfListenerInstalled && typeof window.__tcfapi === 'function') { + try { + window.__tcfapi('addEventListener', 2, (tcData, success) => + controller.handleTcfConsentEvent(tcData, success), + ) + tcfListenerInstalled = true + } catch {} + } + + if (!gppListenerInstalled && typeof window.__gpp === 'function') { + try { + window.__gpp('addEventListener', (gppData, success) => + controller.handleGppConsentEvent(gppData, success), + ) + gppListenerInstalled = true + } catch {} + } + + if ( + (tcfListenerInstalled && gppListenerInstalled) || + consentListenerInstallAttempts >= CONSENT_LISTENER_MAX_ATTEMPTS || + consentListenerRetry + ) { + return + } + + consentListenerInstallAttempts += 1 + consentListenerRetry = setTimeout(() => { + consentListenerRetry = null + installConsentListeners() + }, CONSENT_LISTENER_RETRY_INTERVAL) +} + function initializeTopFrame() { if (!isTopFrame()) return installConsentStyles() + installConsentListeners() controller.syncConsentPopup() } @@ -17,6 +60,10 @@ document.addEventListener( notifyAdClick() const target = event.target instanceof Element ? event.target : null + if (target?.closest('#qc-cmp2-usp .qc-usp-ui-form-content button[mode="primary"]')) { + controller.beginConsentSubmission() + } + const link = target?.closest('a') if (link?.href) { event.preventDefault() diff --git a/apps/app/src/api/ads-consent/state.js b/apps/app/src/api/ads-consent/state.js index 90b0b58c60..70445528f4 100644 --- a/apps/app/src/api/ads-consent/state.js +++ b/apps/app/src/api/ads-consent/state.js @@ -1,5 +1,5 @@ /** - * @typedef {'idle' | 'initial' | 'reopened' | 'complete'} AdsConsentPhase + * @typedef {'idle' | 'showing-popup' | 'showing-preferences' | 'showing-reopened-preferences' | 'submitting-consent' | 'finishing'} AdsConsentPhase * @typedef {'usp' | 'tcf'} AdsConsentVariant */ diff --git a/apps/app/src/api/ads-consent/styles.js b/apps/app/src/api/ads-consent/styles.js index 31f4602e02..6dab777bb1 100644 --- a/apps/app/src/api/ads-consent/styles.js +++ b/apps/app/src/api/ads-consent/styles.js @@ -14,6 +14,7 @@ const THEME_STYLE = ` } #qc-cmp2-usp { + outline: none !important; background: var(--modrinth-usp-bg) !important; border: 1px solid var(--modrinth-usp-divider) !important; border-radius: 1rem !important; diff --git a/apps/app/src/api/ads.rs b/apps/app/src/api/ads.rs index 3f02331d07..02d90ccf66 100644 --- a/apps/app/src/api/ads.rs +++ b/apps/app/src/api/ads.rs @@ -503,6 +503,7 @@ pub async fn init_ads_window( // Aditude has separately informed SSPs and IVT vendors that this traffic // originates from a desktop app. .user_agent(ADS_USER_AGENT) + .incognito(true) .zoom_hotkeys_enabled(false) .transparent(true) .on_new_window(|_, _| { @@ -594,6 +595,8 @@ pub async fn init_ads_window( } })?; + webview.open_devtools(); + Some(webview) } else { None @@ -889,11 +892,9 @@ pub async fn get_ads_consent_required( let state = app.state::>(); let state = state.read().await; - Ok( - state.shown - && state.consent_required - && state.consent_notification_enabled, - ) + Ok(state.shown + && state.consent_required + && state.consent_notification_enabled) } #[tauri::command]