- Modrinth's ad provider, Aditude, shows ads based on your preferences. By disabling this - option, you opt out and ads will no longer be shown based on your interests. -
+
diff --git a/apps/app-frontend/src/helpers/ads.js b/apps/app-frontend/src/helpers/ads.js
index 46f1500efa..b434cc6716 100644
--- a/apps/app-frontend/src/helpers/ads.js
+++ b/apps/app-frontend/src/helpers/ads.js
@@ -1,4 +1,5 @@
import { invoke } from '@tauri-apps/api/core'
+import { listen } from '@tauri-apps/api/event'
export async function init_ads_window(overrideShown = false) {
return await invoke('plugin:ads|init_ads_window', {
@@ -15,6 +16,22 @@ 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 perform_ads_consent_action(action) {
+ return await invoke('plugin:ads|perform_ads_consent_action', { action })
+}
+
+export async function open_ads_consent_preferences() {
+ return await invoke('plugin:ads|open_ads_consent_preferences')
+}
+
+export async function ads_consent_listener(callback) {
+ return await listen('ads-consent-required', (event) => callback(event.payload))
+}
+
export async function record_ads_click() {
return await invoke('plugin:ads|record_ads_click')
}
diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json
index 7c481d1542..751a92db49 100644
--- a/apps/app-frontend/src/locales/en-US/index.json
+++ b/apps/app-frontend/src/locales/en-US/index.json
@@ -125,6 +125,21 @@
"app.action-bar.view-logs": {
"message": "View logs"
},
+ "app.ads-consent.accept": {
+ "message": "Accept all"
+ },
+ "app.ads-consent.body": {
+ "message": "Ads make Modrinth possible and fund creator payouts. Our partners may store or access cookies in the app to personalize ads and measure performance."
+ },
+ "app.ads-consent.manage": {
+ "message": "Manage preferences"
+ },
+ "app.ads-consent.reject": {
+ "message": "Reject all"
+ },
+ "app.ads-consent.title": {
+ "message": "Your privacy and how ads support Modrinth"
+ },
"app.appearance-settings.advanced-rendering.description": {
"message": "Enables advanced rendering such as blur effects that may cause performance issues without hardware-accelerated rendering."
},
@@ -500,6 +515,9 @@
"app.settings.downloading": {
"message": "Downloading v{version}"
},
+ "app.settings.privacy.ads-consent.intro": {
+ "message": "Ads make Modrinth possible and fund creator payouts. Our partners may store or access cookies in the app to personalize ads and measure performance. You can opt out or manage your preferences below."
+ },
"app.settings.tabs.appearance": {
"message": "Appearance"
},
diff --git a/apps/app/build.rs b/apps/app/build.rs
index da18e49769..777e944738 100644
--- a/apps/app/build.rs
+++ b/apps/app/build.rs
@@ -279,7 +279,12 @@ fn main() {
"scroll_ads_window",
"show_ads_window",
"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",
"record_ads_click",
"open_link",
"get_ads_personalization",
diff --git a/apps/app/src/api/ads-init.js b/apps/app/src/api/ads-init.js
index f75fd9a1ce..dacc41b6d9 100644
--- a/apps/app/src/api/ads-init.js
+++ b/apps/app/src/api/ads-init.js
@@ -6,6 +6,14 @@ document.addEventListener(
window.top.postMessage({ modrinthAdClick: true }, MODRINTH_ORIGIN)
let target = e.target
+ if (target?.closest?.('.qc-cmp2-close-icon')) {
+ if (modrinthAdsConsentReprompt) {
+ setTimeout(finishAdsConsentReprompt)
+ } else if (document.documentElement.classList.contains('modrinth-ads-consent-preferences')) {
+ setTimeout(() => void restoreAdsConsentNotification())
+ }
+ }
+
while (target != null) {
if (target.matches('a')) {
e.preventDefault()
@@ -27,6 +35,25 @@ window.open = (url, target, features) => {
let modrinthAdsConsentOverlayShown = false
let modrinthTcfListenerInstalled = false
let modrinthTcfListenerAttempts = 0
+let modrinthAdsConsentReprompt = false
+let modrinthAdsConsentRepromptManaging = false
+let modrinthAdsConsentActionRequestId = 0
+const modrinthAdsConsentActionResolvers = new Map()
+
+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 {
+ visibility: hidden !important;
+ pointer-events: none !important;
+ }
+ `
+ document.documentElement.appendChild(style)
+}
function installAdsConsentOverlayStyle() {
if (document.getElementById('modrinth-ads-consent-overlay-style')) {
@@ -35,8 +62,14 @@ function installAdsConsentOverlayStyle() {
const style = document.createElement('style')
style.id = 'modrinth-ads-consent-overlay-style'
style.textContent = `
- html.modrinth-ads-consent-overlay #modrinth-rail-1 {
+ html.modrinth-ads-consent-overlay:not(.modrinth-ads-consent-preferences) #modrinth-rail-1 {
+ visibility: hidden !important;
+ }
+
+ html.modrinth-ads-consent-overlay:not(.modrinth-ads-consent-preferences) #qc-cmp2-container,
+ html.modrinth-ads-consent-overlay:not(.modrinth-ads-consent-preferences) #qc-cmp2-main {
visibility: hidden !important;
+ pointer-events: none !important;
}
.qc-cmp2-close-icon {
@@ -63,12 +96,243 @@ function invokeAdsConsentOverlayCommand(shown) {
invoke(`plugin:ads|${command}`, args).catch(() => {})
}
+function revealAdsConsentPreferences() {
+ document.documentElement.classList.add('modrinth-ads-consent-preferences')
+ document.getElementById('modrinth-ads-consent-overlay-style')?.remove()
+}
+
+function concealAdsConsentPreferences() {
+ document.documentElement.classList.remove('modrinth-ads-consent-preferences')
+ 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 findAdsConsentButton(action) {
+ const container = document.querySelector('#qc-cmp2-container, #qc-cmp2-main')
+ if (!container) return null
+ const summaryButtons = Array.from(container.querySelectorAll('.qc-cmp2-summary-buttons button'))
+
+ if (action === 'accept') {
+ const explicitAcceptButton = container.querySelector('#accept-btn')
+ if (explicitAcceptButton) return explicitAcceptButton
+ if (summaryButtons.length >= 3) return summaryButtons[2]
+
+ return container.querySelector('.qc-cmp2-summary-buttons button[mode="primary"]')
+ }
+
+ if (action === 'reject') {
+ const explicitRejectButton = container.querySelector('#disagree-btn')
+ if (explicitRejectButton) return explicitRejectButton
+ if (summaryButtons.length >= 3) return summaryButtons[1]
+
+ const secondaryButtons = container.querySelectorAll(
+ '.qc-cmp2-summary-buttons button[mode="secondary"]',
+ )
+ if (secondaryButtons.length > 1) return secondaryButtons[1]
+
+ return summaryButtons.find(
+ (button) => button.textContent?.trim().toLowerCase() === 'reject all',
+ )
+ }
+
+ return (
+ container.querySelector(
+ '#more-options-btn, .qc-cmp2-summary-buttons > button[mode="secondary"]:first-of-type',
+ ) ?? summaryButtons[0]
+ )
+}
+
+function clickAdsConsentButtonWhenReady(action, timeoutMs, onButtonFound) {
+ const deadline = Date.now() + timeoutMs
+
+ return new Promise((resolve) => {
+ function tryClick() {
+ const button = findAdsConsentButton(action)
+ if (button) {
+ // CMP navigation can replace this document during the click, so acknowledge it first.
+ onButtonFound?.()
+ resolve(true)
+ button.click()
+ } else if (Date.now() >= deadline) {
+ resolve(false)
+ } else {
+ setTimeout(tryClick, 50)
+ }
+ }
+
+ tryClick()
+ })
+}
+
+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 })
+ clickAdsConsentButtonWhenReady(action, timeoutMs, () => settle(true)).then((clicked) => {
+ if (!clicked) settle(false)
+ })
+ })
+}
+
+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')
+ }
+}
+
+function finishAdsConsentReprompt() {
+ modrinthAdsConsentReprompt = false
+ modrinthAdsConsentRepromptManaging = false
+ modrinthAdsConsentOverlayShown = false
+ document.documentElement.classList.remove('modrinth-ads-consent-overlay')
+ concealAdsConsentPreferences()
+ sendAdsConsentCommandToChildFrames({ type: 'conceal' })
+ invokeAdsConsentOverlayCommand(false)
+}
+
+async function openAdsConsentPreferences() {
+ revealAdsConsentPreferences()
+ sendAdsConsentCommandToChildFrames({ type: 'reveal' })
+ await expandAdsConsentWebview()
+ await waitForAdsConsentLayout()
+
+ window.dispatchEvent(new Event('resize'))
+ sendAdsConsentCommandToChildFrames({ type: 'resize' })
+
+ const clicked = await performAdsConsentActionAcrossFrames('manage', 2500)
+ if (!clicked) {
+ await restoreAdsConsentNotification()
+ }
+}
+
+async function performAdsConsentAction(action) {
+ if (!['accept', 'reject', 'manage'].includes(action)) return
+
+ if (action === 'manage') {
+ try {
+ await openAdsConsentPreferences()
+ } catch {
+ await restoreAdsConsentNotification()
+ }
+ return
+ }
+
+ await performAdsConsentActionAcrossFrames(action, 1000)
+}
+
+window.modrinthAdsConsentAction = (action) => {
+ void performAdsConsentAction(action)
+}
+
+window.modrinthAdsReopenConsentPreferences = async () => {
+ modrinthAdsConsentReprompt = true
+ modrinthAdsConsentRepromptManaging = false
+ revealAdsConsentPreferences()
+ sendAdsConsentCommandToChildFrames({ type: 'reveal' })
+
+ try {
+ await expandAdsConsentWebview()
+ await waitForAdsConsentLayout()
+ window.dispatchEvent(new Event('resize'))
+ sendAdsConsentCommandToChildFrames({ type: 'resize' })
+
+ if (typeof window.__tcfapi === 'function') {
+ window.__tcfapi('displayConsentUi', 2, () => {})
+ } else {
+ finishAdsConsentReprompt()
+ }
+ } 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 === '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)
+ clickAdsConsentButtonWhenReady(command.action, command.timeoutMs, () => {
+ window.parent.postMessage({ modrinthAdsConsentResult: command.requestId }, '*')
+ })
+ }
+})
+
function setAdsConsentOverlay(shown) {
if (modrinthAdsConsentOverlayShown === shown) return
modrinthAdsConsentOverlayShown = shown
installAdsConsentOverlayStyle()
document.documentElement.classList.toggle('modrinth-ads-consent-overlay', shown)
+ if (!shown) {
+ document.documentElement.classList.remove('modrinth-ads-consent-preferences')
+ }
if (window.top === window) {
invokeAdsConsentOverlayCommand(shown)
@@ -92,8 +356,23 @@ function handleTcfConsentEvent(tcData, success) {
if (!success || !tcData) return
if (tcData.eventStatus === 'cmpuishown') {
+ if (modrinthAdsConsentReprompt) {
+ if (!modrinthAdsConsentRepromptManaging) {
+ modrinthAdsConsentRepromptManaging = true
+ window.dispatchEvent(new Event('resize'))
+ sendAdsConsentCommandToChildFrames({ type: 'resize' })
+ void performAdsConsentActionAcrossFrames('manage', 2500)
+ }
+ return
+ }
+
setAdsConsentOverlay(true)
- } else if (tcData.eventStatus === 'useractioncomplete' || tcData.eventStatus === 'tcloaded') {
+ } else if (tcData.eventStatus === 'useractioncomplete') {
+ if (modrinthAdsConsentReprompt) {
+ finishAdsConsentReprompt()
+ return
+ }
+
setAdsConsentOverlay(false)
}
}
@@ -190,6 +469,7 @@ function muteVideos() {
}
document.addEventListener('DOMContentLoaded', () => {
+ installAdsRailStyle()
installAdsConsentOverlayStyle()
muteVideos()
muteAudioContext()
diff --git a/apps/app/src/api/ads.rs b/apps/app/src/api/ads.rs
index ba06283d0b..2f0887de27 100644
--- a/apps/app/src/api/ads.rs
+++ b/apps/app/src/api/ads.rs
@@ -3,7 +3,7 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use tauri::plugin::TauriPlugin;
-use tauri::{Manager, PhysicalPosition, PhysicalSize, Runtime};
+use tauri::{Emitter, Manager, PhysicalPosition, PhysicalSize, Rect, Runtime};
use tauri_plugin_opener::OpenerExt;
use theseus::settings;
use tokio::sync::RwLock;
@@ -11,6 +11,7 @@ use tokio::sync::RwLock;
pub struct AdsState {
pub shown: bool,
pub modal_shown: bool,
+ pub consent_required: bool,
pub consent_overlay_shown: bool,
pub occluded: bool,
pub last_click: Option