use crate::host_bridge::capabilities::capabilities; use crate::host_bridge::protocol::HOST_BRIDGE_VERSION; use crate::shell::runtime::desktop_platform; use std::path::PathBuf; use tauri::{Url, WebviewUrl}; pub(crate) const WEB_APP_ORIGIN: &str = "https://www.genarrative.world"; const HOST_CONTEXT_QUERY_KEYS: [&str; 7] = [ "clientRuntime", "clientType", "hostShell", "hostPlatform", "hostVersion", "bridgeVersion", "hostCapabilities", ]; fn append_desktop_host_context(url: &mut Url) { url.query_pairs_mut() .append_pair("clientRuntime", "native_app") .append_pair("clientType", "native_app") .append_pair("hostShell", "tauri_desktop") .append_pair("hostPlatform", desktop_platform()) .append_pair("hostVersion", env!("CARGO_PKG_VERSION")) .append_pair("bridgeVersion", &HOST_BRIDGE_VERSION.to_string()) .append_pair("hostCapabilities", &capabilities().join(",")); } pub(crate) fn desktop_h5_url_with_host_context(mut target_url: Url) -> Option { let base_url = Url::parse(WEB_APP_ORIGIN).ok()?; if target_url.scheme() != "https" || target_url.origin() != base_url.origin() { return None; } let retained_query_pairs = target_url .query_pairs() .filter(|(key, _)| !HOST_CONTEXT_QUERY_KEYS.contains(&key.as_ref())) .map(|(key, value)| (key.into_owned(), value.into_owned())) .collect::>(); target_url .query_pairs_mut() .clear() .extend_pairs( retained_query_pairs .iter() .map(|(key, value)| (key.as_str(), value.as_str())), ); append_desktop_host_context(&mut target_url); if target_url.origin() != base_url.origin() { return None; } Some(target_url) } fn append_desktop_host_context_to_url(mut url: Url) -> String { let retained_query_pairs = url .query_pairs() .filter(|(key, _)| !HOST_CONTEXT_QUERY_KEYS.contains(&key.as_ref())) .map(|(key, value)| (key.into_owned(), value.into_owned())) .collect::>(); url.query_pairs_mut() .clear() .extend_pairs( retained_query_pairs .iter() .map(|(key, value)| (key.as_str(), value.as_str())), ); append_desktop_host_context(&mut url); url.to_string() } pub(crate) fn desktop_entry_url_with_host_context(raw_url: &str) -> String { if let Ok(url) = Url::parse(raw_url) { return append_desktop_host_context_to_url(url); } let (without_hash, hash) = raw_url .split_once('#') .map(|(path, hash)| (path, Some(hash))) .unwrap_or((raw_url, None)); let mut parts = without_hash.splitn(2, '?'); let path = parts.next().unwrap_or_default(); let query = parts.next(); let mut pairs = query .map(|query| { query .split('&') .filter(|pair| { if pair.is_empty() { return false; } let key = pair.split_once('=').map(|(key, _)| key).unwrap_or(pair); !HOST_CONTEXT_QUERY_KEYS.contains(&key) }) .map(str::to_owned) .collect::>() }) .unwrap_or_default(); let mut context_url = Url::parse(WEB_APP_ORIGIN).expect("desktop web origin"); append_desktop_host_context(&mut context_url); pairs.extend( context_url .query_pairs() .map(|(key, value)| format!("{key}={value}")), ); let normalized_url = format!("{path}?{}", pairs.join("&")); if let Some(hash) = hash { format!("{normalized_url}#{hash}") } else { normalized_url } } pub(crate) fn desktop_window_config_with_runtime_platform( mut config: tauri::utils::config::WindowConfig, ) -> tauri::utils::config::WindowConfig { config.url = match config.url { WebviewUrl::External(url) => WebviewUrl::External( Url::parse(&desktop_entry_url_with_host_context(url.as_str())).unwrap_or(url), ), WebviewUrl::CustomProtocol(url) => WebviewUrl::CustomProtocol( Url::parse(&desktop_entry_url_with_host_context(url.as_str())).unwrap_or(url), ), WebviewUrl::App(path) => WebviewUrl::App(PathBuf::from(desktop_entry_url_with_host_context( path.to_string_lossy().as_ref(), ))), other => other, }; config } #[cfg(test)] mod tests { use super::*; #[test] fn desktop_h5_url_with_host_context_rewrites_runtime_query_once() { let platform = desktop_platform(); let url = Url::parse( "https://www.genarrative.world/creation/puzzle?work=PZ-1&clientRuntime=browser&hostCapabilities=old#draft", ) .expect("desktop H5 url"); let url = desktop_h5_url_with_host_context(url).expect("context url"); assert_eq!(url.origin().ascii_serialization(), WEB_APP_ORIGIN); assert_eq!(url.path(), "/creation/puzzle"); assert_eq!(url.fragment(), Some("draft")); assert_eq!( url.query_pairs() .filter(|(key, _)| key == "clientRuntime") .count(), 1 ); assert_eq!( url.query_pairs() .find(|(key, _)| key == "clientRuntime") .unwrap() .1, "native_app" ); assert_eq!( url.query_pairs() .find(|(key, _)| key == "hostPlatform") .unwrap() .1, platform ); assert_eq!( url.query_pairs() .find(|(key, _)| key == "hostVersion") .unwrap() .1, env!("CARGO_PKG_VERSION") ); assert_eq!( url.query_pairs() .find(|(key, _)| key == "hostCapabilities") .unwrap() .1, capabilities().join(",") ); assert_eq!( url.query_pairs().find(|(key, _)| key == "work").unwrap().1, "PZ-1" ); } #[test] fn desktop_h5_url_with_host_context_rejects_non_h5_origin() { let url = Url::parse("https://example.com/works/detail?work=PZ-1").expect("external url"); assert_eq!(desktop_h5_url_with_host_context(url), None); } #[test] fn desktop_entry_url_adds_host_context_from_plain_entries() { let platform = desktop_platform(); let dev_url = desktop_entry_url_with_host_context("http://127.0.0.1:3000/"); let dev_url = Url::parse(&dev_url).expect("dev url"); assert_eq!( dev_url .query_pairs() .find(|(key, _)| key == "clientRuntime"), Some(("clientRuntime".into(), "native_app".into())) ); assert_eq!( dev_url.query_pairs().find(|(key, _)| key == "hostPlatform"), Some(("hostPlatform".into(), platform.into())) ); assert_eq!( dev_url .query_pairs() .filter(|(key, _)| key == "hostPlatform") .count(), 1 ); assert_eq!( dev_url.query_pairs().find(|(key, _)| key == "hostVersion"), Some(("hostVersion".into(), env!("CARGO_PKG_VERSION").into())) ); assert_eq!( dev_url.query_pairs().find(|(key, _)| key == "bridgeVersion"), Some(("bridgeVersion".into(), HOST_BRIDGE_VERSION.to_string().into())) ); assert_eq!( dev_url.query_pairs().find(|(key, _)| key == "hostCapabilities"), Some(("hostCapabilities".into(), capabilities().join(",").into())) ); let packaged_url = desktop_entry_url_with_host_context("index.html#works"); assert!(packaged_url.contains("clientRuntime=native_app")); assert!(packaged_url.contains("clientType=native_app")); assert!(packaged_url.contains("hostShell=tauri_desktop")); assert!(packaged_url.contains(&format!("hostPlatform={platform}"))); assert!(packaged_url.contains(&format!("hostVersion={}", env!("CARGO_PKG_VERSION")))); assert!(packaged_url.contains(&format!("bridgeVersion={HOST_BRIDGE_VERSION}"))); assert!(packaged_url.contains(&format!("hostCapabilities={}", capabilities().join(",")))); assert!(packaged_url.ends_with("#works")); } #[test] fn desktop_entry_url_removes_stale_host_context_before_appending_current_context() { let url = desktop_entry_url_with_host_context( "index.html?clientRuntime=browser&hostShell=old_shell&hostCapabilities=old&work=PZ-1#works", ); let query = url.split_once('?').expect("context query").1; assert!(url.contains("work=PZ-1")); assert!(url.contains("clientRuntime=native_app")); assert!(url.contains("hostShell=tauri_desktop")); assert!(url.contains(&format!("hostCapabilities={}", capabilities().join(",")))); assert_eq!(query.matches("clientRuntime=").count(), 1); assert_eq!(query.matches("hostShell=").count(), 1); assert_eq!(query.matches("hostCapabilities=").count(), 1); assert!(!url.contains("clientRuntime=browser")); assert!(!url.contains("hostShell=old_shell")); assert!(!url.contains("hostCapabilities=old")); assert!(url.ends_with("#works")); } }