From 451b2d0e440330c8953decafadd7cb9785cc1012 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Wed, 20 May 2026 20:31:12 +0100 Subject: [PATCH] Window occlusion checks on MacOS (#6135) * wip: window occlusion checks on MacOS * wip: occlusion works * occlusion notification * fix ci * fix * wire in hiding into macos occlusion * remove debug logs * fix * clean up --- Cargo.lock | 3 + Cargo.toml | 3 + apps/app/Cargo.toml | 7 + apps/app/src/api/ads.rs | 68 +++++-- apps/app/src/api/ads_occlusion_macos.rs | 236 ++++++++++++++++++++++ apps/app/src/api/ads_occlusion_windows.rs | 4 +- apps/app/src/api/mod.rs | 2 + 7 files changed, 302 insertions(+), 21 deletions(-) create mode 100644 apps/app/src/api/ads_occlusion_macos.rs diff --git a/Cargo.lock b/Cargo.lock index e458977f8..3cd13b000 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10565,6 +10565,8 @@ version = "1.0.0-local" dependencies = [ "async_zip", "chrono", + "core-foundation 0.10.1", + "core-graphics", "daedalus", "dashmap", "either", @@ -10572,6 +10574,7 @@ dependencies = [ "hyper 1.7.0", "hyper-util", "native-dialog", + "objc2-app-kit", "paste", "path-util", "serde", diff --git a/Cargo.toml b/Cargo.toml index a7a9dbe04..3e4da5928 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,6 +64,8 @@ clickhouse = "0.14.0" color-eyre = "0.6.5" color-thief = "0.2.2" const_format = "0.2.34" +core-foundation = "0.10.1" +core-graphics = "0.24.0" daedalus = { path = "packages/daedalus" } darling = { version = "0.23" } dashmap = "6.1.0" @@ -123,6 +125,7 @@ murmur2 = "0.1.0" native-dialog = "0.9.2" notify = { version = "8.2.0", default-features = false } notify-debouncer-mini = { version = "0.7.0", default-features = false } +objc2-app-kit = { version = "0.3.2", default-features = false } p256 = "0.13.2" parking_lot = "0.12.5" paste = "1.0.15" diff --git a/apps/app/Cargo.toml b/apps/app/Cargo.toml index a1e4edf54..929a19134 100644 --- a/apps/app/Cargo.toml +++ b/apps/app/Cargo.toml @@ -51,6 +51,13 @@ tauri-build = { workspace = true, features = ["codegen"] } [target.'cfg(target_os = "linux")'.dependencies] tauri-plugin-updater = { workspace = true, optional = true } +[target.'cfg(target_os = "macos")'.dependencies] +core-foundation.workspace = true +core-graphics.workspace = true +objc2-app-kit = { workspace = true, features = [ + "NSWindow", +] } + [target.'cfg(windows)'.dependencies] webview2-com.workspace = true windows = { workspace = true, features = [ diff --git a/apps/app/src/api/ads.rs b/apps/app/src/api/ads.rs index bd906d5d2..f82d58b6c 100644 --- a/apps/app/src/api/ads.rs +++ b/apps/app/src/api/ads.rs @@ -17,6 +17,8 @@ pub struct AdsState { } const AD_LINK: &str = "https://modrinth.com/wrapper/app-ads-cookie"; +#[cfg(any(windows, target_os = "macos"))] +pub(super) const OCCLUDED_AREA_THRESHOLD: f64 = 1.0; #[cfg(not(target_os = "linux"))] const ADS_USER_AGENT: &str = concat!( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ", @@ -92,10 +94,12 @@ fn configure_ads_cookie_settings( } } -fn set_webview_visible( - webview: &tauri::Webview, - _visible: bool, -) { +fn set_webview_visible(webview: &tauri::Webview, visible: bool) { + #[cfg(not(any(windows, target_os = "macos")))] + { + _ = visible; + } + webview .with_webview( #[allow(unused_variables)] @@ -103,11 +107,18 @@ fn set_webview_visible( #[cfg(windows)] { let controller = wv.controller(); - unsafe { controller.SetIsVisible(_visible) }.ok(); + unsafe { controller.SetIsVisible(visible) }.ok(); } }, ) .ok(); + + #[cfg(target_os = "macos")] + if visible { + webview.show().ok(); + } else { + webview.hide().ok(); + } } fn set_webview_visible_for_window( @@ -129,27 +140,48 @@ fn set_webview_visible_for_window( set_webview_visible(webview, visible && !is_minimized && !is_occluded); } -#[cfg(windows)] +#[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")?; - 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(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(windows)] +#[cfg(any(windows, target_os = "macos"))] async fn sync_ads_occlusion(app: &tauri::AppHandle) { let Some(occluded) = compute_ads_webview_occlusion(app) else { return; @@ -281,7 +313,7 @@ pub fn init() -> TauriPlugin { }); } - #[cfg(windows)] + #[cfg(any(windows, target_os = "macos"))] { let app_handle = app.clone(); diff --git a/apps/app/src/api/ads_occlusion_macos.rs b/apps/app/src/api/ads_occlusion_macos.rs new file mode 100644 index 000000000..5c2a50f3f --- /dev/null +++ b/apps/app/src/api/ads_occlusion_macos.rs @@ -0,0 +1,236 @@ +use std::ptr::NonNull; + +use core_foundation::array::CFArray; +use core_foundation::base::{CFType, TCFType}; +use core_foundation::dictionary::CFDictionary; +use core_foundation::number::CFNumber; +use core_foundation::string::CFString; +use core_graphics::display::CGDisplay; +use core_graphics::geometry::{CGPoint, CGRect, CGSize}; +use core_graphics::window::{ + CGWindowID, kCGWindowAlpha, kCGWindowBounds, kCGWindowLayer, + kCGWindowListExcludeDesktopElements, + kCGWindowListOptionOnScreenAboveWindow, kCGWindowListOptionOnScreenOnly, + kCGWindowNumber, kCGWindowOwnerName, kCGWindowOwnerPID, +}; +use objc2_app_kit::NSWindow; + +pub fn is_ads_webview_occluded( + main_window: &tauri::Window, + ad_x: i32, + ad_y: i32, + ad_width: u32, + ad_height: u32, +) -> Option { + let scale_factor = main_window.scale_factor().ok()?; + let maybe_ns_window = main_window.ns_window().ok()?; + let ns_window = NonNull::new(maybe_ns_window)?.cast::(); + let ns_window = unsafe { ns_window.as_ref() }; + let window_number = ns_window.windowNumber(); + let main_window_id = if window_number > 0 { + window_number as CGWindowID + } else { + return None; + }; + + let window_infos = CGDisplay::window_list_info( + kCGWindowListOptionOnScreenOnly | kCGWindowListExcludeDesktopElements, + None, + )?; + + let window_infos = unsafe { + CFArray::>::wrap_under_get_rule( + window_infos.as_concrete_TypeRef(), + ) + }; + let main_window_rect = window_infos + .iter() + .find(|window_info| window_id(window_info) == Some(main_window_id)) + .and_then(|window_info| window_rect(&window_info))?; + let ad_rect = ad_rect_from_main_window( + &main_window_rect, + ad_x, + ad_y, + ad_width, + ad_height, + scale_factor, + ); + + if is_empty_rect(&ad_rect) { + return None; + } + + let ad_area = rect_area(&ad_rect); + + if ad_area == 0.0 { + return None; + } + + let app_process_id = std::process::id() as i32; + let windows_above_main = CGDisplay::window_list_info( + kCGWindowListOptionOnScreenAboveWindow + | kCGWindowListExcludeDesktopElements, + Some(main_window_id), + )?; + let windows_above_main = unsafe { + CFArray::>::wrap_under_get_rule( + windows_above_main.as_concrete_TypeRef(), + ) + }; + let mut occluded_area = 0.0; + + for window_info in windows_above_main.iter() { + if window_id(&window_info) == Some(main_window_id) { + continue; + } + + if window_process_id(&window_info) == Some(app_process_id) { + continue; + } + + let owner_name = window_owner_name(&window_info); + + if owner_name.as_deref().is_some_and(is_system_window_owner) { + continue; + } + + let layer = window_layer(&window_info); + + if layer != Some(0) { + continue; + } + + let alpha = window_alpha(&window_info); + + if alpha.is_some_and(|alpha| alpha <= 0.0) { + continue; + } + + let Some(rect) = window_rect(&window_info) else { + continue; + }; + + let Some(intersection) = intersect_rects(&ad_rect, &rect) else { + continue; + }; + + occluded_area += rect_area(&intersection); + let occluded_ratio = occluded_area / ad_area; + + if occluded_ratio >= super::ads::OCCLUDED_AREA_THRESHOLD { + return Some(true); + } + } + + Some(false) +} + +fn ad_rect_from_main_window( + main_window_rect: &CGRect, + x: i32, + y: i32, + width: u32, + height: u32, + scale_factor: f64, +) -> CGRect { + let scale_factor = if scale_factor > 0.0 { + scale_factor + } else { + 1.0 + }; + + CGRect::new( + &CGPoint::new( + main_window_rect.origin.x + x as f64 / scale_factor, + main_window_rect.origin.y + y as f64 / scale_factor, + ), + &CGSize::new(width as f64 / scale_factor, height as f64 / scale_factor), + ) +} + +fn window_id( + window_info: &CFDictionary, +) -> Option { + number_value(window_info, unsafe { kCGWindowNumber }) + .and_then(|value| u32::try_from(value).ok()) +} + +fn window_process_id( + window_info: &CFDictionary, +) -> Option { + number_value(window_info, unsafe { kCGWindowOwnerPID }) + .and_then(|value| i32::try_from(value).ok()) +} + +fn window_layer(window_info: &CFDictionary) -> Option { + number_value(window_info, unsafe { kCGWindowLayer }) + .and_then(|value| i32::try_from(value).ok()) +} + +fn window_alpha(window_info: &CFDictionary) -> Option { + let key = unsafe { CFString::wrap_under_get_rule(kCGWindowAlpha) }; + + window_info.find(&key)?.downcast::()?.to_f64() +} + +fn window_owner_name( + window_info: &CFDictionary, +) -> Option { + let key = unsafe { CFString::wrap_under_get_rule(kCGWindowOwnerName) }; + + Some(window_info.find(&key)?.downcast::()?.to_string()) +} + +fn is_system_window_owner(owner_name: &str) -> bool { + matches!(owner_name, "WindowManager") +} + +fn number_value( + window_info: &CFDictionary, + key: core_foundation::string::CFStringRef, +) -> Option { + let key = unsafe { CFString::wrap_under_get_rule(key) }; + + window_info.find(&key)?.downcast::()?.to_i64() +} + +fn window_rect(window_info: &CFDictionary) -> Option { + let key = unsafe { CFString::wrap_under_get_rule(kCGWindowBounds) }; + let bounds = window_info.find(&key)?.downcast::()?; + let rect = CGRect::from_dict_representation(&bounds)?; + + if is_empty_rect(&rect) { + None + } else { + Some(rect) + } +} + +fn is_empty_rect(rect: &CGRect) -> bool { + rect.size.width <= 0.0 || rect.size.height <= 0.0 +} + +fn rect_area(rect: &CGRect) -> f64 { + if is_empty_rect(rect) { + return 0.0; + } + + rect.size.width * rect.size.height +} + +fn intersect_rects(a: &CGRect, b: &CGRect) -> Option { + let left = a.origin.x.max(b.origin.x); + let top = a.origin.y.max(b.origin.y); + let right = (a.origin.x + a.size.width).min(b.origin.x + b.size.width); + let bottom = (a.origin.y + a.size.height).min(b.origin.y + b.size.height); + let rect = CGRect::new( + &CGPoint::new(left, top), + &CGSize::new(right - left, bottom - top), + ); + + if is_empty_rect(&rect) { + None + } else { + Some(rect) + } +} diff --git a/apps/app/src/api/ads_occlusion_windows.rs b/apps/app/src/api/ads_occlusion_windows.rs index ede843f62..2a5fdb3ad 100644 --- a/apps/app/src/api/ads_occlusion_windows.rs +++ b/apps/app/src/api/ads_occlusion_windows.rs @@ -8,8 +8,6 @@ use windows::Win32::UI::WindowsAndMessaging::{ GetWindowThreadProcessId, IsIconic, IsWindowVisible, }; -const OCCLUDED_AREA_THRESHOLD: f64 = 1.0; - pub fn is_ads_webview_occluded( main_hwnd: HWND, x: i32, @@ -63,7 +61,7 @@ pub fn is_ads_webview_occluded( occluded_area.saturating_add(rect_area(&intersection)); if (occluded_area as f64 / ad_area as f64) - >= OCCLUDED_AREA_THRESHOLD + >= super::ads::OCCLUDED_AREA_THRESHOLD { return true; } diff --git a/apps/app/src/api/mod.rs b/apps/app/src/api/mod.rs index d30697aed..9cea479b1 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(target_os = "macos")] +mod ads_occlusion_macos; #[cfg(windows)] mod ads_occlusion_windows; pub mod cache;