forked from didirus/AstralRinth
451b2d0e44
* 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
671 lines
22 KiB
Rust
671 lines
22 KiB
Rust
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::{Manager, PhysicalPosition, PhysicalSize, Runtime};
|
|
use tauri_plugin_opener::OpenerExt;
|
|
use theseus::settings;
|
|
use tokio::sync::RwLock;
|
|
|
|
pub struct AdsState {
|
|
pub shown: bool,
|
|
pub modal_shown: bool,
|
|
pub occluded: bool,
|
|
pub last_click: Option<Instant>,
|
|
pub malicious_origins: HashSet<String>,
|
|
}
|
|
|
|
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 ",
|
|
"(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::<ICoreWebView2_13>()
|
|
.and_then(|core_webview2| unsafe { core_webview2.Profile() })
|
|
.and_then(|profile| profile.cast::<ICoreWebView2Profile3>())
|
|
{
|
|
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<R: Runtime>(webview: &tauri::Webview<R>, 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();
|
|
}
|
|
}
|
|
|
|
fn set_webview_visible_for_window<R: Runtime>(
|
|
app: &tauri::AppHandle<R>,
|
|
webview: &tauri::Webview<R>,
|
|
visible: bool,
|
|
) {
|
|
let is_minimized = app
|
|
.get_window("main")
|
|
.and_then(|window| window.is_minimized().ok())
|
|
.unwrap_or(false);
|
|
|
|
let is_occluded = app
|
|
.state::<RwLock<AdsState>>()
|
|
.try_read()
|
|
.map(|state| state.occluded)
|
|
.unwrap_or(false);
|
|
|
|
set_webview_visible(webview, visible && !is_minimized && !is_occluded);
|
|
}
|
|
|
|
#[cfg(any(windows, target_os = "macos"))]
|
|
fn compute_ads_webview_occlusion<R: Runtime>(
|
|
app: &tauri::AppHandle<R>,
|
|
) -> Option<bool> {
|
|
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<R: Runtime>(app: &tauri::AppHandle<R>) {
|
|
let Some(occluded) = compute_ads_webview_occlusion(app) else {
|
|
return;
|
|
};
|
|
|
|
let state = app.state::<RwLock<AdsState>>();
|
|
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<R: Runtime>(
|
|
app: &tauri::AppHandle<R>,
|
|
main_window: &tauri::Window<R>,
|
|
was_minimized: &AtomicBool,
|
|
) {
|
|
let is_minimized = main_window.is_minimized().unwrap_or(false);
|
|
let was = was_minimized.load(Ordering::SeqCst);
|
|
|
|
if is_minimized == was {
|
|
return;
|
|
}
|
|
|
|
was_minimized.store(is_minimized, Ordering::SeqCst);
|
|
|
|
let ads_visible = if is_minimized {
|
|
false
|
|
} else {
|
|
match app.state::<RwLock<AdsState>>().try_read() {
|
|
Ok(state) => state.shown && !state.modal_shown && !state.occluded,
|
|
Err(_) => false,
|
|
}
|
|
};
|
|
|
|
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<R: Runtime>() -> TauriPlugin<R> {
|
|
tauri::plugin::Builder::<R>::new("ads")
|
|
.setup(|app, _api| {
|
|
app.manage(RwLock::new(AdsState {
|
|
shown: true,
|
|
modal_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::<RwLock<AdsState>>()
|
|
.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());
|
|
}
|
|
|
|
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,
|
|
show_ads_window,
|
|
record_ads_click,
|
|
open_link,
|
|
get_ads_personalization,
|
|
])
|
|
.build()
|
|
}
|
|
|
|
fn get_webview_position<R: Runtime>(
|
|
app: &tauri::AppHandle<R>,
|
|
dpr: f32,
|
|
) -> crate::api::Result<(PhysicalPosition<f32>, PhysicalSize<f32>)> {
|
|
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),
|
|
))
|
|
}
|
|
|
|
#[tauri::command]
|
|
#[cfg(not(target_os = "linux"))]
|
|
pub async fn init_ads_window<R: Runtime>(
|
|
app: tauri::AppHandle<R>,
|
|
dpr: f32,
|
|
override_shown: bool,
|
|
) -> crate::api::Result<()> {
|
|
use tauri::WebviewUrl;
|
|
|
|
let state = app.state::<RwLock<AdsState>>();
|
|
let mut state = state.write().await;
|
|
|
|
if override_shown {
|
|
state.shown = true;
|
|
}
|
|
|
|
if state.modal_shown {
|
|
return Ok(());
|
|
}
|
|
|
|
if let Ok((position, size)) = get_webview_position(&app, dpr) {
|
|
let webview = if let Some(webview) = app.webviews().get("ads-window") {
|
|
// set both the `hide`/`show` state and `position`,
|
|
// to ensure that the webview is actually shown/hidden
|
|
if state.shown {
|
|
webview.show().ok();
|
|
webview.set_position(position).ok();
|
|
webview.set_size(size).ok();
|
|
set_webview_visible_for_window(&app, webview, true);
|
|
} else {
|
|
webview.hide().ok();
|
|
webview
|
|
.set_position(PhysicalPosition::new(-1000, -1000))
|
|
.ok();
|
|
set_webview_visible(webview, false);
|
|
}
|
|
|
|
Some(webview.clone())
|
|
} else if let Some(window) = app.get_window("main") {
|
|
#[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(include_str!(
|
|
"ads-init.js"
|
|
))
|
|
// 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 state.shown {
|
|
position
|
|
} else {
|
|
PhysicalPosition::new(-1000.0, -1000.0)
|
|
},
|
|
size,
|
|
)?;
|
|
|
|
if state.shown {
|
|
webview.show().ok();
|
|
set_webview_visible_for_window(&app, &webview, true);
|
|
} else {
|
|
webview.hide().ok();
|
|
set_webview_visible(&webview, false);
|
|
}
|
|
|
|
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::<ICoreWebView2_8>())
|
|
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;
|
|
// }
|
|
// });
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// TODO: make ads work on linux
|
|
#[tauri::command]
|
|
#[cfg(target_os = "linux")]
|
|
pub async fn init_ads_window() {}
|
|
|
|
#[tauri::command]
|
|
pub async fn show_ads_window<R: Runtime>(
|
|
app: tauri::AppHandle<R>,
|
|
dpr: f32,
|
|
) -> crate::api::Result<()> {
|
|
if let Some(webview) = app.webviews().get("ads-window") {
|
|
let state = app.state::<RwLock<AdsState>>();
|
|
let mut state = state.write().await;
|
|
|
|
state.modal_shown = false;
|
|
|
|
if state.shown {
|
|
let (position, size) = get_webview_position(&app, dpr)?;
|
|
// set both the `hide`/`show` state and `position`,
|
|
// to ensure that the webview is actually shown/hidden
|
|
webview.set_size(size).ok();
|
|
webview.set_position(position).ok();
|
|
webview.show().ok();
|
|
set_webview_visible_for_window(&app, webview, true);
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn hide_ads_window<R: Runtime>(
|
|
app: tauri::AppHandle<R>,
|
|
reset: Option<bool>,
|
|
) -> crate::api::Result<()> {
|
|
if let Some(webview) = app.webviews().get("ads-window") {
|
|
let state = app.state::<RwLock<AdsState>>();
|
|
let mut state = state.write().await;
|
|
|
|
if reset.unwrap_or(false) {
|
|
state.shown = false;
|
|
} else {
|
|
state.modal_shown = true;
|
|
}
|
|
|
|
// set both the `hide`/`show` state and `position`,
|
|
// to ensure that the webview is actually shown/hidden
|
|
webview
|
|
.set_position(PhysicalPosition::new(-1000, -1000))
|
|
.ok();
|
|
webview.hide().ok();
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn record_ads_click<R: Runtime>(
|
|
app: tauri::AppHandle<R>,
|
|
) -> crate::api::Result<()> {
|
|
let state = app.state::<RwLock<AdsState>>();
|
|
|
|
let mut state = state.write().await;
|
|
state.last_click = Some(Instant::now());
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn open_link<R: Runtime>(
|
|
app: tauri::AppHandle<R>,
|
|
path: String,
|
|
origin: String,
|
|
) -> crate::api::Result<()> {
|
|
let state = app.state::<RwLock<AdsState>>();
|
|
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::<String>);
|
|
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<bool> {
|
|
let res = settings::get().await?;
|
|
Ok(res.personalized_ads)
|
|
}
|