diff --git a/Cargo.lock b/Cargo.lock index 212ab4860f..e458977f85 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10597,6 +10597,7 @@ dependencies = [ "urlencoding", "uuid 1.18.1", "webview2-com", + "windows", "windows-core 0.61.2", ] diff --git a/apps/app/Cargo.toml b/apps/app/Cargo.toml index 375158ea8e..a1e4edf542 100644 --- a/apps/app/Cargo.toml +++ b/apps/app/Cargo.toml @@ -53,6 +53,12 @@ tauri-plugin-updater = { workspace = true, optional = true } [target.'cfg(windows)'.dependencies] webview2-com.workspace = true +windows = { workspace = true, features = [ + "Win32_Foundation", + "Win32_Graphics_Dwm", + "Win32_Graphics_Gdi", + "Win32_UI_WindowsAndMessaging", +] } windows-core.workspace = true [features] diff --git a/apps/app/src/api/ads.rs b/apps/app/src/api/ads.rs index f6c699e62b..bd906d5d2b 100644 --- a/apps/app/src/api/ads.rs +++ b/apps/app/src/api/ads.rs @@ -11,6 +11,7 @@ use tokio::sync::RwLock; pub struct AdsState { pub shown: bool, pub modal_shown: bool, + pub occluded: bool, pub last_click: Option, pub malicious_origins: HashSet, } @@ -60,8 +61,8 @@ 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, - ICoreWebView2_13, ICoreWebView2Profile3, + COREWEBVIEW2_TRACKING_PREVENTION_LEVEL_NONE, ICoreWebView2_13, + ICoreWebView2Profile3, }; use windows_core::Interface; @@ -119,7 +120,55 @@ fn set_webview_visible_for_window( .and_then(|window| window.is_minimized().ok()) .unwrap_or(false); - set_webview_visible(webview, visible && !is_minimized); + let is_occluded = app + .state::>() + .try_read() + .map(|state| state.occluded) + .unwrap_or(false); + + set_webview_visible(webview, visible && !is_minimized && !is_occluded); +} + +#[cfg(windows)] +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")?; + 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(windows)] +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 = state.shown && !state.modal_shown; + drop(state); + + if let Some(webview) = app.webviews().get("ads-window") { + set_webview_visible_for_window(app, webview, visible); + } } fn sync_webview_visibility_for_main_window( @@ -140,7 +189,7 @@ fn sync_webview_visibility_for_main_window( false } else { match app.state::>().try_read() { - Ok(state) => state.shown && !state.modal_shown, + Ok(state) => state.shown && !state.modal_shown && !state.occluded, Err(_) => false, } }; @@ -173,20 +222,28 @@ pub fn init() -> TauriPlugin { app.manage(RwLock::new(AdsState { shown: true, modal_shown: false, + occluded: false, last_click: None, malicious_origins: HashSet::new(), })); - // We refresh the ads window every 5 minutes to mitigate memory leak issues. - // While this loop doesn't include explicit checks to see if the window is still - // visible when we refresh, the Aditude wrapper will not make any ad requests - // unless Chromium reports the page as visible. The refresh does not reset the - // visibility state. + // 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 { - if let Some(webview) = - refresh_app.webviews().get_mut("ads-window") + let should_refresh = refresh_app + .state::>() + .try_read() + .map(|state| { + state.shown && !state.modal_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()); } @@ -224,6 +281,19 @@ pub fn init() -> TauriPlugin { }); } + #[cfg(windows)] + { + 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![ diff --git a/apps/app/src/api/ads_occlusion_windows.rs b/apps/app/src/api/ads_occlusion_windows.rs new file mode 100644 index 0000000000..ede843f62e --- /dev/null +++ b/apps/app/src/api/ads_occlusion_windows.rs @@ -0,0 +1,193 @@ +use windows::Win32::Foundation::{HWND, POINT, RECT}; +use windows::Win32::Graphics::Dwm::{ + DWMWA_CLOAKED, DWMWA_EXTENDED_FRAME_BOUNDS, DwmGetWindowAttribute, +}; +use windows::Win32::Graphics::Gdi::ClientToScreen; +use windows::Win32::UI::WindowsAndMessaging::{ + GA_ROOT, GW_HWNDNEXT, GetAncestor, GetTopWindow, GetWindow, GetWindowRect, + GetWindowThreadProcessId, IsIconic, IsWindowVisible, +}; + +const OCCLUDED_AREA_THRESHOLD: f64 = 1.0; + +pub fn is_ads_webview_occluded( + main_hwnd: HWND, + x: i32, + y: i32, + width: u32, + height: u32, +) -> bool { + let Some(ad_rect) = ad_rect_in_screen(main_hwnd, x, y, width, height) + else { + return false; + }; + + if is_empty_rect(&ad_rect) { + return false; + } + + let ad_area = rect_area(&ad_rect); + if ad_area == 0 { + return false; + } + + let mut occluded_area = 0u64; + let app_root = unsafe { GetAncestor(main_hwnd, GA_ROOT) }; + let app_process_id = std::process::id(); + let mut hwnd = match unsafe { GetTopWindow(None) } { + Ok(hwnd) => hwnd, + Err(_) => return false, + }; + + while !hwnd.is_invalid() { + let window_root = unsafe { GetAncestor(hwnd, GA_ROOT) }; + + if window_root == app_root { + return false; + } + + if window_process_id(hwnd) == Some(app_process_id) { + hwnd = match unsafe { GetWindow(hwnd, GW_HWNDNEXT) } { + Ok(hwnd) => hwnd, + Err(_) => break, + }; + continue; + } + + if window_counts_as_occluder(hwnd) + && let Some(occluder_rect) = window_rect(hwnd) + && let Some(intersection) = + intersect_rects(&ad_rect, &occluder_rect) + { + occluded_area = + occluded_area.saturating_add(rect_area(&intersection)); + + if (occluded_area as f64 / ad_area as f64) + >= OCCLUDED_AREA_THRESHOLD + { + return true; + } + } + + hwnd = match unsafe { GetWindow(hwnd, GW_HWNDNEXT) } { + Ok(hwnd) => hwnd, + Err(_) => break, + }; + } + + false +} + +fn ad_rect_in_screen( + main_hwnd: HWND, + x: i32, + y: i32, + width: u32, + height: u32, +) -> Option { + let mut origin = POINT { x: 0, y: 0 }; + + if !unsafe { ClientToScreen(main_hwnd, &mut origin).as_bool() } { + return None; + } + + let left = origin.x.saturating_add(x); + let top = origin.y.saturating_add(y); + let right = left.saturating_add(width as i32); + let bottom = top.saturating_add(height as i32); + + Some(RECT { + left, + top, + right, + bottom, + }) +} + +fn window_counts_as_occluder(hwnd: HWND) -> bool { + if !unsafe { IsWindowVisible(hwnd).as_bool() } { + return false; + } + + if unsafe { IsIconic(hwnd).as_bool() } { + return false; + } + + if is_dwm_cloaked(hwnd) { + return false; + } + + true +} + +fn window_process_id(hwnd: HWND) -> Option { + let mut process_id = 0u32; + + unsafe { + GetWindowThreadProcessId(hwnd, Some(&mut process_id)); + } + + (process_id != 0).then_some(process_id) +} + +fn is_dwm_cloaked(hwnd: HWND) -> bool { + let mut cloaked = 0u32; + + unsafe { + DwmGetWindowAttribute( + hwnd, + DWMWA_CLOAKED, + &mut cloaked as *mut u32 as *mut _, + std::mem::size_of::() as u32, + ) + } + .is_ok() + && cloaked != 0 +} + +fn window_rect(hwnd: HWND) -> Option { + let mut rect = RECT::default(); + + if unsafe { + DwmGetWindowAttribute( + hwnd, + DWMWA_EXTENDED_FRAME_BOUNDS, + &mut rect as *mut RECT as *mut _, + std::mem::size_of::() as u32, + ) + } + .is_err() + && unsafe { GetWindowRect(hwnd, &mut rect) }.is_err() + { + return None; + } + + if is_empty_rect(&rect) { + return None; + } + + Some(rect) +} + +fn is_empty_rect(rect: &RECT) -> bool { + rect.right <= rect.left || rect.bottom <= rect.top +} + +fn rect_area(rect: &RECT) -> u64 { + if is_empty_rect(rect) { + return 0; + } + + (rect.right - rect.left) as u64 * (rect.bottom - rect.top) as u64 +} + +fn intersect_rects(a: &RECT, b: &RECT) -> Option { + let rect = RECT { + left: a.left.max(b.left), + top: a.top.max(b.top), + right: a.right.min(b.right), + bottom: a.bottom.min(b.bottom), + }; + + (!is_empty_rect(&rect)).then_some(rect) +} diff --git a/apps/app/src/api/mod.rs b/apps/app/src/api/mod.rs index b865499b55..d30697aed2 100644 --- a/apps/app/src/api/mod.rs +++ b/apps/app/src/api/mod.rs @@ -18,6 +18,8 @@ pub mod tags; pub mod utils; pub mod ads; +#[cfg(windows)] +mod ads_occlusion_windows; pub mod cache; pub mod files; pub mod friends;