use log::{debug, error}; use tauri::{ image::Image, AppHandle, Listener, Manager, Result, Theme, UserAttentionType, WebviewUrl, WebviewWindow, WebviewWindowBuilder, }; use std::sync::{atomic::{AtomicBool, Ordering}}; use tauri_plugin_window_state::{AppHandleExt, StateFlags}; use crate::{portmaster::PortmasterExt, traymenu}; const LIGHT_PM_ICON: &[u8] = include_bytes!("../../../../assets/data/icons/pm_light_512.png"); const DARK_PM_ICON: &[u8] = include_bytes!("../../../../assets/data/icons/pm_dark_512.png"); const CUSTOM_ENVVAR_FOR_WEBVIEW_PROCESS: &str = "PORTMASTER_UI_WEBVIEW_PROCESS"; static UI_PROCESS_ENV_VAR_DEFINED_FLAG: AtomicBool = AtomicBool::new(false); /// Either returns the existing "main" window or creates a new one. /// /// The window is not automatically shown (i.e it starts hidden). /// If a new main window is created (i.e. the tauri app was minimized to system-tray) /// then the window will be automatically navigated to the Portmaster UI endpoint /// if ::websocket::is_portapi_reachable returns true. /// /// Either the existing or the newly created window is returned. pub fn create_main_window(app: &AppHandle) -> Result { let mut window = if let Some(window) = app.get_webview_window("main") { debug!("[tauri] main window already created"); window } else { debug!("[tauri] creating main window"); do_before_any_window_create(); // required operations before window creation let res = WebviewWindowBuilder::new(app, "main", WebviewUrl::App("index.html".into())) .title("Portmaster") .visible(false) .inner_size(1200.0, 700.0) .min_inner_size(800.0, 600.0) .zoom_hotkeys_enabled(true) .theme(Some(Theme::Dark)) .on_page_load(|_window, _event| { debug!("[tauri] main window page loaded: {}", _event.url()); do_after_main_window_created(); // required operations after Main window creation }) .on_navigation(|url| { debug!("[tauri] main window navigation event: {}", url); if url.as_str() == "about:blank" { debug!("[tauri] blocking navigation to about:blank"); return false; } return true; }) .build(); match res { Ok(win) => { win.once("tauri://error", |event| { error!("failed to open tauri window: {}", event.payload()); }); #[cfg(target_os = "linux")] { // Workaround for KDE/Wayland environments on Linux: // On KDE with Wayland, after hiding and showing the window, // the title-bar buttons (close, minimize, maximize) may stop working. // Toggling the resizable property appears to resolve this issue. // Issue: https://github.com/safing/portmaster/issues/1909 // Additional info: https://github.com/tauri-apps/tauri/issues/6162#issuecomment-1423304398 let win_clone = win.clone(); win.listen("tauri://focus", move |event| { let _ = win_clone.set_resizable(false); let _ = win_clone.set_resizable(true); }); } win } Err(err) => { error!("[tauri] failed to create main window: {}", err.to_string()); return Err(err); } } }; // If the window is not yet navigated to the Portmaster UI, do it now. may_navigate_to_ui(&mut window, false); set_window_icon(&window); #[cfg(debug_assertions)] if std::env::var("TAURI_SHOW_IMMEDIATELY").is_ok() { debug!("[tauri] TAURI_SHOW_IMMEDIATELY is set, opening window"); if let Err(err) = window.show() { error!("[tauri] failed to show window: {}", err.to_string()); } } Ok(window) } pub fn create_splash_window(app: &AppHandle) -> Result { if let Some(window) = app.get_webview_window("splash") { let _ = window.show(); Ok(window) } else { do_before_any_window_create(); // required operations before window creation let window = WebviewWindowBuilder::new(app, "splash", WebviewUrl::App("index.html".into())) .center() .closable(false) .focused(true) .resizable(false) .visible(true) .title("Portmaster") .inner_size(600.0, 250.0) .zoom_hotkeys_enabled(true) .build()?; set_window_icon(&window); let _ = window.request_user_attention(Some(UserAttentionType::Informational)); Ok(window) } } pub fn close_splash_window(app: &AppHandle) -> Result<()> { if let Some(window) = app.get_webview_window("splash") { let _ = window.hide(); return window.destroy(); } Err(tauri::Error::WindowNotFound) } pub fn hide_splash_window(app: &AppHandle) -> Result<()> { if let Some(window) = app.get_webview_window("splash") { return window.hide(); } Err(tauri::Error::WindowNotFound) } pub fn set_window_icon(window: &WebviewWindow) { let mut mode = if let Ok(value) = traymenu::USER_THEME.read() { *value } else { dark_light::Mode::Default }; if mode == dark_light::Mode::Default { mode = dark_light::detect(); } let _ = match mode { dark_light::Mode::Light => window.set_icon(Image::from_bytes(DARK_PM_ICON).unwrap()), _ => window.set_icon(Image::from_bytes(LIGHT_PM_ICON).unwrap()), }; } /// This function must be called before any window is created. /// /// Temporarily sets the environment variable `PORTMASTER_WEBVIEW_UI_PROCESS` to "true". /// This ensures that any child process (i.e., the WebView process) spawned during window creation /// will inherit this environment variable. This allows portmaster-core to detect that the process /// is a child WebView of the main process. /// /// IMPORTANT: After the 'Main' window is created, you must call `do_after_main_window_created()` to remove /// the environment variable from the main process environment. /// This ensures that any subsequent child processes (such as those created by "open external" functionality) /// will not inherit this environment variable, correctly indicating that they are not part of the /// Portmaster UI WebView process. pub fn do_before_any_window_create() { UI_PROCESS_ENV_VAR_DEFINED_FLAG.store(true, Ordering::SeqCst); std::env::set_var(CUSTOM_ENVVAR_FOR_WEBVIEW_PROCESS, "true"); } /// This function must be called after the Main window is created. /// /// Removes the `PORTMASTER_WEBVIEW_UI_PROCESS` environment variable from the main process. /// This ensures that only the child WebView process has the variable set, and the main process /// does not retain it. pub fn do_after_main_window_created() { let flag_was_set = UI_PROCESS_ENV_VAR_DEFINED_FLAG.compare_exchange( true, false, Ordering::SeqCst, Ordering::SeqCst ).is_ok(); if flag_was_set { std::env::remove_var(CUSTOM_ENVVAR_FOR_WEBVIEW_PROCESS); } } /// Opens a window for the tauri application. /// /// If the main window has already been created, it is instructed to /// show even if we're currently not connected to Portmaster. /// This is safe since the main-window will only be created if Portmaster API /// was reachable so the angular application must have finished bootstrapping. /// /// If there's not main window and the Portmaster API is reachable we create a new /// main window. /// /// If the Portmaster API is unreachable and there's no main window yet, we show the /// splash-screen window. pub fn open_window(app: &AppHandle) -> Result { if app.portmaster().is_reachable() { match app.get_webview_window("main") { Some(win) => { if let Ok(true) = win.is_minimized() { let _ = win.unminimize(); } app.portmaster().show_window(); let _ = win.show(); let _ = win.set_focus(); set_window_icon(&win); Ok(win) } None => { app.portmaster().show_window(); create_main_window(app) } } } else { debug!("Show splash screen"); create_splash_window(app) } } /// If the Portmaster Websocket database API is reachable the window will be navigated /// to the HTTP endpoint of Portmaster to load the UI from there. /// /// Note that only happens if the window URL does not already point to the PM API. /// /// In #[cfg(debug_assertions)] the TAURI_PM_URL environment variable will be used /// if set. /// Otherwise or in release builds, it will be navigated to http://127.0.0.1:817. pub fn may_navigate_to_ui(win: &mut WebviewWindow, force: bool) { if !win.app_handle().portmaster().is_reachable() && !force { error!("[tauri] portmaster API is not reachable, not navigating"); return; } if force || win.label().eq("main") { #[cfg(debug_assertions)] if let Ok(target_url) = std::env::var("TAURI_PM_URL") { debug!("[tauri] navigating to {}", target_url); _ = win.navigate(target_url.parse().unwrap()); return; } #[cfg(debug_assertions)] { // Only for dev build // Allow connection to http://localhost:4200 let capabilities = include_str!("../capabilities/default.json") .replace("http://127.0.0.1:817", "http://127.0.0.1:4200"); let _ = win.add_capability(capabilities); debug!("[tauri] navigating to http://127.0.0.1:4200"); _ = win.navigate("http://127.0.0.1:4200".parse().unwrap()); } #[cfg(not(debug_assertions))] { _ = win.navigate("http://127.0.0.1:817".parse().unwrap()); } } else { error!( "not navigating to user interface: current url: {}", win.url().unwrap().as_str() ); } } /// Creates a debounced window state saver that waits for a quiet period before saving. /// /// Returns a sender that can be used to trigger save events. Multiple rapid events /// will be debounced - only saving after the specified timeout with no new events. /// /// # Example /// ```rust /// let save_trigger = create_debounced_window_state_saver(app, state_flags, Duration::from_secs(5)); /// let _ = save_trigger.try_send(()); // Trigger a save (will be debounced) /// ``` pub fn create_debounced_window_state_saver( app: &tauri::App, state_flags: StateFlags, debounce_timeout: std::time::Duration, ) -> tokio::sync::mpsc::Sender<()> { let app_handle = app.handle().clone(); let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(10); // Spawn debouncer task - saves state after the specified timeout following the last event tauri::async_runtime::spawn(async move { loop { if rx.recv().await.is_none() { break; // Channel closed } loop { match tokio::time::timeout(debounce_timeout, rx.recv()).await { Ok(Some(_)) => { continue; // Received another event within timeout period, restart the timer } Ok(None) => { return; // Channel closed } Err(_) => { // Timeout: specified duration passed without new events, save state if let Err(e) = app_handle.save_window_state(state_flags) { debug!("Failed to save window state: {}", e); } break; // Exit inner loop and wait for next event } } } } }); tx }