use std::path::PathBuf;
use std::sync::{mpsc, Arc};
use crate::window::LiveBackground;
use crate::{
DesktopEvent, DesktopHostMessage, EventRegistry, EventResponse, Rgba, WindowEffect,
DRAG_REGION_SCRIPT,
};
use anyhow::{Context, Result};
use webview2_com::Microsoft::Web::WebView2::Win32::{
CreateCoreWebView2EnvironmentWithOptions, ICoreWebView2, ICoreWebView2Controller,
ICoreWebView2Controller2, ICoreWebView2Environment, ICoreWebView2EnvironmentOptions,
ICoreWebView2NavigationCompletedEventHandler, ICoreWebView2NavigationStartingEventHandler,
ICoreWebView2WebMessageReceivedEventArgs, COREWEBVIEW2_COLOR,
};
use webview2_com::{
AddScriptToExecuteOnDocumentCreatedCompletedHandler, CoTaskMemPWSTR,
CoreWebView2EnvironmentOptions, CreateCoreWebView2ControllerCompletedHandler,
CreateCoreWebView2EnvironmentCompletedHandler, ExecuteScriptCompletedHandler,
NavigationCompletedEventHandler, NavigationStartingEventHandler,
};
use windows::core::{Error as WindowsError, Interface, Result as WindowsResult, PCWSTR};
use windows::Win32::Foundation::{E_FAIL, HWND};
use windows::Win32::Graphics::Dwm::{
DwmSetWindowAttribute, DWMSBT_MAINWINDOW, DWMSBT_TRANSIENTWINDOW, DWMWA_SYSTEMBACKDROP_TYPE,
DWMWA_USE_IMMERSIVE_DARK_MODE,
};
use super::command::execute_host_message;
use super::message::system_dark;
use super::protocol::read_pwstr;
use super::{APP_ORIGIN, WINDOW_ID};
const DWM_ATTRIBUTE_SIZE: u32 = 4;
const HOST_BRIDGE_SCRIPT: &str = "(()=>{if(!window.chrome?.webview)return;\
window.webuiHostPostMessage=m=>window.chrome.webview.postMessage(String(m));})();";
pub(super) fn create_environment(
user_data_folder: &std::path::Path,
) -> Result<ICoreWebView2Environment> {
let user_data_folder = user_data_folder.to_string_lossy();
let user_data_folder = CoTaskMemPWSTR::from(user_data_folder.as_ref());
let options = webview_environment_options();
let (tx, rx) = mpsc::channel();
CreateCoreWebView2EnvironmentCompletedHandler::wait_for_async_operation(
Box::new(move |handler| {
unsafe {
CreateCoreWebView2EnvironmentWithOptions(
PCWSTR::null(),
*user_data_folder.as_ref().as_pcwstr(),
&options,
&handler,
)
.map_err(webview2_com::Error::WindowsError)
}
}),
Box::new(
move |error_code, environment: Option<ICoreWebView2Environment>| {
error_code?;
tx.send(environment.ok_or_else(|| WindowsError::from(E_FAIL)))
.map_err(|_| WindowsError::from(E_FAIL))?;
Ok(())
},
),
)?;
rx.recv()
.map_err(|_| anyhow::anyhow!("WebView2 environment creation was cancelled"))?
.map_err(Into::into)
}
fn startup_url() -> String {
let path = std::env::var("WEBUI_DESKTOP_START_PATH").unwrap_or_else(|_| "/".to_string());
startup_url_for_path(&path)
}
fn startup_url_for_path(path: &str) -> String {
let path = if path.starts_with('/') {
path.to_string()
} else {
format!("/{path}")
};
let mut url = String::with_capacity(APP_ORIGIN.len() + path.len());
url.push_str(APP_ORIGIN);
url.push_str(&path);
url
}
fn webview_environment_options() -> ICoreWebView2EnvironmentOptions {
let options = CoreWebView2EnvironmentOptions::default();
options.into()
}
pub(super) fn browser_profile(
app_id: Option<&str>,
) -> Result<crate::browser_profile::BrowserProfile> {
let base = std::env::var_os("LOCALAPPDATA")
.map(PathBuf::from)
.unwrap_or_else(std::env::temp_dir);
let dir = base.join("Microsoft").join("WebUI").join("Desktop");
crate::browser_profile::BrowserProfile::create(&dir, app_id).with_context(|| {
format!(
"Failed to create WebView2 user data folder {}",
dir.display()
)
})
}
pub(super) fn create_controller(
environment: &ICoreWebView2Environment,
hwnd: HWND,
) -> Result<ICoreWebView2Controller> {
let (tx, rx) = mpsc::channel();
let environment = environment.clone();
CreateCoreWebView2ControllerCompletedHandler::wait_for_async_operation(
Box::new(move |handler| {
unsafe {
environment
.CreateCoreWebView2Controller(hwnd, &handler)
.map_err(webview2_com::Error::WindowsError)
}
}),
Box::new(
move |error_code, controller: Option<ICoreWebView2Controller>| {
error_code?;
tx.send(controller.ok_or_else(|| WindowsError::from(E_FAIL)))
.map_err(|_| WindowsError::from(E_FAIL))?;
Ok(())
},
),
)?;
rx.recv()
.map_err(|_| anyhow::anyhow!("WebView2 controller creation was cancelled"))?
.map_err(Into::into)
}
pub(super) fn configure_settings(webview: &ICoreWebView2, devtools: bool) -> Result<()> {
unsafe {
let settings = webview.Settings()?;
settings.SetAreDevToolsEnabled(devtools)?;
settings.SetAreDefaultContextMenusEnabled(devtools)?;
}
Ok(())
}
pub(super) fn register_navigation_guard(
webview: &ICoreWebView2,
events: EventRegistry,
) -> Result<ICoreWebView2NavigationStartingEventHandler> {
let webview_for_events = webview.clone();
let handler = NavigationStartingEventHandler::create(Box::new(move |_sender, args| {
if let Some(args) = args {
let uri = read_pwstr(|out| unsafe { args.Uri(out) })?;
let event = DesktopEvent::NavigationRequested {
window_id: WINDOW_ID,
url: uri.clone(),
};
let prevented = events.dispatch(&event) == EventResponse::PreventDefault;
mirror_event(&webview_for_events, &event);
if prevented || !is_allowed_navigation_url(&uri) {
unsafe { args.SetCancel(true)? };
}
}
Ok(())
}));
let mut token = 0_i64;
unsafe { webview.add_NavigationStarting(&handler, &mut token)? };
Ok(handler)
}
pub(super) fn configure_window_effect(hwnd: HWND, effect: WindowEffect) {
let backdrop = match effect {
WindowEffect::Acrylic | WindowEffect::Vibrancy => DWMSBT_TRANSIENTWINDOW.0,
WindowEffect::Mica => DWMSBT_MAINWINDOW.0,
WindowEffect::None | WindowEffect::Tabbed => return,
};
let _ = unsafe {
DwmSetWindowAttribute(
hwnd,
DWMWA_SYSTEMBACKDROP_TYPE,
std::ptr::from_ref(&backdrop).cast(),
DWM_ATTRIBUTE_SIZE,
)
};
let dark = i32::from(system_dark());
let _ = unsafe {
DwmSetWindowAttribute(
hwnd,
DWMWA_USE_IMMERSIVE_DARK_MODE,
std::ptr::from_ref(&dark).cast(),
DWM_ATTRIBUTE_SIZE,
)
};
}
pub(super) fn configure_controller_background(
controller: &ICoreWebView2Controller,
background: Option<Rgba>,
) -> Result<()> {
let Some(color) = background else {
return Ok(());
};
let Ok(controller) = controller.cast::<ICoreWebView2Controller2>() else {
return Ok(());
};
let color = COREWEBVIEW2_COLOR {
A: color.a,
R: color.r,
G: color.g,
B: color.b,
};
unsafe {
controller.SetDefaultBackgroundColor(color)?;
}
Ok(())
}
pub(super) fn update_background(
controller: &ICoreWebView2Controller,
webview: &ICoreWebView2,
color: Rgba,
) -> Result<()> {
let controller = controller
.cast::<ICoreWebView2Controller2>()
.context("live background updates require a newer WebView2 Runtime; update WebView2")?;
let native = COREWEBVIEW2_COLOR {
A: color.a,
R: color.r,
G: color.g,
B: color.b,
};
unsafe { controller.SetDefaultBackgroundColor(native)? };
update_document_background(webview, color)
}
pub(super) fn update_document_background(webview: &ICoreWebView2, color: Rgba) -> Result<()> {
let script = CoTaskMemPWSTR::from(crate::window::live_background_script(color).as_str());
let completion = ExecuteScriptCompletedHandler::create(Box::new(|result, _| {
if let Err(error) = result {
eprintln!("WebUI: failed to update the current document background: {error}");
}
Ok(())
}));
unsafe { webview.ExecuteScript(*script.as_ref().as_pcwstr(), &completion)? };
Ok(())
}
pub(super) fn inject_drag_script(webview: &ICoreWebView2) -> Result<()> {
add_document_script(webview, HOST_BRIDGE_SCRIPT)?;
add_document_script(webview, DRAG_REGION_SCRIPT)
}
pub(super) fn add_document_script(webview: &ICoreWebView2, source: &str) -> Result<()> {
let webview = webview.clone();
let script = CoTaskMemPWSTR::from(source);
AddScriptToExecuteOnDocumentCreatedCompletedHandler::wait_for_async_operation(
Box::new(move |handler| {
unsafe {
webview
.AddScriptToExecuteOnDocumentCreated(*script.as_ref().as_pcwstr(), &handler)
.map_err(webview2_com::Error::WindowsError)
}
}),
Box::new(|result, _| {
result?;
Ok(())
}),
)?;
Ok(())
}
pub(super) fn mirror_event(webview: &ICoreWebView2, event: &DesktopEvent) {
let Ok(script) = event.to_javascript() else {
return;
};
let script = CoTaskMemPWSTR::from(script.as_str());
let handler = ExecuteScriptCompletedHandler::create(Box::new(|_result, _| Ok(())));
let _ = unsafe { webview.ExecuteScript(*script.as_ref().as_pcwstr(), &handler) };
}
pub(super) fn handle_host_message(
hwnd: HWND,
args: &ICoreWebView2WebMessageReceivedEventArgs,
) -> WindowsResult<bool> {
let raw = read_pwstr(|out| unsafe { args.WebMessageAsJson(out) })?;
let Some(message) = decode_host_message(&raw) else {
return Ok(false);
};
execute_host_message(hwnd, message);
Ok(true)
}
fn decode_host_message(raw: &str) -> Option<DesktopHostMessage> {
let payload = serde_json::from_str::<String>(raw).ok()?;
DesktopHostMessage::from_json(&payload).ok()
}
pub(super) fn register_navigation_completed(
webview: &ICoreWebView2,
events: EventRegistry,
hwnd: HWND,
live_background: Arc<LiveBackground>,
) -> Result<ICoreWebView2NavigationCompletedEventHandler> {
let webview_for_uri = webview.clone();
let handler = NavigationCompletedEventHandler::create(Box::new(move |_sender, _args| {
super::state::with_window_state(hwnd, |state| {
if let Err(error) = state.app_window.publish_metrics(&state.webview) {
eprintln!("WebUI: failed to publish native titlebar measurements: {error}");
}
});
let url = read_pwstr(|out| unsafe { webview_for_uri.Source(out) })?;
let event = DesktopEvent::NavigationCompleted {
window_id: WINDOW_ID,
url,
};
let _ = events.dispatch(&event);
mirror_event(&webview_for_uri, &event);
if let Some(color) = live_background.current() {
if let Err(error) = update_document_background(&webview_for_uri, color) {
eprintln!("WebUI: failed to restore document background after navigation: {error}");
}
}
Ok(())
}));
let mut token = 0_i64;
unsafe { webview.add_NavigationCompleted(&handler, &mut token)? };
Ok(handler)
}
fn is_allowed_navigation_url(url: &str) -> bool {
crate::is_allowed_navigation_url(url, APP_ORIGIN)
}
pub(super) fn navigate_to_startup_url(webview: &ICoreWebView2) -> Result<()> {
let url = startup_url();
let url = CoTaskMemPWSTR::from(url.as_str());
unsafe { webview.Navigate(*url.as_ref().as_pcwstr())? };
Ok(())
}
#[cfg(test)]
#[allow(clippy::disallowed_methods)]
mod tests {
use super::*;
#[test]
fn startup_url_normalizes_paths() {
assert_eq!(
startup_url_for_path("/contacts"),
"https://app.webui.localhost/contacts"
);
assert_eq!(
startup_url_for_path("contacts"),
"https://app.webui.localhost/contacts"
);
}
#[test]
fn navigation_allowlist_accepts_only_the_app_origin() {
assert!(is_allowed_navigation_url("about:blank"));
assert!(is_allowed_navigation_url("https://app.webui.localhost"));
assert!(is_allowed_navigation_url(
"https://app.webui.localhost/contacts?view=all"
));
assert!(!is_allowed_navigation_url("https://example.com"));
assert!(!is_allowed_navigation_url(
"https://app.webui.localhost.evil.com/"
));
}
#[test]
fn host_messages_decode_through_the_webview_json_wrapper() {
assert_eq!(
decode_host_message("\"\\\"start-drag\\\"\""),
Some(DesktopHostMessage::StartDrag)
);
assert_eq!(
decode_host_message("\"\\\"toggle-maximize\\\"\""),
Some(DesktopHostMessage::ToggleMaximize)
);
assert_eq!(decode_host_message("{\"kind\":\"hello\"}"), None);
assert_eq!(decode_host_message("\"\\\"nope\\\"\""), None);
}
#[test]
fn host_bridge_script_defines_the_documented_global() {
assert!(HOST_BRIDGE_SCRIPT.contains("window.webuiHostPostMessage"));
assert!(DRAG_REGION_SCRIPT.contains("window.webuiHostPostMessage"));
}
}