use std::collections::HashSet; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, Instant}; use tauri::plugin::TauriPlugin; use tauri::{Emitter, Manager, PhysicalPosition, PhysicalSize, Runtime}; use tauri_plugin_opener::OpenerExt; use theseus::settings; use tokio::sync::RwLock; pub struct AdsState { pub shown: bool, pub visibility_holds: usize, pub consent_required: bool, pub consent_notification_enabled: bool, pub consent_overlay_shown: bool, pub occluded: bool, pub last_click: Option, pub malicious_origins: HashSet, } const AD_LINK: &str = "https://modrinth.com/wrapper/app-ads-cookie"; const ADS_CONSENT_REQUIRED_EVENT: &str = "ads-consent-required"; const APP_TITLE_BAR_HEIGHT: f32 = 48.0; #[cfg(any(windows, target_os = "macos"))] pub(super) const OCCLUDED_AREA_THRESHOLD: f64 = 0.5; fn should_show_ads_webview(state: &AdsState) -> bool { state.shown && (state.visibility_holds == 0 || state.consent_overlay_shown) } #[cfg(not(target_os = "linux"))] const ADS_USER_AGENT: &str = concat!( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ", "(KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 ", "ModrinthApp/", env!("CARGO_PKG_VERSION"), " (Modrinth App)", ); #[cfg(windows)] fn ads_user_agent_override_params() -> String { serde_json::json!({ "userAgent": ADS_USER_AGENT, "platform": "Win32", "userAgentMetadata": { "brands": [ { "brand": "Chromium", "version": "128" }, { "brand": "Google Chrome", "version": "128" }, { "brand": "Modrinth App", "version": env!("CARGO_PKG_VERSION") }, { "brand": "Not=A?Brand", "version": "99" }, ], "fullVersion": "128.0.0.0", "fullVersionList": [ { "brand": "Chromium", "version": "128.0.0.0" }, { "brand": "Google Chrome", "version": "128.0.0.0" }, { "brand": "Modrinth App", "version": env!("CARGO_PKG_VERSION") }, { "brand": "Not=A?Brand", "version": "99.0.0.0" }, ], "platform": "Windows", "platformVersion": "10.0.0", "architecture": "x86", "bitness": "64", "model": "", "mobile": false, }, }) .to_string() } #[cfg(windows)] fn configure_ads_cookie_settings( core_webview2: &webview2_com::Microsoft::Web::WebView2::Win32::ICoreWebView2, ) { use webview2_com::Microsoft::Web::WebView2::Win32::{ COREWEBVIEW2_TRACKING_PREVENTION_LEVEL_NONE, ICoreWebView2_13, ICoreWebView2Profile3, }; use windows_core::Interface; match core_webview2 .cast::() .and_then(|core_webview2| unsafe { core_webview2.Profile() }) .and_then(|profile| profile.cast::()) { Ok(profile) => { if let Err(error) = unsafe { profile.SetPreferredTrackingPreventionLevel( COREWEBVIEW2_TRACKING_PREVENTION_LEVEL_NONE, ) } { tracing::warn!( ?error, "Failed to disable ads WebView2 tracking prevention" ); } } Err(error) => { tracing::warn!( ?error, "Failed to access ads WebView2 profile tracking prevention settings" ); } } } fn set_webview_visible(webview: &tauri::Webview, visible: bool) { #[cfg(not(any(windows, target_os = "macos")))] { _ = visible; } webview .with_webview( #[allow(unused_variables)] move |wv| { #[cfg(windows)] { let controller = wv.controller(); unsafe { controller.SetIsVisible(visible) }.ok(); } }, ) .ok(); #[cfg(target_os = "macos")] if visible { webview.show().ok(); } else { webview.hide().ok(); } } #[cfg(any(windows, target_os = "macos"))] fn compute_ads_webview_occlusion( app: &tauri::AppHandle, ) -> Option { let main_window = app.get_window("main")?; let webviews = app.webviews(); let webview = webviews.get("ads-window")?; #[cfg(target_os = "macos")] { let position = webview.position().ok()?; let size = webview.size().ok()?; Some( crate::api::ads_occlusion_macos::is_ads_webview_occluded( &main_window, position.x, position.y, size.width, size.height, ) .unwrap_or(false), ) } #[cfg(windows)] { let position = webview.position().ok()?; let size = webview.size().ok()?; let hwnd = main_window.hwnd().ok()?; Some(crate::api::ads_occlusion_windows::is_ads_webview_occluded( hwnd, position.x, position.y, size.width, size.height, )) } } #[cfg(any(windows, target_os = "macos"))] async fn sync_ads_occlusion(app: &tauri::AppHandle) { let Some(occluded) = compute_ads_webview_occlusion(app) else { return; }; let state = app.state::>(); let mut state = state.write().await; if state.occluded == occluded { return; } state.occluded = occluded; let visible = should_show_ads_webview(&state); let consent_overlay_shown = state.consent_overlay_shown; drop(state); if let Some(webview) = app.webviews().get("ads-window") { let is_minimized = app .get_window("main") .and_then(|window| window.is_minimized().ok()) .unwrap_or(false); set_webview_visible( webview, visible && !is_minimized && (!occluded || consent_overlay_shown), ); } } fn sync_webview_visibility_for_main_window( app: &tauri::AppHandle, main_window: &tauri::Window, was_minimized: &AtomicBool, ) { let is_minimized = main_window.is_minimized().unwrap_or(false); let was = was_minimized.load(Ordering::SeqCst); let ads_state = if is_minimized { None } else { match app.state::>().try_read() { Ok(state) => Some(( should_show_ads_webview(&state) && (!state.occluded || state.consent_overlay_shown), state.consent_overlay_shown, )), Err(_) => None, } }; let ads_visible = ads_state.map(|state| state.0).unwrap_or(false); if ads_state.map(|state| state.1).unwrap_or(false) && let Some(webview) = app.webviews().get("ads-window") && let Ok((position, size)) = get_overlay_webview_position_for_window(main_window) { webview.set_position(position).ok(); webview.set_size(size).ok(); } if is_minimized == was { return; } was_minimized.store(is_minimized, Ordering::SeqCst); let mut webviews = Vec::new(); let mut seen_webviews = HashSet::new(); for webview in main_window.webviews() { seen_webviews.insert(webview.label().to_string()); webviews.push(webview); } for webview in app.webviews().into_values() { if seen_webviews.insert(webview.label().to_string()) { webviews.push(webview); } } for webview in webviews { let visible = !is_minimized && (webview.label() != "ads-window" || ads_visible); set_webview_visible(&webview, visible); } } pub fn init() -> TauriPlugin { tauri::plugin::Builder::::new("ads") .setup(|app, _api| { app.manage(RwLock::new(AdsState { shown: true, visibility_holds: 0, consent_required: false, consent_notification_enabled: false, consent_overlay_shown: false, occluded: false, last_click: None, malicious_origins: HashSet::new(), })); // We refresh the ads window periodically to mitigate memory leak issues. // Skip refreshes when app state has hidden the ads WebView. The refresh does // not reset the visibility state. let refresh_app = app.clone(); tauri::async_runtime::spawn(async move { loop { let should_refresh = refresh_app .state::>() .try_read() .map(|state| { state.shown && state.visibility_holds == 0 && !state.consent_required && !state.consent_overlay_shown && !state.occluded }) .unwrap_or(false); if should_refresh && let Some(webview) = refresh_app.webviews().get_mut("ads-window") { let _ = webview.navigate(AD_LINK.parse().unwrap()); } tokio::time::sleep(std::time::Duration::from_secs(60 * 5)) .await; } }); if let Some(main_window) = app.get_window("main") { let app_handle = app.clone(); let event_window = main_window.clone(); let was_minimized = Arc::new(AtomicBool::new(false)); main_window.on_window_event(move |_| { sync_webview_visibility_for_main_window( &app_handle, &event_window, &was_minimized, ); let delayed_app_handle = app_handle.clone(); let delayed_event_window = event_window.clone(); let delayed_was_minimized = was_minimized.clone(); tauri::async_runtime::spawn(async move { tokio::time::sleep(Duration::from_millis(100)).await; sync_webview_visibility_for_main_window( &delayed_app_handle, &delayed_event_window, &delayed_was_minimized, ); }); }); } #[cfg(any(windows, target_os = "macos"))] { let app_handle = app.clone(); tauri::async_runtime::spawn(async move { loop { sync_ads_occlusion(&app_handle).await; tokio::time::sleep(Duration::from_millis(200)).await; } }); } Ok(()) }) .invoke_handler(tauri::generate_handler![ init_ads_window, hide_ads_window, update_ads_window_hold, show_ads_consent_ui, expand_ads_consent_webview, open_ads_consent_preferences, finish_ads_consent_flow, should_show_ads_consent_popup, perform_ads_consent_action, record_ads_click, open_link, get_ads_personalization, ]) .build() } fn get_webview_position( app: &tauri::AppHandle, dpr: f32, ) -> crate::api::Result<(PhysicalPosition, PhysicalSize)> { let main_window = app.get_window("main").unwrap(); let width = 300.0 * dpr; let height = 250.0 * dpr; let main_window_size = main_window.outer_size()?; let x = (main_window_size.width as f32) - width; let y = (main_window_size.height as f32) - height; Ok(( PhysicalPosition::new(x, y), PhysicalSize::new(width, height), )) } fn get_overlay_webview_position_for_window( main_window: &tauri::Window, ) -> crate::api::Result<(PhysicalPosition, PhysicalSize)> { let main_window_size = main_window.outer_size()?; let title_bar_height = APP_TITLE_BAR_HEIGHT * main_window.scale_factor()? as f32; Ok(( PhysicalPosition::new(0.0, title_bar_height), PhysicalSize::new( main_window_size.width as f32, (main_window_size.height as f32 - title_bar_height).max(0.0), ), )) } fn get_overlay_webview_position( app: &tauri::AppHandle, ) -> crate::api::Result<(PhysicalPosition, PhysicalSize)> { let main_window = app.get_window("main").unwrap(); get_overlay_webview_position_for_window(&main_window) } fn get_device_pixel_ratio( app: &tauri::AppHandle, dpr: Option, ) -> f32 { dpr.unwrap_or_else(|| { app.get_window("main") .and_then(|window| window.scale_factor().ok()) .map(|scale_factor| scale_factor as f32) .unwrap_or(1.0) }) } fn sync_ads_webview_visibility( app: &tauri::AppHandle, state: &AdsState, dpr: f32, ) -> crate::api::Result<()> { let webviews = app.webviews(); let Some(webview) = webviews.get("ads-window") else { return Ok(()); }; if should_show_ads_webview(state) { let (position, size) = if state.consent_overlay_shown { get_overlay_webview_position(app)? } else { get_webview_position(app, dpr)? }; let is_minimized = app .get_window("main") .and_then(|window| window.is_minimized().ok()) .unwrap_or(false); webview.set_size(size).ok(); webview.set_position(position).ok(); webview.show().ok(); set_webview_visible( webview, !is_minimized && (!state.occluded || state.consent_overlay_shown), ); } else { webview .set_position(PhysicalPosition::new(-1000, -1000)) .ok(); webview.hide().ok(); set_webview_visible(webview, false); } Ok(()) } #[tauri::command] #[cfg(not(target_os = "linux"))] pub async fn init_ads_window( app: tauri::AppHandle, dpr: f32, override_shown: bool, ) -> crate::api::Result<()> { use tauri::WebviewUrl; let state = app.state::>(); let mut state = state.write().await; if override_shown { state.shown = true; } let layout = if state.consent_overlay_shown { get_overlay_webview_position(&app) } else { get_webview_position(&app, dpr) }; if let Ok((position, size)) = layout { let webview = if let Some(webview) = app.webviews().get("ads-window") { sync_ads_webview_visibility(&app, &state, dpr)?; 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()); #[cfg(not(windows))] let webview_url = WebviewUrl::External(AD_LINK.parse().unwrap()); let webview = window.add_child( tauri::webview::WebviewBuilder::new("ads-window", webview_url) .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 // originates from a desktop app. .user_agent(ADS_USER_AGENT) .zoom_hotkeys_enabled(false) .transparent(true) .on_new_window(|_, _| { tauri::webview::NewWindowResponse::Deny }), // set both the `hide`/`show` state and `position`, // to ensure that the webview is actually shown/hidden if should_show_ads_webview(&state) { position } else { PhysicalPosition::new(-1000.0, -1000.0) }, size, )?; sync_ads_webview_visibility(&app, &state, dpr)?; webview.with_webview(#[allow(unused_variables)] |webview2| { #[cfg(windows)] { use webview2_com::CallDevToolsProtocolMethodCompletedHandler; use webview2_com::Microsoft::Web::WebView2::Win32::ICoreWebView2_8; use windows_core::Interface; use windows_core::HSTRING; let core_webview2 = unsafe { webview2.controller().CoreWebView2() }; if let Ok(core_webview2) = core_webview2 { configure_ads_cookie_settings(&core_webview2); let navigate_webview = core_webview2.clone(); let handler = CallDevToolsProtocolMethodCompletedHandler::create( Box::new(move |result: windows_core::Result<()>, _| { if let Err(error) = result { tracing::error!( ?error, "Failed to override ads user-agent client hints" ); } unsafe { navigate_webview .Navigate(&HSTRING::from(AD_LINK)) .ok(); } Ok(()) }) as Box<_>, ); unsafe { if let Err(error) = core_webview2 .CallDevToolsProtocolMethod( &HSTRING::from( "Emulation.setUserAgentOverride", ), &HSTRING::from( ads_user_agent_override_params(), ), &handler, ) { tracing::error!( ?error, "Failed to install ads user-agent client hints override" ); core_webview2.Navigate(&HSTRING::from(AD_LINK)).ok(); } } } let webview2_controller = webview2.controller(); let Ok(webview2_8) = unsafe { webview2_controller.CoreWebView2() } .and_then(|core_webview2| core_webview2.cast::()) else { return; }; unsafe { webview2_8.SetIsMuted(true) }.ok(); } })?; Some(webview) } else { None }; if webview.is_none() { return Ok(()); } // tauri::async_runtime::spawn(async move { // loop { // webview.with_webview(|wv| { // #[cfg(windows)] // { // use webview2_com::ExecuteScriptCompletedHandler; // let core_webview2 = unsafe { // webview.controller().CoreWebView2().unwrap() // }; // let handler = ExecuteScriptCompletedHandler::create(Box::new( // move |hr: windows_core::Result<()>, result: String| { // if hr.is_ok() { // let hidden: bool = serde_json::from_str(&result).unwrap_or(true); // tracing::error!("!! ads wv hidden? {}", hidden); // } // Ok(()) // }, // ) as Box<_>); // unsafe { // let _ = core_webview2.ExecuteScript( // windows_core::w!("document.hidden"), // &handler, // ); // } // } // #[cfg(not(windows))] // { // use webkit2gtk::WebViewExt; // wv.inner().evaluate_javascript( // "document.hidden", // None, // None, // None::<&webkit2gtk::gio::Cancellable>, // |result| { // use javascriptcore::ValueExt; // let hidden = result.map(|v| v.to_boolean()); // tracing::error!("!! ads wv hidden? {hidden:?}"); // }, // ); // } // }); // tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; // } // }); } if state.shown && state.consent_required && state.consent_notification_enabled { app.emit_to("main", ADS_CONSENT_REQUIRED_EVENT, true).ok(); } Ok(()) } // TODO: make ads work on linux #[tauri::command] #[cfg(target_os = "linux")] pub async fn init_ads_window() {} #[tauri::command] pub async fn update_ads_window_hold( app: tauri::AppHandle, acquire: bool, dpr: f32, ) -> crate::api::Result<()> { let state = app.state::>(); let mut state = state.write().await; if acquire { state.visibility_holds = state.visibility_holds.saturating_add(1); } else if state.visibility_holds > 0 { state.visibility_holds -= 1; } else { tracing::warn!( "Attempted to release an ads window hold when none were active" ); } sync_ads_webview_visibility(&app, &state, dpr)?; if !acquire && state.visibility_holds == 0 && state.shown && state.consent_required && state.consent_notification_enabled { app.emit_to("main", ADS_CONSENT_REQUIRED_EVENT, true).ok(); } Ok(()) } #[tauri::command] pub async fn hide_ads_window( app: tauri::AppHandle, reset: Option, ) -> crate::api::Result<()> { let reset = reset.unwrap_or(false); let state = app.state::>(); let mut state = state.write().await; if reset { state.shown = false; state.consent_overlay_shown = false; sync_ads_webview_visibility( &app, &state, get_device_pixel_ratio(&app, None), )?; } if reset { app.emit_to("main", ADS_CONSENT_REQUIRED_EVENT, false).ok(); } Ok(()) } #[tauri::command] pub async fn show_ads_consent_ui( app: tauri::AppHandle, notification_enabled: bool, ) -> crate::api::Result<()> { let mut show_notification = false; if app.webviews().contains_key("ads-window") { let state = app.state::>(); let mut state = state.write().await; // 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; sync_ads_webview_visibility( &app, &state, get_device_pixel_ratio(&app, None), )?; } app.emit_to("main", ADS_CONSENT_REQUIRED_EVENT, show_notification) .ok(); Ok(()) } #[tauri::command] pub async fn expand_ads_consent_webview( app: tauri::AppHandle, ) -> crate::api::Result<()> { if app.webviews().contains_key("ads-window") { let state = app.state::>(); let mut state = state.write().await; if !state.consent_required { return Ok(()); } state.consent_overlay_shown = true; sync_ads_webview_visibility( &app, &state, get_device_pixel_ratio(&app, None), )?; } Ok(()) } #[tauri::command] pub async fn open_ads_consent_preferences( app: tauri::AppHandle, ) -> crate::api::Result<()> { let Some(webview) = app.webviews().get("ads-window").cloned() else { return Ok(()); }; { 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.modrinthPrivacy?.adsReopenConsentPreferences?.()")?; Ok(()) } #[tauri::command] pub async fn finish_ads_consent_flow( app: tauri::AppHandle, dpr: Option, ) -> crate::api::Result<()> { if let Some(webview) = app.webviews().get("ads-window") { let state = app.state::>(); let mut state = state.write().await; let should_reload_ads = state.consent_required; state.consent_required = false; state.consent_notification_enabled = false; state.consent_overlay_shown = false; sync_ads_webview_visibility( &app, &state, get_device_pixel_ratio(&app, dpr), )?; drop(state); if should_reload_ads { webview.navigate(AD_LINK.parse().unwrap()).ok(); } } app.emit_to("main", ADS_CONSENT_REQUIRED_EVENT, false).ok(); Ok(()) } #[tauri::command] pub async fn should_show_ads_consent_popup( app: tauri::AppHandle, ) -> crate::api::Result { let state = app.state::>(); let state = state.read().await; Ok(state.shown && state.consent_required && state.consent_notification_enabled) } #[tauri::command] pub async fn perform_ads_consent_action( app: tauri::AppHandle, action: String, ) -> crate::api::Result<()> { let script = match action.as_str() { "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.consent_notification_enabled }; if !should_perform { return Ok(()); } if let Some(webview) = app.webviews().get("ads-window") { webview.eval(script)?; } Ok(()) } #[tauri::command] pub async fn record_ads_click( app: tauri::AppHandle, ) -> crate::api::Result<()> { let state = app.state::>(); let mut state = state.write().await; state.last_click = Some(Instant::now()); Ok(()) } #[tauri::command] pub async fn open_link( app: tauri::AppHandle, path: String, origin: String, ) -> crate::api::Result<()> { let state = app.state::>(); let mut state = state.write().await; if url::Url::parse(&path).is_ok() && !state.malicious_origins.contains(&origin) && let Some(last_click) = state.last_click && last_click.elapsed() < Duration::from_millis(100) { let _ = app.opener().open_url(&path, None::); state.last_click = None; return Ok(()); } tracing::info!("Malicious click: {path} origin {origin}"); state.malicious_origins.insert(origin); Ok(()) } #[tauri::command] pub async fn get_ads_personalization() -> crate::api::Result { let res = settings::get().await?; Ok(res.personalized_ads) }