diff --git a/apps/app/build.rs b/apps/app/build.rs index 777e944738..e42347181c 100644 --- a/apps/app/build.rs +++ b/apps/app/build.rs @@ -281,7 +281,6 @@ fn main() { "show_ads_consent_overlay", "show_ads_consent_preferences", "open_ads_consent_preferences", - "hide_ads_consent_preferences", "hide_ads_consent_overlay", "get_ads_consent_required", "perform_ads_consent_action", diff --git a/apps/app/src/api/ads-consent/bridge.js b/apps/app/src/api/ads-consent/bridge.js new file mode 100644 index 0000000000..3c5b576e4b --- /dev/null +++ b/apps/app/src/api/ads-consent/bridge.js @@ -0,0 +1,51 @@ +const MODRINTH_ORIGIN = 'https://modrinth.com' + +/** + * @typedef {'custom' | 'fallback' | 'hidden'} AdsConsentPopupMode + * + * @typedef {(command: string, args?: Record) => Promise} 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 {void} + */ +function invokeAdsConsentPopupMode(mode) { + const invoke = getTauriInvoke() + if (!invoke) return + + const show = mode !== 'hidden' + const command = show ? 'show_ads_consent_overlay' : 'hide_ads_consent_overlay' + const args = + mode === 'hidden' + ? { dpr: window.devicePixelRatio } + : { notificationEnabled: mode === 'custom' } + void invoke(`plugin:ads|${command}`, args).catch(() => {}) +} + +/** @returns {Promise} */ +async function expandAdsConsentWebview() { + const invoke = getTauriInvoke() + if (!invoke) throw new Error('Tauri invoke is unavailable in the ads webview') + + await invoke('plugin:ads|show_ads_consent_preferences') +} diff --git a/apps/app/src/api/ads-consent/cmp.js b/apps/app/src/api/ads-consent/cmp.js new file mode 100644 index 0000000000..ca3fbcc043 --- /dev/null +++ b/apps/app/src/api/ads-consent/cmp.js @@ -0,0 +1,293 @@ +/** + * @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>} */ +const ACTION_BUTTON_IDS = { + accept: 'accept-btn', + reject: 'disagree-btn', + manage: 'more-options-btn', +} + +/** @type {Readonly>} */ +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} */ ( + 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} + */ +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} + */ +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 + * @returns {Promise} + */ +async function performDocumentConsentAction(action, variant) { + 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' + + settledControls.confirmButton.click() + return 'handled' + } + + if (variant === 'tcf') { + const button = findTcfConsentButton(action) + if (!button) return 'not-ready' + + 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} */ +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) + } + }) +} diff --git a/apps/app/src/api/ads-consent/controller.js b/apps/app/src/api/ads-consent/controller.js new file mode 100644 index 0000000000..db4a2ca431 --- /dev/null +++ b/apps/app/src/api/ads-consent/controller.js @@ -0,0 +1,217 @@ +const ACTION_TIMEOUT = 10_000 +const LAYOUT_DELAY = 100 + +class AdsConsentController { + constructor() { + /** @type {AdsConsentState} */ + this.state = new AdsConsentState() + } + + /** @returns {void} */ + syncConsentPopup() { + 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') { + this.finishConsentFlow() + } + return + } + + 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.setPopupMode('custom') + } + + /** + * Sends the clicks from custom privacy popup to native popup buttons + * @param {ConsentAction} action + * @returns {Promise} + */ + 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) + if (result !== 'handled') this.showNativeCmpFallback() + } catch { + this.showNativeCmpFallback() + } + } + + /** @returns {Promise} */ + async reopenPreferences() { + if (document.documentElement.classList.contains('modrinth-ads-consent-overlay')) { + try { + await this.openPreferences() + } catch { + this.showNativeCmpFallback() + } + return + } + + this.state.setState('reopened') + 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(ACTION_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} + */ + 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, 50)) + } + } + + /** @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} */ + waitForLayout() { + return new Promise((resolve) => setTimeout(resolve, LAYOUT_DELAY)) + } + + /** @returns {Promise} */ + async showExpandedUi() { + this.preparePreferences() + await this.waitForLayout() + await expandAdsConsentWebview() + await this.waitForLayout() + this.revealPreferences() + window.dispatchEvent(new Event('resize')) + } + + /** @returns {Promise} */ + async openPreferences() { + await this.showExpandedUi() + + if (!(await this.openConsentManagerWhenReady(ACTION_TIMEOUT))) { + this.showNativeCmpFallback() + return false + } + + return true + } + + /** @returns {Promise} */ + 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() + 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 === 'complete') { + return + } + + this.state.setState('complete') + this.setPopupMode('hidden') + } +} diff --git a/apps/app/src/api/ads-consent/index.js b/apps/app/src/api/ads-consent/index.js new file mode 100644 index 0000000000..38ea502407 --- /dev/null +++ b/apps/app/src/api/ads-consent/index.js @@ -0,0 +1,50 @@ +const controller = new AdsConsentController() + +function isTopFrame() { + return window.top === window +} + +function initializeTopFrame() { + if (!isTopFrame()) return + + installConsentStyles() + controller.syncConsentPopup() +} + +document.addEventListener( + 'click', + (event) => { + notifyAdClick() + + const target = event.target instanceof Element ? event.target : null + 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() diff --git a/apps/app/src/api/ads-consent/media.js b/apps/app/src/api/ads-consent/media.js new file mode 100644 index 0000000000..8135482709 --- /dev/null +++ b/apps/app/src/api/ads-consent/media.js @@ -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' + } + }) +} diff --git a/apps/app/src/api/ads-consent/state.js b/apps/app/src/api/ads-consent/state.js new file mode 100644 index 0000000000..90b0b58c60 --- /dev/null +++ b/apps/app/src/api/ads-consent/state.js @@ -0,0 +1,24 @@ +/** + * @typedef {'idle' | 'initial' | 'reopened' | 'complete'} 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 + } +} diff --git a/apps/app/src/api/ads-consent/styles.js b/apps/app/src/api/ads-consent/styles.js new file mode 100644 index 0000000000..31f4602e02 --- /dev/null +++ b/apps/app/src/api/ads-consent/styles.js @@ -0,0 +1,159 @@ +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 { + 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) +} diff --git a/apps/app/src/api/ads-init.js b/apps/app/src/api/ads-init.js deleted file mode 100644 index c21c40310c..0000000000 --- a/apps/app/src/api/ads-init.js +++ /dev/null @@ -1,879 +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 - -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 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) { - const deadline = Date.now() + timeoutMs - - return new Promise((resolve) => { - let settled = false - const settle = (handled) => { - if (settled) return - settled = true - resolve(handled) - } - - async function tryAction() { - const result = await performAdsConsentActionInDocument(action, () => settle(true)) - if (result === 'handled') { - settle(true) - } else if (result === 'failed' || Date.now() >= deadline) { - settle(false) - } else { - setTimeout(tryAction, 50) - } - } - - void tryAction() - }) -} - -function waitForAdsConsentLayout() { - return new Promise((resolve) => setTimeout(resolve, 100)) -} - -async function restoreAdsConsentNotification() { - concealAdsConsentPreferences() - - const invoke = getTauriInvoke() - if (typeof invoke === 'function') { - await invoke('plugin:ads|hide_ads_consent_preferences') - } -} - -async function showNativeAdsConsentUi() { - prepareAdsConsentPreferences() - await waitForAdsConsentLayout() - await expandAdsConsentWebview() - await waitForAdsConsentLayout() - revealAdsConsentPreferences() - - window.dispatchEvent(new Event('resize')) - - const shown = await performAdsConsentActionWhenReady('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() - invokeAdsConsentOverlayCommand(false) -} - -async function openAdsConsentPreferences() { - if (!(await showNativeAdsConsentUi())) return - - await performAdsConsentActionWhenReady('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 performAdsConsentActionWhenReady(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() - - try { - await waitForAdsConsentLayout() - await expandAdsConsentWebview() - await waitForAdsConsentLayout() - revealAdsConsentPreferences() - window.dispatchEvent(new Event('resize')) - - if (!(await displayAdsConsentReprompt())) { - finishAdsConsentReprompt() - return - } - - if (!(await performAdsConsentActionWhenReady('show', 2500))) { - finishAdsConsentReprompt() - return - } - - await performAdsConsentActionWhenReady('manage', 2500) - } catch { - finishAdsConsentReprompt() - } -} - -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') - } - - invokeAdsConsentOverlayCommand(shown) -} - -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() { - detectAdsConsentVariant() - - 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', () => { - muteVideos() - muteAudioContext() - - if (window.top === window) { - installAdsConsentThemeStyle() - installAdsRailStyle() - installAdsConsentOverlayStyle() - syncAdsConsentUi() - installTcfConsentListener() - installGppConsentListener() - } - - const observer = new MutationObserver(() => { - muteVideos() - if (window.top === window) { - syncAdsConsentUi() - } - }) - observer.observe(document.body, { childList: true, subtree: true }) -}) - -if (window.top === window) { - syncAdsConsentUi() - installTcfConsentListener() - installGppConsentListener() -} diff --git a/apps/app/src/api/ads.rs b/apps/app/src/api/ads.rs index 588f0319c8..3f02331d07 100644 --- a/apps/app/src/api/ads.rs +++ b/apps/app/src/api/ads.rs @@ -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, @@ -278,6 +279,7 @@ pub fn init() -> TauriPlugin { shown: true, modal_shown: false, consent_required: false, + consent_notification_enabled: false, consent_overlay_shown: false, occluded: false, last_click: None, @@ -364,7 +366,6 @@ pub fn init() -> TauriPlugin { show_ads_consent_overlay, show_ads_consent_preferences, open_ads_consent_preferences, - hide_ads_consent_preferences, hide_ads_consent_overlay, get_ads_consent_required, perform_ads_consent_action, @@ -475,6 +476,19 @@ pub async fn init_ads_window( 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( 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( // }); } - 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( app: tauri::AppHandle, 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::>(); @@ -683,10 +698,12 @@ pub async fn show_ads_window( 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(); } @@ -740,30 +757,34 @@ pub async fn hide_ads_window( #[tauri::command] pub async fn show_ads_consent_overlay( app: tauri::AppHandle, + notification_enabled: bool, ) -> crate::api::Result<()> { + let mut show_notification = false; + if let Some(webview) = app.webviews().get("ads-window") { let state = app.state::>(); let mut state = state.write().await; // dont show for hidden ads so consent events cannot re-enable the webview. - if !state.shown { - return Ok(()); - } + if state.shown { + state.consent_required = true; + state.consent_notification_enabled = notification_enabled; + state.consent_overlay_shown = false; + show_notification = notification_enabled; - state.consent_required = true; - state.consent_overlay_shown = false; - - if !state.modal_shown { - let dpr = get_device_pixel_ratio(&app, None); - let (position, size) = get_webview_position(&app, dpr)?; - webview.set_size(size).ok(); - webview.set_position(position).ok(); - webview.show().ok(); - set_webview_visible_for_window(&app, webview, true); + if !state.modal_shown { + let dpr = get_device_pixel_ratio(&app, None); + let (position, size) = get_webview_position(&app, dpr)?; + webview.set_size(size).ok(); + webview.set_position(position).ok(); + webview.show().ok(); + set_webview_visible_for_window(&app, webview, true); + } } } - app.emit_to("main", ADS_CONSENT_REQUIRED_EVENT, true).ok(); + app.emit_to("main", ADS_CONSENT_REQUIRED_EVENT, show_notification) + .ok(); Ok(()) } @@ -807,47 +828,15 @@ pub async fn open_ads_consent_preferences( { let state = app.state::>(); 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( - app: tauri::AppHandle, -) -> crate::api::Result<()> { - if let Some(webview) = app.webviews().get("ads-window") { - let state = app.state::>(); - 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(()) } @@ -863,6 +852,7 @@ pub async fn hide_ads_consent_overlay( 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 { @@ -899,7 +889,11 @@ pub async fn get_ads_consent_required( let state = app.state::>(); 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 +902,18 @@ pub async fn perform_ads_consent_action( 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::>(); 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 {