mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 13:16:38 +00:00
fix: handling failures/invalid popup edge cases to show normal gdpr popup fallback (#6857)
* fix: remove drilling actions down iframes * refactor: split up custom popup logic, trim it down a lot * fix: bring back TCF useractioncomplete and USP/GPP sectionChange event listeners, and only using dom observer as the fallback * remove open devtools * format * harden CPM fallbacks for unknown layouts * consolidate timeouts * refactor: rename functions to make sense * fix: sidebar closed on launch does not properly set state
This commit is contained in:
@@ -93,10 +93,10 @@ import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
|
||||
import { config } from '@/config'
|
||||
import {
|
||||
ads_consent_listener,
|
||||
get_ads_consent_required,
|
||||
hide_ads_window,
|
||||
init_ads_window,
|
||||
perform_ads_consent_action,
|
||||
should_show_ads_consent_popup,
|
||||
show_ads_window,
|
||||
} from '@/helpers/ads.js'
|
||||
import { debugAnalytics, initAnalytics, trackEvent } from '@/helpers/analytics'
|
||||
@@ -339,7 +339,7 @@ onMounted(async () => {
|
||||
await useCheckDisableMouseover()
|
||||
try {
|
||||
unlistenAdsConsent = await ads_consent_listener(handleAdsConsentRequired)
|
||||
handleAdsConsentRequired(await get_ads_consent_required())
|
||||
handleAdsConsentRequired(await should_show_ads_consent_popup())
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@ export async function hide_ads_window(reset) {
|
||||
return await invoke('plugin:ads|hide_ads_window', { reset })
|
||||
}
|
||||
|
||||
export async function get_ads_consent_required() {
|
||||
return await invoke('plugin:ads|get_ads_consent_required')
|
||||
export async function should_show_ads_consent_popup() {
|
||||
return await invoke('plugin:ads|should_show_ads_consent_popup')
|
||||
}
|
||||
|
||||
export async function perform_ads_consent_action(action) {
|
||||
|
||||
+4
-5
@@ -306,12 +306,11 @@ fn main() {
|
||||
"init_ads_window",
|
||||
"hide_ads_window",
|
||||
"show_ads_window",
|
||||
"show_ads_consent_overlay",
|
||||
"show_ads_consent_preferences",
|
||||
"show_ads_consent_ui",
|
||||
"expand_ads_consent_webview",
|
||||
"open_ads_consent_preferences",
|
||||
"hide_ads_consent_preferences",
|
||||
"hide_ads_consent_overlay",
|
||||
"get_ads_consent_required",
|
||||
"finish_ads_consent_flow",
|
||||
"should_show_ads_consent_popup",
|
||||
"perform_ads_consent_action",
|
||||
"record_ads_click",
|
||||
"open_link",
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
const MODRINTH_ORIGIN = 'https://modrinth.com'
|
||||
|
||||
/**
|
||||
* @typedef {'custom' | 'fallback' | 'hidden'} AdsConsentPopupMode
|
||||
*
|
||||
* @typedef {(command: string, args?: Record<string, unknown>) => Promise<unknown>} TauriInvoke
|
||||
*/
|
||||
|
||||
/** @returns {TauriInvoke | undefined} */
|
||||
function getTauriInvoke() {
|
||||
const invoke = window.__TAURI__?.core?.invoke ?? window.__TAURI_INTERNALS__?.invoke
|
||||
return typeof invoke === 'function' ? invoke : undefined
|
||||
}
|
||||
|
||||
/** @returns {void} */
|
||||
function notifyAdClick() {
|
||||
window.top?.postMessage({ modrinthAdClick: true }, MODRINTH_ORIGIN)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | URL} url
|
||||
* @returns {void}
|
||||
*/
|
||||
function openExternalUrl(url) {
|
||||
window.top?.postMessage({ modrinthOpenUrl: String(url) }, MODRINTH_ORIGIN)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {AdsConsentPopupMode} mode
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function invokeAdsConsentPopupMode(mode) {
|
||||
const invoke = getTauriInvoke()
|
||||
if (!invoke) return
|
||||
|
||||
try {
|
||||
if (mode === 'hidden') {
|
||||
await invoke('plugin:ads|finish_ads_consent_flow', { dpr: window.devicePixelRatio })
|
||||
return
|
||||
}
|
||||
|
||||
await invoke('plugin:ads|show_ads_consent_ui', {
|
||||
notificationEnabled: mode === 'custom',
|
||||
})
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** @returns {Promise<void>} */
|
||||
async function expandAdsConsentWebview() {
|
||||
const invoke = getTauriInvoke()
|
||||
if (!invoke) throw new Error('Tauri invoke is unavailable in the ads webview')
|
||||
|
||||
await invoke('plugin:ads|expand_ads_consent_webview')
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* @typedef {'accept' | 'reject' | 'manage'} ConsentAction
|
||||
* @typedef {'handled' | 'not-ready' | 'failed'} ConsentActionResult
|
||||
*
|
||||
* @typedef {object} UspConsentControls
|
||||
* @property {HTMLButtonElement[]} toggles
|
||||
* @property {HTMLButtonElement} confirmButton
|
||||
*
|
||||
* @typedef {object} UspPingData
|
||||
* @property {string | string[]} [mode]
|
||||
* @property {string | string[]} [jurisdiction]
|
||||
* @property {string} [location]
|
||||
*/
|
||||
|
||||
/** @type {Readonly<Record<ConsentAction, string>>} */
|
||||
const ACTION_BUTTON_IDS = {
|
||||
accept: 'accept-btn',
|
||||
reject: 'disagree-btn',
|
||||
manage: 'more-options-btn',
|
||||
}
|
||||
|
||||
/** @type {Readonly<Record<AdsConsentVariant, string>>} */
|
||||
const DIALOG_IDS = {
|
||||
tcf: 'qc-cmp2-ui',
|
||||
usp: 'qc-cmp2-usp',
|
||||
}
|
||||
|
||||
/** @type {Readonly<{ toggles: string, confirmButton: string }>} */
|
||||
const USP_CONTROL_SELECTORS = {
|
||||
toggles: '.qc-usp-container button.qc-cmp2-toggle[role="switch"]',
|
||||
confirmButton: '.qc-usp-ui-form-content button[mode="primary"]',
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLButtonElement | null} button
|
||||
* @returns {boolean | null}
|
||||
*/
|
||||
function isButtonEnabled(button) {
|
||||
return button && !button.disabled && button.getAttribute('aria-disabled') !== 'true'
|
||||
}
|
||||
|
||||
/** @returns {AdsConsentVariant | null} */
|
||||
function detectConsentVariant() {
|
||||
if (document.getElementById(DIALOG_IDS.usp)) return 'usp'
|
||||
if (document.getElementById(DIALOG_IDS.tcf)) return 'tcf'
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ConsentAction} action
|
||||
* @returns {HTMLButtonElement | null}
|
||||
*/
|
||||
function findTcfConsentButton(action) {
|
||||
const dialog = document.getElementById(DIALOG_IDS.tcf)
|
||||
if (!dialog) return null
|
||||
|
||||
const button = /** @type {HTMLButtonElement | null} */ (
|
||||
dialog.querySelector(`#${ACTION_BUTTON_IDS[action]}`)
|
||||
)
|
||||
return isButtonEnabled(button) ? button : null
|
||||
}
|
||||
|
||||
/** @returns {UspConsentControls | null} */
|
||||
function getUspConsentControls() {
|
||||
const dialog = document.getElementById(DIALOG_IDS.usp)
|
||||
if (!dialog) return null
|
||||
|
||||
const toggles = Array.from(
|
||||
/** @type {NodeListOf<HTMLButtonElement>} */ (
|
||||
dialog.querySelectorAll(USP_CONTROL_SELECTORS.toggles)
|
||||
),
|
||||
)
|
||||
const confirmButton = /** @type {HTMLButtonElement | null} */ (
|
||||
dialog.querySelector(USP_CONTROL_SELECTORS.confirmButton)
|
||||
)
|
||||
|
||||
if (
|
||||
toggles.length === 0 ||
|
||||
!isButtonEnabled(confirmButton) ||
|
||||
toggles.some(
|
||||
(toggle) =>
|
||||
!isButtonEnabled(toggle) ||
|
||||
!['true', 'false'].includes(toggle.getAttribute('aria-checked') ?? ''),
|
||||
)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { toggles, confirmButton }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {AdsConsentVariant | null} variant
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function areConsentControlsPresent(variant) {
|
||||
if (variant === 'tcf') {
|
||||
const dialog = document.getElementById(DIALOG_IDS.tcf)
|
||||
return (
|
||||
dialog !== null &&
|
||||
/** @type {ConsentAction[]} */ (['accept', 'reject', 'manage']).every((action) =>
|
||||
dialog.querySelector(`#${ACTION_BUTTON_IDS[action]}`),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (variant === 'usp') {
|
||||
const dialog = document.getElementById(DIALOG_IDS.usp)
|
||||
return (
|
||||
dialog !== null &&
|
||||
dialog.querySelectorAll(USP_CONTROL_SELECTORS.toggles).length > 0 &&
|
||||
dialog.querySelector(USP_CONTROL_SELECTORS.confirmButton) !== null
|
||||
)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ConsentAction} action
|
||||
* @param {AdsConsentVariant | null} variant
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isConsentActionAvailable(action, variant) {
|
||||
if (variant === 'tcf') return findTcfConsentButton(action) !== null
|
||||
if (variant === 'usp') {
|
||||
if (action === 'manage') return document.getElementById(DIALOG_IDS.usp) !== null
|
||||
return getUspConsentControls() !== null
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} index
|
||||
* @param {boolean} checked
|
||||
* @param {number} expectedCount
|
||||
* @param {number} deadline
|
||||
* @returns {Promise<UspConsentControls | null>}
|
||||
*/
|
||||
function waitForUspToggleState(index, checked, expectedCount, deadline) {
|
||||
return new Promise((resolve) => {
|
||||
/** @returns {void} */
|
||||
const 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(null)
|
||||
} else {
|
||||
setTimeout(checkState, 50)
|
||||
}
|
||||
}
|
||||
|
||||
checkState()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} checked
|
||||
* @param {UspConsentControls} controls
|
||||
* @param {number} timeoutMs
|
||||
* @returns {Promise<UspConsentControls | null>}
|
||||
*/
|
||||
async function setUspToggleStates(checked, controls, timeoutMs) {
|
||||
const expectedCount = controls.toggles.length
|
||||
const deadline = Date.now() + timeoutMs
|
||||
|
||||
for (let index = 0; index < expectedCount; index += 1) {
|
||||
const currentControls = getUspConsentControls()
|
||||
if (!currentControls || currentControls.toggles.length !== expectedCount) return null
|
||||
controls = currentControls
|
||||
|
||||
const toggle = controls.toggles[index]
|
||||
if (toggle.getAttribute('aria-checked') !== String(checked)) {
|
||||
toggle.click()
|
||||
const settledControls = await waitForUspToggleState(index, checked, expectedCount, deadline)
|
||||
if (!settledControls) return null
|
||||
controls = settledControls
|
||||
}
|
||||
}
|
||||
|
||||
return controls
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ConsentAction} action
|
||||
* @param {AdsConsentVariant | null} variant
|
||||
* @param {(() => void) | undefined} onSubmit
|
||||
* @returns {Promise<ConsentActionResult>}
|
||||
*/
|
||||
async function performDocumentConsentAction(action, variant, onSubmit) {
|
||||
if (variant === 'usp') {
|
||||
if (action === 'manage') {
|
||||
return 'handled'
|
||||
}
|
||||
|
||||
const controls = getUspConsentControls()
|
||||
if (!controls) return 'not-ready'
|
||||
|
||||
const settledControls = await setUspToggleStates(action === 'reject', controls, 2_000)
|
||||
if (!settledControls) return 'failed'
|
||||
|
||||
onSubmit?.()
|
||||
settledControls.confirmButton.click()
|
||||
return 'handled'
|
||||
}
|
||||
|
||||
if (variant === 'tcf') {
|
||||
const button = findTcfConsentButton(action)
|
||||
if (!button) return 'not-ready'
|
||||
|
||||
onSubmit?.()
|
||||
button.click()
|
||||
return 'handled'
|
||||
}
|
||||
|
||||
return 'not-ready'
|
||||
}
|
||||
|
||||
/** @returns {boolean} */
|
||||
function displayUspConsentUi() {
|
||||
if (!window.__uspapi) return false
|
||||
|
||||
try {
|
||||
window.__uspapi('displayUspUi', 1, () => {})
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {Promise<boolean>} */
|
||||
function isUspConsentApplicable() {
|
||||
if (detectConsentVariant() === 'usp') return Promise.resolve(true)
|
||||
if (!window.__uspapi) return Promise.resolve(false)
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let settled = false
|
||||
/**
|
||||
* @param {boolean} applicable
|
||||
* @returns {void}
|
||||
*/
|
||||
const settle = (applicable) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timeout)
|
||||
resolve(applicable)
|
||||
}
|
||||
const timeout = setTimeout(() => settle(false), 500)
|
||||
|
||||
try {
|
||||
window.__uspapi?.(
|
||||
'uspPing',
|
||||
1,
|
||||
/**
|
||||
* @param {UspPingData | null | undefined} data
|
||||
* @param {boolean} success
|
||||
* @returns {void}
|
||||
*/
|
||||
(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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
const LAYOUT_DELAY = 100
|
||||
const CONSENT_TIMEOUT = 10_000
|
||||
|
||||
class AdsConsentController {
|
||||
constructor() {
|
||||
/** @type {AdsConsentState} */
|
||||
this.state = new AdsConsentState()
|
||||
|
||||
/** @type {AdsConsentPhase | null} */
|
||||
this.preSubmissionPhase = null
|
||||
|
||||
/** @type {ReturnType<typeof setTimeout> | null} */
|
||||
this.submissionTimeout = null
|
||||
|
||||
/** @type {ReturnType<typeof setTimeout> | null} */
|
||||
this.popupReadinessTimeout = 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')
|
||||
|
||||
if (!cmpMain) {
|
||||
this.clearPopupReadinessTimeout()
|
||||
if (this.state.phase === 'idle') {
|
||||
document.documentElement.classList.remove('modrinth-ads-consent-overlay')
|
||||
} else if (this.state.phase !== 'submitting-consent' && this.state.phase !== 'finishing') {
|
||||
this.finishConsentFlow()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (this.state.phase !== 'idle') return
|
||||
|
||||
document.documentElement.classList.add('modrinth-ads-consent-overlay')
|
||||
const variant = this.detectVariant()
|
||||
if (!areConsentControlsPresent(variant)) {
|
||||
this.waitForConsentControls()
|
||||
return
|
||||
}
|
||||
|
||||
this.clearPopupReadinessTimeout()
|
||||
this.state.setState('showing-popup')
|
||||
this.setPopupMode('custom')
|
||||
}
|
||||
|
||||
/** @returns {void} */
|
||||
waitForConsentControls() {
|
||||
if (this.popupReadinessTimeout) return
|
||||
|
||||
this.popupReadinessTimeout = setTimeout(() => {
|
||||
this.popupReadinessTimeout = null
|
||||
|
||||
if (this.state.phase !== 'idle' || !document.getElementById('qc-cmp2-main')) return
|
||||
|
||||
const variant = this.detectVariant()
|
||||
if (areConsentControlsPresent(variant)) {
|
||||
this.syncConsentPopup()
|
||||
return
|
||||
}
|
||||
|
||||
this.state.setState('showing-popup')
|
||||
this.showNativeCmpFallback()
|
||||
}, CONSENT_TIMEOUT)
|
||||
}
|
||||
|
||||
/** @returns {void} */
|
||||
clearPopupReadinessTimeout() {
|
||||
clearTimeout(this.popupReadinessTimeout ?? undefined)
|
||||
this.popupReadinessTimeout = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the clicks from custom privacy popup to native popup buttons
|
||||
* @param {ConsentAction} action
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async performAction(action) {
|
||||
if (!['accept', 'reject', 'manage'].includes(action)) return
|
||||
|
||||
if (action === 'manage') {
|
||||
try {
|
||||
await this.openPreferences()
|
||||
} catch {
|
||||
this.showNativeCmpFallback()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const variant = this.detectVariant()
|
||||
if (!isConsentActionAvailable(action, variant)) {
|
||||
this.showNativeCmpFallback()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
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
|
||||
this.showNativeCmpFallback()
|
||||
}
|
||||
}, CONSENT_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 {void} */
|
||||
handleTcfClose() {
|
||||
if (this.state.variant !== 'tcf') return
|
||||
|
||||
if (this.state.phase === 'showing-preferences') {
|
||||
this.state.setState('showing-popup')
|
||||
this.concealPreferences()
|
||||
this.setPopupMode('custom')
|
||||
} else if (this.state.phase === 'showing-reopened-preferences') {
|
||||
this.finishReopenedPopup()
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {Promise<void>} */
|
||||
async reopenPreferences() {
|
||||
if (document.documentElement.classList.contains('modrinth-ads-consent-overlay')) {
|
||||
try {
|
||||
await this.openPreferences()
|
||||
} catch {
|
||||
this.showNativeCmpFallback()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
this.state.setState('showing-reopened-preferences')
|
||||
this.preparePreferences()
|
||||
|
||||
try {
|
||||
await this.waitForLayout()
|
||||
await expandAdsConsentWebview()
|
||||
await this.waitForLayout()
|
||||
this.revealPreferences()
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
|
||||
if (!(await this.displayReopenedPopup())) {
|
||||
this.finishReopenedPopup()
|
||||
return
|
||||
}
|
||||
|
||||
if (!(await this.openConsentManagerWhenReady(CONSENT_TIMEOUT))) {
|
||||
this.showNativeCmpFallback()
|
||||
}
|
||||
} catch {
|
||||
this.finishReopenedPopup()
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {AdsConsentVariant | null} */
|
||||
detectVariant() {
|
||||
const variant = detectConsentVariant()
|
||||
if (variant) this.state.setVariant(variant)
|
||||
return variant
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} timeoutMs
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async openConsentManagerWhenReady(timeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const result = await performDocumentConsentAction('manage', this.detectVariant())
|
||||
if (result === 'handled') return true
|
||||
if (result === 'failed' || Date.now() >= deadline) return false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {void} */
|
||||
preparePreferences() {
|
||||
document.documentElement.classList.add('modrinth-ads-consent-preferences')
|
||||
document.documentElement.classList.remove('modrinth-ads-consent-preferences-visible')
|
||||
}
|
||||
|
||||
/** @returns {void} */
|
||||
revealPreferences() {
|
||||
document.documentElement.classList.add('modrinth-ads-consent-preferences-visible')
|
||||
}
|
||||
|
||||
/** @returns {void} */
|
||||
concealPreferences() {
|
||||
document.documentElement.classList.remove('modrinth-ads-consent-preferences')
|
||||
document.documentElement.classList.remove('modrinth-ads-consent-preferences-visible')
|
||||
}
|
||||
|
||||
/** @returns {Promise<void>} */
|
||||
waitForLayout() {
|
||||
return new Promise((resolve) => setTimeout(resolve, LAYOUT_DELAY))
|
||||
}
|
||||
|
||||
/** @returns {Promise<void>} */
|
||||
async showExpandedUi() {
|
||||
this.preparePreferences()
|
||||
await this.waitForLayout()
|
||||
await expandAdsConsentWebview()
|
||||
await this.waitForLayout()
|
||||
this.revealPreferences()
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
}
|
||||
|
||||
/** @returns {Promise<boolean>} */
|
||||
async openPreferences() {
|
||||
this.state.setState('showing-preferences')
|
||||
await this.showExpandedUi()
|
||||
|
||||
if (!(await this.openConsentManagerWhenReady(CONSENT_TIMEOUT))) {
|
||||
this.showNativeCmpFallback()
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/** @returns {Promise<boolean>} */
|
||||
async displayReopenedPopup() {
|
||||
if ((this.state.variant === 'usp' || (await isUspConsentApplicable())) && window.__uspapi) {
|
||||
this.state.setVariant('usp')
|
||||
return displayUspConsentUi()
|
||||
}
|
||||
|
||||
if (!window.__tcfapi) return false
|
||||
this.state.setVariant('tcf')
|
||||
window.__tcfapi('displayConsentUi', 2, () => {})
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {AdsConsentPopupMode} mode
|
||||
* @returns {void}
|
||||
*/
|
||||
setPopupMode(mode) {
|
||||
const shown = mode !== 'hidden'
|
||||
const hidden = mode === 'hidden'
|
||||
document.documentElement.classList.toggle('modrinth-ads-consent-overlay', shown)
|
||||
document.documentElement.classList.toggle('modrinth-ads-consent-fallback', mode === 'fallback')
|
||||
if (hidden) this.concealPreferences()
|
||||
void invokeAdsConsentPopupMode(mode)
|
||||
}
|
||||
|
||||
/** @returns {void} */
|
||||
showNativeCmpFallback() {
|
||||
this.concealPreferences()
|
||||
this.setPopupMode('fallback')
|
||||
}
|
||||
|
||||
/** @returns {void} */
|
||||
finishReopenedPopup() {
|
||||
this.finishConsentFlow()
|
||||
}
|
||||
|
||||
/** @returns {void} */
|
||||
finishConsentFlow() {
|
||||
if (this.state.phase === 'idle' || this.state.phase === 'finishing') {
|
||||
return
|
||||
}
|
||||
|
||||
clearTimeout(this.submissionTimeout ?? undefined)
|
||||
this.submissionTimeout = null
|
||||
this.clearPopupReadinessTimeout()
|
||||
this.preSubmissionPhase = null
|
||||
this.state.setState('finishing')
|
||||
this.setPopupMode('hidden')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
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<typeof setTimeout> | 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()
|
||||
}
|
||||
|
||||
document.addEventListener(
|
||||
'click',
|
||||
(event) => {
|
||||
notifyAdClick()
|
||||
|
||||
const target = event.target instanceof Element ? event.target : null
|
||||
if (target?.closest('.qc-cmp2-close-icon')) {
|
||||
setTimeout(() => controller.handleTcfClose())
|
||||
}
|
||||
|
||||
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()
|
||||
openExternalUrl(link.href)
|
||||
}
|
||||
},
|
||||
true,
|
||||
)
|
||||
|
||||
window.open = (url) => {
|
||||
if (url) openExternalUrl(url)
|
||||
return null
|
||||
}
|
||||
|
||||
window.modrinthPrivacy ??= {}
|
||||
window.modrinthPrivacy.adsConsentAction = (action) => void controller.performAction(action)
|
||||
window.modrinthPrivacy.adsReopenConsentPreferences = () => controller.reopenPreferences()
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
muteMediaElements()
|
||||
muteAudioContext()
|
||||
initializeTopFrame()
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
muteMediaElements()
|
||||
if (isTopFrame()) controller.syncConsentPopup()
|
||||
})
|
||||
observer.observe(document.body, { childList: true, subtree: true })
|
||||
})
|
||||
|
||||
initializeTopFrame()
|
||||
@@ -0,0 +1,73 @@
|
||||
function muteAudioContext() {
|
||||
const AudioContextClass = window.AudioContext ?? window.webkitAudioContext
|
||||
if (!AudioContextClass) return
|
||||
|
||||
const prototype = AudioContextClass.prototype
|
||||
const originalCreateGain = prototype.createGain
|
||||
const originalCreateMediaElementSource = prototype.createMediaElementSource
|
||||
const originalCreateMediaStreamSource = prototype.createMediaStreamSource
|
||||
const originalCreateMediaStreamTrackSource = prototype.createMediaStreamTrackSource
|
||||
const originalCreateBufferSource = prototype.createBufferSource
|
||||
const originalCreateOscillator = prototype.createOscillator
|
||||
|
||||
prototype.createGain = function () {
|
||||
const gain = originalCreateGain.call(this)
|
||||
gain.gain.value = 0
|
||||
return gain
|
||||
}
|
||||
|
||||
prototype.createMediaElementSource = function (mediaElement) {
|
||||
const source = originalCreateMediaElementSource.call(this, mediaElement)
|
||||
source.connect(this.createGain())
|
||||
return source
|
||||
}
|
||||
|
||||
prototype.createMediaStreamSource = function (mediaStream) {
|
||||
const source = originalCreateMediaStreamSource.call(this, mediaStream)
|
||||
source.connect(this.createGain())
|
||||
return source
|
||||
}
|
||||
|
||||
if (originalCreateMediaStreamTrackSource) {
|
||||
prototype.createMediaStreamTrackSource = function (mediaStreamTrack) {
|
||||
const source = originalCreateMediaStreamTrackSource.call(this, mediaStreamTrack)
|
||||
source.connect(this.createGain())
|
||||
return source
|
||||
}
|
||||
}
|
||||
|
||||
prototype.createBufferSource = function () {
|
||||
const source = originalCreateBufferSource.call(this)
|
||||
source.connect(this.createGain())
|
||||
return source
|
||||
}
|
||||
|
||||
prototype.createOscillator = function () {
|
||||
const oscillator = originalCreateOscillator.call(this)
|
||||
oscillator.connect(this.createGain())
|
||||
return oscillator
|
||||
}
|
||||
}
|
||||
|
||||
function muteMediaElement(mediaElement) {
|
||||
const muteCount = Number(mediaElement.dataset.modrinthMutedCount ?? 0)
|
||||
|
||||
if (!mediaElement.muted || mediaElement.volume !== 0) {
|
||||
mediaElement.muted = true
|
||||
mediaElement.volume = 0
|
||||
mediaElement.dataset.modrinthMutedCount = String(muteCount + 1)
|
||||
}
|
||||
|
||||
if (muteCount > 5) mediaElement.remove()
|
||||
}
|
||||
|
||||
function muteMediaElements() {
|
||||
document.querySelectorAll('video, audio').forEach((mediaElement) => {
|
||||
muteMediaElement(mediaElement)
|
||||
|
||||
if (!mediaElement.dataset.modrinthMuted) {
|
||||
mediaElement.addEventListener('volumechange', () => muteMediaElement(mediaElement))
|
||||
mediaElement.dataset.modrinthMuted = 'true'
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* @typedef {'idle' | 'showing-popup' | 'showing-preferences' | 'showing-reopened-preferences' | 'submitting-consent' | 'finishing'} AdsConsentPhase
|
||||
* @typedef {'usp' | 'tcf'} AdsConsentVariant
|
||||
*/
|
||||
|
||||
class AdsConsentState {
|
||||
constructor() {
|
||||
/** @type {AdsConsentPhase} */
|
||||
this.phase = 'idle'
|
||||
|
||||
/** @type {AdsConsentVariant | null} */
|
||||
this.variant = null
|
||||
}
|
||||
|
||||
/** @param {AdsConsentPhase} phase */
|
||||
setState(phase) {
|
||||
this.phase = phase
|
||||
}
|
||||
|
||||
/** @param {AdsConsentVariant} variant */
|
||||
setVariant(variant) {
|
||||
this.variant = variant
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
const THEME_STYLE = `
|
||||
:root {
|
||||
--modrinth-usp-bg: #27292e;
|
||||
--modrinth-usp-surface: #34363c;
|
||||
--modrinth-usp-divider: #34363c;
|
||||
--modrinth-usp-text: #b0bac5;
|
||||
--modrinth-usp-contrast: #ffffff;
|
||||
--modrinth-usp-brand: #1bd96a;
|
||||
--modrinth-usp-link: #4f9cff;
|
||||
--modrinth-usp-accent-contrast: #000000;
|
||||
--modrinth-usp-shadow: rgba(0, 0, 0, 0.1) 0 4px 6px -1px,
|
||||
rgba(0, 0, 0, 0.06) 0 2px 4px -1px;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
#qc-cmp2-usp {
|
||||
outline: none !important;
|
||||
background: var(--modrinth-usp-bg) !important;
|
||||
border: 1px solid var(--modrinth-usp-divider) !important;
|
||||
border-radius: 1rem !important;
|
||||
box-shadow: var(--modrinth-usp-shadow) !important;
|
||||
color: var(--modrinth-usp-text) !important;
|
||||
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Oxygen, Ubuntu, Roboto,
|
||||
Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif !important;
|
||||
max-width: 660px;
|
||||
}
|
||||
|
||||
#qc-cmp2-usp .qc-usp-ui-content,
|
||||
#qc-cmp2-usp .qc-usp-ui-form-content,
|
||||
#qc-cmp2-usp .qc-usp-container {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
#qc-cmp2-usp .qc-usp-container {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
#qc-cmp2-usp p,
|
||||
#qc-cmp2-usp label,
|
||||
#qc-cmp2-usp .qc-usp-action-description {
|
||||
color: var(--modrinth-usp-text) !important;
|
||||
font-family: inherit !important;
|
||||
}
|
||||
|
||||
#qc-cmp2-usp .qc-usp-title,
|
||||
#qc-cmp2-usp .qc-cmp2-list-item-title {
|
||||
color: var(--modrinth-usp-contrast) !important;
|
||||
font-family: inherit !important;
|
||||
font-weight: 700 !important;
|
||||
}
|
||||
|
||||
#qc-cmp2-usp .qc-usp-title {
|
||||
font-size: 1.25rem !important;
|
||||
}
|
||||
|
||||
#qc-cmp2-usp a,
|
||||
#qc-cmp2-usp .qc-usp-alt-action {
|
||||
color: var(--modrinth-usp-link) !important;
|
||||
}
|
||||
|
||||
#qc-cmp2-usp .qc-cmp2-list-item {
|
||||
background: var(--modrinth-usp-surface) !important;
|
||||
border: 1px solid var(--modrinth-usp-divider) !important;
|
||||
border-radius: 0.75rem !important;
|
||||
}
|
||||
|
||||
#qc-cmp2-usp .qc-cmp2-list-item-header {
|
||||
background: transparent !important;
|
||||
border: 0 !important;
|
||||
color: var(--modrinth-usp-contrast) !important;
|
||||
}
|
||||
|
||||
#qc-cmp2-usp .qc-cmp2-list-item-header svg {
|
||||
color: var(--modrinth-usp-text) !important;
|
||||
}
|
||||
|
||||
#qc-cmp2-usp .qc-cmp2-toggle {
|
||||
background: var(--modrinth-usp-bg) !important;
|
||||
border-color: var(--modrinth-usp-divider) !important;
|
||||
}
|
||||
|
||||
#qc-cmp2-usp .qc-cmp2-toggle .toggle {
|
||||
background: var(--modrinth-usp-contrast) !important;
|
||||
}
|
||||
|
||||
#qc-cmp2-usp .qc-cmp2-toggle .text {
|
||||
color: var(--modrinth-usp-contrast) !important;
|
||||
}
|
||||
|
||||
#qc-cmp2-usp .qc-cmp2-toggle[aria-checked='true'] {
|
||||
background: var(--modrinth-usp-brand) !important;
|
||||
border-color: var(--modrinth-usp-brand) !important;
|
||||
}
|
||||
|
||||
#qc-cmp2-usp .qc-cmp2-toggle[aria-checked='true'] .text {
|
||||
color: var(--modrinth-usp-accent-contrast) !important;
|
||||
}
|
||||
|
||||
#qc-cmp2-usp button[mode='primary'] {
|
||||
background: var(--modrinth-usp-brand) !important;
|
||||
border: 0 !important;
|
||||
border-radius: 0.75rem !important;
|
||||
color: var(--modrinth-usp-accent-contrast) !important;
|
||||
font-family: inherit !important;
|
||||
font-weight: 700 !important;
|
||||
}
|
||||
|
||||
#qc-cmp2-usp .qc-usp-close-icon {
|
||||
border: 0 !important;
|
||||
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 6 6 18'/%3E%3Cpath d='m6 6 12 12'/%3E%3C/svg%3E")
|
||||
center / 1.5rem 1.5rem no-repeat;
|
||||
}
|
||||
|
||||
#qc-cmp2-usp a:focus-visible,
|
||||
#qc-cmp2-usp button:focus-visible {
|
||||
outline: 2px solid var(--modrinth-usp-brand) !important;
|
||||
outline-offset: 2px !important;
|
||||
}
|
||||
|
||||
#qc-cmp2-usp .qc-usp-ui-content {
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
#qc-cmp2-usp .qc-usp-ui-content .qc-usp-ui-form-content {
|
||||
border: 1px solid transparent !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
`
|
||||
|
||||
const RAIL_STYLE = `
|
||||
html.modrinth-ads-consent-preferences #modrinth-rail-1 {
|
||||
display: none !important;
|
||||
}
|
||||
`
|
||||
|
||||
const OVERLAY_STYLE = `
|
||||
html.modrinth-ads-consent-overlay:not(.modrinth-ads-consent-fallback):not(.modrinth-ads-consent-preferences) #qc-cmp2-main,
|
||||
html.modrinth-ads-consent-preferences:not(.modrinth-ads-consent-preferences-visible) #qc-cmp2-main {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
#qc-cmp2-usp .qc-usp-close-icon {
|
||||
display: none !important;
|
||||
}
|
||||
`
|
||||
|
||||
function installStyle(id, css) {
|
||||
if (document.getElementById(id)) return
|
||||
|
||||
const style = document.createElement('style')
|
||||
style.id = id
|
||||
style.textContent = css
|
||||
document.documentElement.appendChild(style)
|
||||
}
|
||||
|
||||
function installConsentStyles() {
|
||||
installStyle('modrinth-ads-consent-theme-style', THEME_STYLE)
|
||||
installStyle('modrinth-ads-rail-style', RAIL_STYLE)
|
||||
installStyle('modrinth-ads-consent-overlay-style', OVERLAY_STYLE)
|
||||
}
|
||||
@@ -1,967 +0,0 @@
|
||||
const MODRINTH_ORIGIN = 'https://modrinth.com'
|
||||
|
||||
function installAdsConsentThemeStyle() {
|
||||
if (document.getElementById('modrinth-ads-consent-theme-style')) return
|
||||
|
||||
const style = document.createElement('style')
|
||||
style.id = 'modrinth-ads-consent-theme-style'
|
||||
style.textContent = `
|
||||
:root {
|
||||
--modrinth-usp-bg: #27292e;
|
||||
--modrinth-usp-surface: #34363c;
|
||||
--modrinth-usp-divider: #34363c;
|
||||
--modrinth-usp-text: #b0bac5;
|
||||
--modrinth-usp-contrast: #ffffff;
|
||||
--modrinth-usp-brand: #1bd96a;
|
||||
--modrinth-usp-link: #4f9cff;
|
||||
--modrinth-usp-accent-contrast: #000000;
|
||||
--modrinth-usp-shadow: rgba(0, 0, 0, 0.1) 0 4px 6px -1px,
|
||||
rgba(0, 0, 0, 0.06) 0 2px 4px -1px;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
#qc-cmp2-usp {
|
||||
background: var(--modrinth-usp-bg) !important;
|
||||
border: 1px solid var(--modrinth-usp-divider) !important;
|
||||
border-radius: 1rem !important;
|
||||
box-shadow: var(--modrinth-usp-shadow) !important;
|
||||
color: var(--modrinth-usp-text) !important;
|
||||
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Oxygen, Ubuntu, Roboto,
|
||||
Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif !important;
|
||||
max-width: 660px;
|
||||
}
|
||||
|
||||
#qc-cmp2-usp .qc-usp-ui-content,
|
||||
#qc-cmp2-usp .qc-usp-ui-form-content,
|
||||
#qc-cmp2-usp .qc-usp-container {
|
||||
background: transparent !important;
|
||||
}
|
||||
#qc-cmp2-usp .qc-usp-container{
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
#qc-cmp2-usp p,
|
||||
#qc-cmp2-usp label,
|
||||
#qc-cmp2-usp .qc-usp-action-description {
|
||||
color: var(--modrinth-usp-text) !important;
|
||||
font-family: inherit !important;
|
||||
}
|
||||
|
||||
#qc-cmp2-usp .qc-usp-title,
|
||||
#qc-cmp2-usp .qc-cmp2-list-item-title {
|
||||
color: var(--modrinth-usp-contrast) !important;
|
||||
font-family: inherit !important;
|
||||
font-weight: 700 !important;
|
||||
}
|
||||
|
||||
#qc-cmp2-usp .qc-usp-title {
|
||||
font-size: 1.25rem !important;
|
||||
}
|
||||
|
||||
#qc-cmp2-usp a,
|
||||
#qc-cmp2-usp .qc-usp-alt-action {
|
||||
color: var(--modrinth-usp-link) !important;
|
||||
}
|
||||
|
||||
#qc-cmp2-usp .qc-cmp2-list-item {
|
||||
background: var(--modrinth-usp-surface) !important;
|
||||
border: 1px solid var(--modrinth-usp-divider) !important;
|
||||
border-radius: 0.75rem !important;
|
||||
}
|
||||
|
||||
#qc-cmp2-usp .qc-cmp2-list-item-header {
|
||||
background: transparent !important;
|
||||
border: 0 !important;
|
||||
color: var(--modrinth-usp-contrast) !important;
|
||||
}
|
||||
|
||||
#qc-cmp2-usp .qc-cmp2-list-item-header svg {
|
||||
color: var(--modrinth-usp-text) !important;
|
||||
}
|
||||
|
||||
#qc-cmp2-usp .qc-cmp2-toggle {
|
||||
background: var(--modrinth-usp-bg) !important;
|
||||
border-color: var(--modrinth-usp-divider) !important;
|
||||
}
|
||||
|
||||
#qc-cmp2-usp .qc-cmp2-toggle .toggle {
|
||||
background: var(--modrinth-usp-contrast) !important;
|
||||
}
|
||||
|
||||
#qc-cmp2-usp .qc-cmp2-toggle .text {
|
||||
color: var(--modrinth-usp-contrast) !important;
|
||||
}
|
||||
|
||||
#qc-cmp2-usp .qc-cmp2-toggle[aria-checked='true'] {
|
||||
background: var(--modrinth-usp-brand) !important;
|
||||
border-color: var(--modrinth-usp-brand) !important;
|
||||
}
|
||||
|
||||
#qc-cmp2-usp .qc-cmp2-toggle[aria-checked='true'] .text {
|
||||
color: var(--modrinth-usp-accent-contrast) !important;
|
||||
}
|
||||
|
||||
#qc-cmp2-usp button[mode='primary'] {
|
||||
background: var(--modrinth-usp-brand) !important;
|
||||
border: 0 !important;
|
||||
border-radius: 0.75rem !important;
|
||||
color: var(--modrinth-usp-accent-contrast) !important;
|
||||
font-family: inherit !important;
|
||||
font-weight: 700 !important;
|
||||
}
|
||||
|
||||
#qc-cmp2-usp .qc-usp-close-icon {
|
||||
border: 0 !important;
|
||||
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 6 6 18'/%3E%3Cpath d='m6 6 12 12'/%3E%3C/svg%3E")
|
||||
center / 1.5rem 1.5rem no-repeat;
|
||||
}
|
||||
|
||||
#qc-cmp2-usp a:focus-visible,
|
||||
#qc-cmp2-usp button:focus-visible {
|
||||
outline: 2px solid var(--modrinth-usp-brand) !important;
|
||||
outline-offset: 2px !important;
|
||||
}
|
||||
|
||||
#qc-cmp2-usp .qc-usp-ui-content {
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
#qc-cmp2-usp .qc-usp-ui-content .qc-usp-ui-form-content {
|
||||
border: 1px solid transparent !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
`
|
||||
document.documentElement.appendChild(style)
|
||||
}
|
||||
|
||||
document.addEventListener(
|
||||
'click',
|
||||
function (e) {
|
||||
window.top.postMessage({ modrinthAdClick: true }, MODRINTH_ORIGIN)
|
||||
|
||||
let target = e.target
|
||||
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()
|
||||
if (target.href) {
|
||||
window.top.postMessage({ modrinthOpenUrl: target.href }, MODRINTH_ORIGIN)
|
||||
}
|
||||
break
|
||||
}
|
||||
target = target.parentElement
|
||||
}
|
||||
},
|
||||
true,
|
||||
)
|
||||
|
||||
window.open = (url, target, features) => {
|
||||
window.top.postMessage({ modrinthOpenUrl: url }, MODRINTH_ORIGIN)
|
||||
}
|
||||
|
||||
const modrinthAdsConsentState = {
|
||||
phase: 'idle',
|
||||
variant: null,
|
||||
commitTimeout: null,
|
||||
}
|
||||
let modrinthTcfListenerInstalled = false
|
||||
let modrinthTcfListenerAttempts = 0
|
||||
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
|
||||
}
|
||||
const style = document.createElement('style')
|
||||
style.id = 'modrinth-ads-rail-style'
|
||||
style.textContent = `
|
||||
html.modrinth-ads-consent-preferences #modrinth-rail-1 {
|
||||
display: none !important;
|
||||
}
|
||||
`
|
||||
document.documentElement.appendChild(style)
|
||||
}
|
||||
|
||||
function installAdsConsentOverlayStyle() {
|
||||
if (document.getElementById('modrinth-ads-consent-overlay-style')) {
|
||||
return
|
||||
}
|
||||
const style = document.createElement('style')
|
||||
style.id = 'modrinth-ads-consent-overlay-style'
|
||||
style.textContent = `
|
||||
html.modrinth-ads-consent-overlay:not(.modrinth-ads-consent-preferences) #qc-cmp2-container,
|
||||
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)
|
||||
}
|
||||
|
||||
function getTauriInvoke() {
|
||||
return window.__TAURI__?.core?.invoke ?? window.__TAURI_INTERNALS__?.invoke
|
||||
}
|
||||
|
||||
function invokeAdsConsentOverlayCommand(shown) {
|
||||
const invoke = getTauriInvoke()
|
||||
|
||||
if (typeof invoke !== 'function') {
|
||||
return
|
||||
}
|
||||
|
||||
const command = shown ? 'show_ads_consent_overlay' : 'hide_ads_consent_overlay'
|
||||
const args = shown ? {} : { dpr: window.devicePixelRatio }
|
||||
|
||||
invoke(`plugin:ads|${command}`, args).catch(() => {})
|
||||
}
|
||||
|
||||
function prepareAdsConsentPreferences() {
|
||||
installAdsRailStyle()
|
||||
installAdsConsentOverlayStyle()
|
||||
document.documentElement.classList.add('modrinth-ads-consent-preferences')
|
||||
document.documentElement.classList.remove('modrinth-ads-consent-preferences-visible')
|
||||
}
|
||||
|
||||
function revealAdsConsentPreferences() {
|
||||
document.documentElement.classList.add('modrinth-ads-consent-preferences-visible')
|
||||
}
|
||||
|
||||
function concealAdsConsentPreferences() {
|
||||
document.documentElement.classList.remove('modrinth-ads-consent-preferences')
|
||||
document.documentElement.classList.remove('modrinth-ads-consent-preferences-visible')
|
||||
installAdsConsentOverlayStyle()
|
||||
}
|
||||
|
||||
async function expandAdsConsentWebview() {
|
||||
const invoke = getTauriInvoke()
|
||||
if (typeof invoke !== 'function') {
|
||||
throw new Error('Tauri invoke is unavailable in the ads webview')
|
||||
}
|
||||
|
||||
await invoke('plugin:ads|show_ads_consent_preferences')
|
||||
}
|
||||
|
||||
function sendAdsConsentCommandToChildFrames(command) {
|
||||
document.querySelectorAll('iframe').forEach((frame) => {
|
||||
frame.contentWindow?.postMessage({ modrinthAdsConsentCommand: command }, '*')
|
||||
})
|
||||
}
|
||||
|
||||
function isDirectChildFrame(source) {
|
||||
return Array.from(document.querySelectorAll('iframe')).some(
|
||||
(frame) => frame.contentWindow === source,
|
||||
)
|
||||
}
|
||||
|
||||
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',
|
||||
reject: 'disagree-btn',
|
||||
manage: 'more-options-btn',
|
||||
}
|
||||
const buttonId = buttonIds[action]
|
||||
if (!buttonId) return null
|
||||
|
||||
const button = dialog.querySelector(`#${buttonId}`)
|
||||
return button && !button.disabled ? button : null
|
||||
}
|
||||
|
||||
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 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(null)
|
||||
} else {
|
||||
setTimeout(checkState, 50)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
||||
}
|
||||
|
||||
function performAdsConsentActionAcrossFrames(action, timeoutMs) {
|
||||
const requestId = `${Date.now()}-${++modrinthAdsConsentActionRequestId}`
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let settled = false
|
||||
const settle = (clicked) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timeout)
|
||||
modrinthAdsConsentActionResolvers.delete(requestId)
|
||||
resolve(clicked)
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => settle(false), timeoutMs)
|
||||
modrinthAdsConsentActionResolvers.set(requestId, () => settle(true))
|
||||
sendAdsConsentCommandToChildFrames({ type: 'perform', action, requestId, timeoutMs })
|
||||
void performAdsConsentActionWhenReady(action, timeoutMs, () => settle(true))
|
||||
})
|
||||
}
|
||||
|
||||
function waitForAdsConsentLayout() {
|
||||
return new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
|
||||
async function restoreAdsConsentNotification() {
|
||||
concealAdsConsentPreferences()
|
||||
sendAdsConsentCommandToChildFrames({ type: 'conceal' })
|
||||
|
||||
const invoke = getTauriInvoke()
|
||||
if (typeof invoke === 'function') {
|
||||
await invoke('plugin:ads|hide_ads_consent_preferences')
|
||||
}
|
||||
}
|
||||
|
||||
async function showNativeAdsConsentUi() {
|
||||
prepareAdsConsentPreferences()
|
||||
await waitForAdsConsentLayout()
|
||||
sendAdsConsentCommandToChildFrames({ type: 'prepare' })
|
||||
await expandAdsConsentWebview()
|
||||
await waitForAdsConsentLayout()
|
||||
revealAdsConsentPreferences()
|
||||
sendAdsConsentCommandToChildFrames({ type: 'reveal' })
|
||||
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
sendAdsConsentCommandToChildFrames({ type: 'resize' })
|
||||
|
||||
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) {
|
||||
if (!['accept', 'reject', 'manage'].includes(action)) return
|
||||
|
||||
if (action === 'manage') {
|
||||
try {
|
||||
await openAdsConsentPreferences()
|
||||
} catch {
|
||||
await restoreAdsConsentNotification()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
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 () => {
|
||||
if (document.documentElement.classList.contains('modrinth-ads-consent-overlay')) {
|
||||
try {
|
||||
await openAdsConsentPreferences()
|
||||
} catch {
|
||||
await restoreAdsConsentNotification()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
transitionAdsConsent('reprompt-started')
|
||||
prepareAdsConsentPreferences()
|
||||
sendAdsConsentCommandToChildFrames({ type: 'prepare' })
|
||||
|
||||
try {
|
||||
await waitForAdsConsentLayout()
|
||||
await expandAdsConsentWebview()
|
||||
await waitForAdsConsentLayout()
|
||||
revealAdsConsentPreferences()
|
||||
sendAdsConsentCommandToChildFrames({ type: 'reveal' })
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
sendAdsConsentCommandToChildFrames({ type: 'resize' })
|
||||
|
||||
if (!(await displayAdsConsentReprompt())) {
|
||||
finishAdsConsentReprompt()
|
||||
return
|
||||
}
|
||||
|
||||
if (!(await performAdsConsentActionAcrossFrames('show', 2500))) {
|
||||
finishAdsConsentReprompt()
|
||||
return
|
||||
}
|
||||
|
||||
await performAdsConsentActionAcrossFrames('manage', 2500)
|
||||
} catch {
|
||||
finishAdsConsentReprompt()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
const resultRequestId = event.data?.modrinthAdsConsentResult
|
||||
if (typeof resultRequestId === 'string' && isDirectChildFrame(event.source)) {
|
||||
if (window.top === window) {
|
||||
modrinthAdsConsentActionResolvers.get(resultRequestId)?.()
|
||||
} else {
|
||||
window.parent.postMessage({ modrinthAdsConsentResult: resultRequestId }, '*')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (window.top === window || event.source !== window.parent) return
|
||||
|
||||
const command = event.data?.modrinthAdsConsentCommand
|
||||
if (!command || typeof command !== 'object') return
|
||||
|
||||
if (command.type === 'prepare') {
|
||||
prepareAdsConsentPreferences()
|
||||
sendAdsConsentCommandToChildFrames(command)
|
||||
} else if (command.type === 'reveal') {
|
||||
revealAdsConsentPreferences()
|
||||
sendAdsConsentCommandToChildFrames(command)
|
||||
} else if (command.type === 'conceal') {
|
||||
concealAdsConsentPreferences()
|
||||
sendAdsConsentCommandToChildFrames(command)
|
||||
} else if (command.type === 'resize') {
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
sendAdsConsentCommandToChildFrames(command)
|
||||
} else if (
|
||||
command.type === 'perform' &&
|
||||
typeof command.action === 'string' &&
|
||||
typeof command.requestId === 'string' &&
|
||||
typeof command.timeoutMs === 'number'
|
||||
) {
|
||||
sendAdsConsentCommandToChildFrames(command)
|
||||
performAdsConsentActionWhenReady(command.action, command.timeoutMs, () => {
|
||||
window.parent.postMessage({ modrinthAdsConsentResult: command.requestId }, '*')
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
function setAdsConsentOverlay(shown) {
|
||||
if (document.documentElement.classList.contains('modrinth-ads-consent-overlay') === shown) return
|
||||
|
||||
installAdsConsentOverlayStyle()
|
||||
document.documentElement.classList.toggle('modrinth-ads-consent-overlay', shown)
|
||||
if (!shown) {
|
||||
document.documentElement.classList.remove('modrinth-ads-consent-preferences')
|
||||
document.documentElement.classList.remove('modrinth-ads-consent-preferences-visible')
|
||||
}
|
||||
|
||||
if (window.top === window) {
|
||||
invokeAdsConsentOverlayCommand(shown)
|
||||
} else {
|
||||
window.top.postMessage({ modrinthAdsConsentOverlay: shown }, MODRINTH_ORIGIN)
|
||||
}
|
||||
}
|
||||
|
||||
if (window.top === window) {
|
||||
window.addEventListener('message', (event) => {
|
||||
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 (isAdsConsentReprompt()) return
|
||||
|
||||
transitionAdsConsent('prompt-detected')
|
||||
setAdsConsentOverlay(true)
|
||||
} else if (
|
||||
tcData.eventStatus === 'useractioncomplete' &&
|
||||
modrinthAdsConsentState.variant === 'tcf'
|
||||
) {
|
||||
if (isAdsConsentReprompt()) {
|
||||
finishAdsConsentReprompt()
|
||||
return
|
||||
}
|
||||
|
||||
transitionAdsConsent('completed')
|
||||
setAdsConsentOverlay(false)
|
||||
}
|
||||
}
|
||||
|
||||
// polling to install listener on tcf api
|
||||
function installTcfConsentListener() {
|
||||
if (modrinthTcfListenerInstalled) return
|
||||
|
||||
if (typeof window.__tcfapi === 'function') {
|
||||
modrinthTcfListenerInstalled = true
|
||||
window.__tcfapi('addEventListener', 2, handleTcfConsentEvent)
|
||||
return
|
||||
}
|
||||
|
||||
if (modrinthTcfListenerAttempts < 60) {
|
||||
modrinthTcfListenerAttempts += 1
|
||||
setTimeout(installTcfConsentListener, 500)
|
||||
}
|
||||
}
|
||||
|
||||
function muteAudioContext() {
|
||||
if (window.AudioContext || window.webkitAudioContext) {
|
||||
const AudioContext = window.AudioContext || window.webkitAudioContext
|
||||
const originalCreateMediaElementSource = AudioContext.prototype.createMediaElementSource
|
||||
const originalCreateMediaStreamSource = AudioContext.prototype.createMediaStreamSource
|
||||
const originalCreateMediaStreamTrackSource = AudioContext.prototype.createMediaStreamTrackSource
|
||||
const originalCreateBufferSource = AudioContext.prototype.createBufferSource
|
||||
const originalCreateOscillator = AudioContext.prototype.createOscillator
|
||||
|
||||
AudioContext.prototype.createGain = function () {
|
||||
const gain = originalCreateGain.call(this)
|
||||
gain.gain.value = 0
|
||||
return gain
|
||||
}
|
||||
|
||||
AudioContext.prototype.createMediaElementSource = function (mediaElement) {
|
||||
const source = originalCreateMediaElementSource.call(this, mediaElement)
|
||||
source.connect(this.createGain())
|
||||
return source
|
||||
}
|
||||
|
||||
AudioContext.prototype.createMediaStreamSource = function (mediaStream) {
|
||||
const source = originalCreateMediaStreamSource.call(this, mediaStream)
|
||||
source.connect(this.createGain())
|
||||
return source
|
||||
}
|
||||
|
||||
AudioContext.prototype.createMediaStreamTrackSource = function (mediaStreamTrack) {
|
||||
const source = originalCreateMediaStreamTrackSource.call(this, mediaStreamTrack)
|
||||
source.connect(this.createGain())
|
||||
return source
|
||||
}
|
||||
|
||||
AudioContext.prototype.createBufferSource = function () {
|
||||
const source = originalCreateBufferSource.call(this)
|
||||
source.connect(this.createGain())
|
||||
return source
|
||||
}
|
||||
|
||||
AudioContext.prototype.createOscillator = function () {
|
||||
const oscillator = originalCreateOscillator.call(this)
|
||||
oscillator.connect(this.createGain())
|
||||
return oscillator
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function muteVideo(mediaElement) {
|
||||
let count = Number(mediaElement.getAttribute('data-modrinth-muted-count') ?? 0)
|
||||
|
||||
if (!mediaElement.muted || mediaElement.volume !== 0) {
|
||||
mediaElement.muted = true
|
||||
mediaElement.volume = 0
|
||||
|
||||
mediaElement.setAttribute('data-modrinth-muted-count', count + 1)
|
||||
}
|
||||
|
||||
if (count > 5) {
|
||||
// Video is detected as malicious, so it is removed from the page
|
||||
mediaElement.remove()
|
||||
}
|
||||
}
|
||||
|
||||
function muteVideos() {
|
||||
document.querySelectorAll('video, audio').forEach(function (mediaElement) {
|
||||
muteVideo(mediaElement)
|
||||
|
||||
if (!mediaElement.hasAttribute('data-modrinth-muted')) {
|
||||
mediaElement.addEventListener('volumechange', () => muteVideo(mediaElement))
|
||||
|
||||
mediaElement.setAttribute('data-modrinth-muted', 'true')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
installAdsConsentThemeStyle()
|
||||
installAdsRailStyle()
|
||||
installAdsConsentOverlayStyle()
|
||||
muteVideos()
|
||||
muteAudioContext()
|
||||
syncAdsConsentUi()
|
||||
installTcfConsentListener()
|
||||
installGppConsentListener()
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
muteVideos()
|
||||
syncAdsConsentUi()
|
||||
})
|
||||
observer.observe(document.body, { childList: true, subtree: true })
|
||||
})
|
||||
|
||||
syncAdsConsentUi()
|
||||
installTcfConsentListener()
|
||||
installGppConsentListener()
|
||||
+58
-65
@@ -12,6 +12,7 @@ pub struct AdsState {
|
||||
pub shown: bool,
|
||||
pub modal_shown: bool,
|
||||
pub consent_required: bool,
|
||||
pub consent_notification_enabled: bool,
|
||||
pub consent_overlay_shown: bool,
|
||||
pub occluded: bool,
|
||||
pub last_click: Option<Instant>,
|
||||
@@ -278,6 +279,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
shown: true,
|
||||
modal_shown: false,
|
||||
consent_required: false,
|
||||
consent_notification_enabled: false,
|
||||
consent_overlay_shown: false,
|
||||
occluded: false,
|
||||
last_click: None,
|
||||
@@ -361,12 +363,11 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
init_ads_window,
|
||||
hide_ads_window,
|
||||
show_ads_window,
|
||||
show_ads_consent_overlay,
|
||||
show_ads_consent_preferences,
|
||||
show_ads_consent_ui,
|
||||
expand_ads_consent_webview,
|
||||
open_ads_consent_preferences,
|
||||
hide_ads_consent_preferences,
|
||||
hide_ads_consent_overlay,
|
||||
get_ads_consent_required,
|
||||
finish_ads_consent_flow,
|
||||
should_show_ads_consent_popup,
|
||||
perform_ads_consent_action,
|
||||
record_ads_click,
|
||||
open_link,
|
||||
@@ -475,6 +476,19 @@ pub async fn init_ads_window<R: Runtime>(
|
||||
|
||||
Some(webview.clone())
|
||||
} else if let Some(window) = app.get_window("main") {
|
||||
let ads_consent_script = [
|
||||
"(() => {",
|
||||
include_str!("ads-consent/state.js"),
|
||||
include_str!("ads-consent/styles.js"),
|
||||
include_str!("ads-consent/bridge.js"),
|
||||
include_str!("ads-consent/cmp.js"),
|
||||
include_str!("ads-consent/media.js"),
|
||||
include_str!("ads-consent/controller.js"),
|
||||
include_str!("ads-consent/index.js"),
|
||||
"})()",
|
||||
]
|
||||
.join("\n");
|
||||
|
||||
#[cfg(windows)]
|
||||
let webview_url =
|
||||
WebviewUrl::External("about:blank".parse().unwrap());
|
||||
@@ -483,9 +497,7 @@ pub async fn init_ads_window<R: Runtime>(
|
||||
|
||||
let webview = window.add_child(
|
||||
tauri::webview::WebviewBuilder::new("ads-window", webview_url)
|
||||
.initialization_script_for_all_frames(include_str!(
|
||||
"ads-init.js"
|
||||
))
|
||||
.initialization_script_for_all_frames(ads_consent_script)
|
||||
// We use a standard Chrome user agent for compatibility with our ad provider,
|
||||
// since Tauri is not recognized by ad providers by default.
|
||||
// Aditude has separately informed SSPs and IVT vendors that this traffic
|
||||
@@ -644,7 +656,10 @@ pub async fn init_ads_window<R: Runtime>(
|
||||
// });
|
||||
}
|
||||
|
||||
if state.shown && state.consent_required {
|
||||
if state.shown
|
||||
&& state.consent_required
|
||||
&& state.consent_notification_enabled
|
||||
{
|
||||
app.emit_to("main", ADS_CONSENT_REQUIRED_EVENT, true).ok();
|
||||
}
|
||||
|
||||
@@ -661,7 +676,7 @@ pub async fn show_ads_window<R: Runtime>(
|
||||
app: tauri::AppHandle<R>,
|
||||
dpr: f32,
|
||||
) -> crate::api::Result<()> {
|
||||
let mut consent_required = false;
|
||||
let mut show_consent_notification = false;
|
||||
|
||||
if let Some(webview) = app.webviews().get("ads-window") {
|
||||
let state = app.state::<RwLock<AdsState>>();
|
||||
@@ -683,10 +698,12 @@ pub async fn show_ads_window<R: Runtime>(
|
||||
set_webview_visible_for_window(&app, webview, true);
|
||||
}
|
||||
|
||||
consent_required = state.shown && state.consent_required;
|
||||
show_consent_notification = state.shown
|
||||
&& state.consent_required
|
||||
&& state.consent_notification_enabled;
|
||||
}
|
||||
|
||||
if consent_required {
|
||||
if show_consent_notification {
|
||||
app.emit_to("main", ADS_CONSENT_REQUIRED_EVENT, true).ok();
|
||||
}
|
||||
|
||||
@@ -738,22 +755,24 @@ pub async fn hide_ads_window<R: Runtime>(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn show_ads_consent_overlay<R: Runtime>(
|
||||
pub async fn show_ads_consent_ui<R: Runtime>(
|
||||
app: tauri::AppHandle<R>,
|
||||
notification_enabled: bool,
|
||||
) -> crate::api::Result<()> {
|
||||
let mut show_notification = false;
|
||||
|
||||
if let Some(webview) = app.webviews().get("ads-window") {
|
||||
let state = app.state::<RwLock<AdsState>>();
|
||||
let mut state = state.write().await;
|
||||
|
||||
// dont show for hidden ads so consent events cannot re-enable the webview.
|
||||
if !state.shown {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Preserve pending consent while the sidebar is hidden, but keep all visibility
|
||||
// changes gated by `state.shown` so consent events cannot re-enable hidden ads.
|
||||
state.consent_required = true;
|
||||
state.consent_notification_enabled = notification_enabled;
|
||||
state.consent_overlay_shown = false;
|
||||
show_notification = state.shown && notification_enabled;
|
||||
|
||||
if !state.modal_shown {
|
||||
if state.shown && !state.modal_shown {
|
||||
let dpr = get_device_pixel_ratio(&app, None);
|
||||
let (position, size) = get_webview_position(&app, dpr)?;
|
||||
webview.set_size(size).ok();
|
||||
@@ -763,13 +782,14 @@ pub async fn show_ads_consent_overlay<R: Runtime>(
|
||||
}
|
||||
}
|
||||
|
||||
app.emit_to("main", ADS_CONSENT_REQUIRED_EVENT, true).ok();
|
||||
app.emit_to("main", ADS_CONSENT_REQUIRED_EVENT, show_notification)
|
||||
.ok();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn show_ads_consent_preferences<R: Runtime>(
|
||||
pub async fn expand_ads_consent_webview<R: Runtime>(
|
||||
app: tauri::AppHandle<R>,
|
||||
) -> crate::api::Result<()> {
|
||||
if let Some(webview) = app.webviews().get("ads-window") {
|
||||
@@ -807,53 +827,21 @@ pub async fn open_ads_consent_preferences<R: Runtime>(
|
||||
{
|
||||
let state = app.state::<RwLock<AdsState>>();
|
||||
let mut state = state.write().await;
|
||||
|
||||
if !state.consent_required {
|
||||
state.consent_notification_enabled = false;
|
||||
}
|
||||
state.consent_required = true;
|
||||
state.consent_overlay_shown = false;
|
||||
}
|
||||
|
||||
webview.eval("window.modrinthAdsReopenConsentPreferences?.()")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Restores the ad inventory bounds without resolving the pending consent request.
|
||||
#[tauri::command]
|
||||
pub async fn hide_ads_consent_preferences<R: Runtime>(
|
||||
app: tauri::AppHandle<R>,
|
||||
) -> crate::api::Result<()> {
|
||||
if let Some(webview) = app.webviews().get("ads-window") {
|
||||
let state = app.state::<RwLock<AdsState>>();
|
||||
let mut state = state.write().await;
|
||||
|
||||
state.consent_overlay_shown = false;
|
||||
|
||||
if state.shown && !state.modal_shown {
|
||||
let dpr = get_device_pixel_ratio(&app, None);
|
||||
let (position, size) = get_webview_position(&app, dpr)?;
|
||||
|
||||
webview
|
||||
.set_bounds(Rect {
|
||||
position: position.into(),
|
||||
size: size.into(),
|
||||
})
|
||||
.ok();
|
||||
webview.show().ok();
|
||||
set_webview_visible_for_window(&app, webview, true);
|
||||
} else {
|
||||
webview
|
||||
.set_position(PhysicalPosition::new(-1000, -1000))
|
||||
.ok();
|
||||
webview.hide().ok();
|
||||
}
|
||||
}
|
||||
|
||||
app.emit_to("main", ADS_CONSENT_REQUIRED_EVENT, true).ok();
|
||||
webview.eval("window.modrinthPrivacy?.adsReopenConsentPreferences?.()")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn hide_ads_consent_overlay<R: Runtime>(
|
||||
pub async fn finish_ads_consent_flow<R: Runtime>(
|
||||
app: tauri::AppHandle<R>,
|
||||
dpr: Option<f32>,
|
||||
) -> crate::api::Result<()> {
|
||||
@@ -863,6 +851,7 @@ pub async fn hide_ads_consent_overlay<R: Runtime>(
|
||||
let should_reload_ads = state.consent_required;
|
||||
|
||||
state.consent_required = false;
|
||||
state.consent_notification_enabled = false;
|
||||
state.consent_overlay_shown = false;
|
||||
|
||||
if state.shown && !state.modal_shown {
|
||||
@@ -893,13 +882,15 @@ pub async fn hide_ads_consent_overlay<R: Runtime>(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_ads_consent_required<R: Runtime>(
|
||||
pub async fn should_show_ads_consent_popup<R: Runtime>(
|
||||
app: tauri::AppHandle<R>,
|
||||
) -> crate::api::Result<bool> {
|
||||
let state = app.state::<RwLock<AdsState>>();
|
||||
let state = state.read().await;
|
||||
|
||||
Ok(state.shown && state.consent_required)
|
||||
Ok(state.shown
|
||||
&& state.consent_required
|
||||
&& state.consent_notification_enabled)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -908,16 +899,18 @@ pub async fn perform_ads_consent_action<R: Runtime>(
|
||||
action: String,
|
||||
) -> crate::api::Result<()> {
|
||||
let script = match action.as_str() {
|
||||
"accept" => "window.modrinthAdsConsentAction?.('accept')",
|
||||
"reject" => "window.modrinthAdsConsentAction?.('reject')",
|
||||
"manage" => "window.modrinthAdsConsentAction?.('manage')",
|
||||
"accept" => "window.modrinthPrivacy?.adsConsentAction?.('accept')",
|
||||
"reject" => "window.modrinthPrivacy?.adsConsentAction?.('reject')",
|
||||
"manage" => "window.modrinthPrivacy?.adsConsentAction?.('manage')",
|
||||
_ => return Ok(()),
|
||||
};
|
||||
|
||||
let state = app.state::<RwLock<AdsState>>();
|
||||
let should_perform = {
|
||||
let state = state.read().await;
|
||||
state.shown && state.consent_required
|
||||
state.shown
|
||||
&& state.consent_required
|
||||
&& state.consent_notification_enabled
|
||||
};
|
||||
|
||||
if !should_perform {
|
||||
|
||||
Reference in New Issue
Block a user