diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 9cb3a9d3b9..379b8a8dbf 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -20,9 +20,9 @@ use tauri::Wry; use tauri::{ webview::PageLoadPayload, AppHandle, Manager, Webview, WebviewUrl, WebviewWindowBuilder, }; -use url::Url; #[cfg(target_os = "macos")] use tokio::time::sleep; +use url::Url; #[cfg(target_os = "macos")] use window_vibrancy::{apply_vibrancy, NSVisualEffectMaterial}; @@ -40,6 +40,136 @@ const TRAY_MENU_OPEN_APP_ID: &str = "tray_open_app"; const TRAY_MENU_OPEN_CHAT_ID: &str = "tray_open_chat"; const TRAY_MENU_SHOW_IN_BAR_ID: &str = "tray_show_in_menu_bar"; const TRAY_MENU_QUIT_ID: &str = "tray_quit"; +const CHAT_LINK_INTERCEPT_SCRIPT: &str = r##" +(() => { + if (window.__ONYX_CHAT_LINK_INTERCEPT_INSTALLED__) { + return; + } + + window.__ONYX_CHAT_LINK_INTERCEPT_INSTALLED__ = true; + + function isChatSessionPage() { + try { + const currentUrl = new URL(window.location.href); + return ( + currentUrl.pathname.startsWith("/app") && + currentUrl.searchParams.has("chatId") + ); + } catch { + return false; + } + } + + function getAllowedNavigationUrl(rawUrl) { + try { + const parsed = new URL(String(rawUrl), window.location.href); + const scheme = parsed.protocol.toLowerCase(); + if (!["http:", "https:", "mailto:", "tel:"].includes(scheme)) { + return null; + } + return parsed; + } catch { + return null; + } + } + + async function openWithTauri(url) { + try { + const invoke = + window.__TAURI__?.core?.invoke || window.__TAURI_INTERNALS__?.invoke; + if (typeof invoke !== "function") { + return false; + } + + await invoke("open_in_browser", { url }); + return true; + } catch { + return false; + } + } + + function handleChatNavigation(rawUrl) { + const parsedUrl = getAllowedNavigationUrl(rawUrl); + if (!parsedUrl) { + return false; + } + + const safeUrl = parsedUrl.toString(); + const scheme = parsedUrl.protocol.toLowerCase(); + if (scheme === "mailto:" || scheme === "tel:") { + void openWithTauri(safeUrl).then((opened) => { + if (!opened) { + window.location.assign(safeUrl); + } + }); + return true; + } + + window.location.assign(safeUrl); + return true; + } + + document.addEventListener( + "click", + (event) => { + if (!isChatSessionPage() || event.defaultPrevented) { + return; + } + + const element = event.target; + if (!(element instanceof Element)) { + return; + } + + const anchor = element.closest("a"); + if (!(anchor instanceof HTMLAnchorElement)) { + return; + } + + const target = (anchor.getAttribute("target") || "").toLowerCase(); + if (target !== "_blank") { + return; + } + + const href = anchor.getAttribute("href"); + if (!href || href.startsWith("#")) { + return; + } + + if (!handleChatNavigation(href)) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + }, + true + ); + + const nativeWindowOpen = window.open; + window.open = function(url, target, features) { + const resolvedTarget = typeof target === "string" ? target.toLowerCase() : ""; + const shouldNavigateInPlace = resolvedTarget === "" || resolvedTarget === "_blank"; + + if ( + isChatSessionPage() && + shouldNavigateInPlace && + url != null && + String(url).length > 0 + ) { + if (!handleChatNavigation(url)) { + return null; + } + return null; + } + + if (typeof nativeWindowOpen === "function") { + return nativeWindowOpen.call(window, url, target, features); + } + return null; + }; +})(); +"##; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AppConfig { @@ -177,22 +307,7 @@ fn trigger_new_window(app: &AppHandle) { } fn open_docs() { - let url = "https://docs.onyx.app"; - #[cfg(target_os = "macos")] - { - let _ = Command::new("open").arg(url).status(); - } - #[cfg(target_os = "linux")] - { - let _ = Command::new("xdg-open").arg(url).status(); - } - #[cfg(target_os = "windows")] - { - let _ = Command::new("rundll32") - .arg("url.dll,FileProtocolHandler") - .arg(url) - .status(); - } + let _ = open_in_default_browser("https://docs.onyx.app"); } fn open_settings(app: &AppHandle) { @@ -219,6 +334,68 @@ fn open_settings(app: &AppHandle) { } } +fn same_origin(left: &Url, right: &Url) -> bool { + left.scheme() == right.scheme() + && left.host_str() == right.host_str() + && left.port_or_known_default() == right.port_or_known_default() +} + +fn is_chat_session_url(url: &Url) -> bool { + url.path().starts_with("/app") && url.query_pairs().any(|(key, _)| key == "chatId") +} + +fn should_open_in_external_browser(current_url: &Url, destination_url: &Url) -> bool { + if !is_chat_session_url(current_url) { + return false; + } + + match destination_url.scheme() { + "mailto" | "tel" => true, + "http" | "https" => !same_origin(current_url, destination_url), + _ => false, + } +} + +fn open_in_default_browser(url: &str) -> bool { + #[cfg(target_os = "macos")] + { + return Command::new("open").arg(url).status().is_ok(); + } + #[cfg(target_os = "linux")] + { + return Command::new("xdg-open").arg(url).status().is_ok(); + } + #[cfg(target_os = "windows")] + { + return Command::new("rundll32") + .arg("url.dll,FileProtocolHandler") + .arg(url) + .status() + .is_ok(); + } + #[allow(unreachable_code)] + false +} + +#[tauri::command] +fn open_in_browser(url: String) -> Result<(), String> { + let parsed_url = Url::parse(&url).map_err(|_| "Invalid URL".to_string())?; + match parsed_url.scheme() { + "http" | "https" | "mailto" | "tel" => {} + _ => return Err("Unsupported URL scheme".to_string()), + } + + if open_in_default_browser(parsed_url.as_str()) { + Ok(()) + } else { + Err("Failed to open URL in default browser".to_string()) + } +} + +fn inject_chat_link_intercept(webview: &Webview) { + let _ = webview.eval(CHAT_LINK_INTERCEPT_SCRIPT); +} + // ============================================================================ // Tauri Commands // ============================================================================ @@ -240,8 +417,8 @@ struct BootstrapState { fn get_bootstrap_state(state: tauri::State) -> BootstrapState { let server_url = state.config.read().unwrap().server_url.clone(); let config_initialized = *state.config_initialized.read().unwrap(); - let config_exists = config_initialized - && get_config_path().map(|path| path.exists()).unwrap_or(false); + let config_exists = + config_initialized && get_config_path().map(|path| path.exists()).unwrap_or(false); BootstrapState { server_url, @@ -462,7 +639,13 @@ fn setup_app_menu(app: &AppHandle) -> tauri::Result<()> { true, Some("CmdOrCtrl+Shift+N"), )?; - let settings_item = MenuItem::with_id(app, "open_settings", "Settings...", true, Some("CmdOrCtrl+Comma"))?; + let settings_item = MenuItem::with_id( + app, + "open_settings", + "Settings...", + true, + Some("CmdOrCtrl+Comma"), + )?; let docs_item = MenuItem::with_id(app, "open_docs", "Onyx Documentation", true, None::<&str>)?; if let Some(file_menu) = menu @@ -501,13 +684,7 @@ fn setup_app_menu(app: &AppHandle) -> tauri::Result<()> { } fn build_tray_menu(app: &AppHandle) -> tauri::Result> { - let open_app = MenuItem::with_id( - app, - TRAY_MENU_OPEN_APP_ID, - "Open Onyx", - true, - None::<&str>, - )?; + let open_app = MenuItem::with_id(app, TRAY_MENU_OPEN_APP_ID, "Open Onyx", true, None::<&str>)?; let open_chat = MenuItem::with_id( app, TRAY_MENU_OPEN_CHAT_ID, @@ -598,6 +775,27 @@ fn main() { tauri::Builder::default() .plugin(tauri_plugin_shell::init()) + .plugin( + tauri::plugin::Builder::::new("chat-external-navigation-handler") + .on_navigation(|webview, destination_url| { + let Ok(current_url) = webview.url() else { + return true; + }; + + if should_open_in_external_browser(¤t_url, destination_url) { + if !open_in_default_browser(destination_url.as_str()) { + eprintln!( + "Failed to open external URL in default browser: {}", + destination_url + ); + } + return false; + } + + true + }) + .build(), + ) .plugin(tauri_plugin_window_state::Builder::default().build()) .manage(ConfigState { config: RwLock::new(config), @@ -609,6 +807,7 @@ fn main() { get_bootstrap_state, set_server_url, get_config_path_cmd, + open_in_browser, open_config_file, open_config_directory, navigate_to, @@ -661,10 +860,12 @@ fn main() { Ok(()) }) - .on_page_load(|_webview: &Webview, _payload: &PageLoadPayload| { + .on_page_load(|webview: &Webview, _payload: &PageLoadPayload| { + inject_chat_link_intercept(webview); + // Re-inject titlebar after every navigation/page load (macOS only) #[cfg(target_os = "macos")] - let _ = _webview.eval(TITLEBAR_SCRIPT); + let _ = webview.eval(TITLEBAR_SCRIPT); }) .run(tauri::generate_context!()) .expect("error while running tauri application");