use crate::locale::en; use crate::server::{validate_url, Server, DEFAULT_SERVER}; use std::{path::PathBuf, sync::Mutex, time::Duration}; use tauri::{ menu::{AboutMetadata, MenuBuilder, MenuItem, SubmenuBuilder}, webview::{NewWindowResponse, PageLoadEvent}, AppHandle, Manager, Url, WebviewUrl, WebviewWindowBuilder, WindowEvent, }; #[derive(Default)] struct Connection { server: Option, label: Option, revision: u64, ready: bool, phase: &'static str, } impl Connection { fn begin_navigation(&mut self) -> u64 { self.revision += 1; self.phase = "connecting"; self.revision } fn timeout(&mut self, revision: u64) -> bool { if self.revision != revision || self.phase != "connecting" { return false; } self.phase = "connectionError"; true } } fn allows_server_navigation(server: &Url, destination: &Url) -> bool { destination.scheme() == "https" && destination.origin() == server.origin() && destination.username().is_empty() && destination.password().is_none() } fn allows_external_navigation(destination: &Url) -> bool { matches!(destination.scheme(), "https" | "http") && destination.host_str().is_some() && destination.username().is_empty() && destination.password().is_none() } fn open_external(destination: &Url) { if allows_external_navigation(destination) { // Arguments are passed directly to Launch Services, never through a shell. let destination = destination.to_string(); std::thread::spawn(move || { let _ = std::process::Command::new("/usr/bin/open") .arg("--") .arg(destination) .status(); }); } } fn allows_launcher_navigation(destination: &Url) -> bool { destination.scheme() == "tauri" && destination.host_str() == Some("localhost") && destination.port().is_none() && destination.username().is_empty() && destination.password().is_none() } fn configuration_path(app: &AppHandle) -> tauri::Result { Ok(app.path().app_config_dir()?.join("active-server")) } fn update_launcher(app: &AppHandle) { let state = app.state::>(); let state = state.lock().unwrap(); let mut message = Url::parse("tauri://localhost/").unwrap(); message .query_pairs_mut() .append_pair( "server", state .server .as_ref() .map_or(DEFAULT_SERVER, |server| server.url.as_str()), ) .append_pair("phase", state.phase) .append_pair("ready", if state.ready { "true" } else { "false" }); if let Some(window) = app.get_webview_window("launcher") { let _ = window.eval(format!( "window.updateLauncher?.('{}')", message.query().unwrap() )); } } fn show_launcher(app: &AppHandle) { update_launcher(app); if let Some(window) = app.get_webview_window("launcher") { let _ = window.show(); let _ = window.set_focus(); } } fn begin_navigation(app: &AppHandle, label: &str) { let state = app.state::>(); let revision = { let mut state = state.lock().unwrap(); if state.label.as_deref() != Some(label) { return; } state.begin_navigation() }; update_launcher(app); let handle = app.clone(); std::thread::spawn(move || { std::thread::sleep(Duration::from_secs(15)); let state = handle.state::>(); let timed_out = state.lock().unwrap().timeout(revision); if timed_out { show_launcher(&handle); } }); } fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> { let url = validate_url(input)?; let state = app.state::>(); let (server, old_label, label) = { let mut state = state.lock().unwrap(); let same_server = state .server .as_ref() .is_some_and(|server| server.url == url); if same_server && state.ready { if let Some(window) = state .label .as_ref() .and_then(|label| app.get_webview_window(label)) { window.show().map_err(|_| "connectionError")?; window.set_focus().map_err(|_| "connectionError")?; return Ok(()); } } let server = if same_server { state.server.clone().unwrap() } else { Server::new(url).map_err(|_| "storageError")? }; server .save(&configuration_path(app).map_err(|_| "storageError")?) .map_err(|_| "storageError")?; let old_label = state.label.take(); state.server = Some(server.clone()); state.ready = false; state.revision += 1; let label = format!("workspace-{}", state.revision); state.label = Some(label.clone()); (server, old_label, label) }; if let Some(window) = old_label.and_then(|label| app.get_webview_window(&label)) { window.close().map_err(|_| "connectionError")?; } begin_navigation(app, &label); let handle = app.clone(); let navigation_label = label.clone(); let server_url = server.url.clone(); let popup_server = server_url.clone(); let popup_handle = app.clone(); let popup_label = label.clone(); let window = WebviewWindowBuilder::new(app, &label, WebviewUrl::External(server.dashboard_url())) .title(en::APP_NAME) .inner_size(1280.0, 850.0) .min_inner_size(375.0, 600.0) .center() .visible(false) .data_store_identifier(server.profile) .initialization_script(include_str!("../../ui/workspace-navigation.js")) .on_navigation(move |destination| { if !allows_server_navigation(&server_url, destination) { open_external(destination); return false; } begin_navigation(&handle, &navigation_label); true }) .on_new_window(move |destination, _| { if allows_server_navigation(&popup_server, &destination) { if let Some(window) = popup_handle.get_webview_window(&popup_label) { let _ = window.navigate(destination); } } else { open_external(&destination); } NewWindowResponse::Deny }) .on_page_load(|window, payload| { if payload.event() != PageLoadEvent::Finished { return; } let app = window.app_handle(); let state = app.state::>(); let should_show = { let mut state = state.lock().unwrap(); if state.label.as_deref() != Some(window.label()) || state.phase != "connecting" || !state.server.as_ref().is_some_and(|server| { allows_server_navigation(&server.url, payload.url()) }) { return; } let first_load = !state.ready; state.ready = true; state.phase = "connected"; first_load }; if should_show { let _ = window.show(); let _ = window.set_focus(); if let Some(launcher) = app.get_webview_window("launcher") { let _ = launcher.hide(); } } update_launcher(app); }) .build() .map_err(|_| "connectionError")?; #[cfg(target_os = "macos")] window .with_webview(|webview| unsafe { unsafe extern "C" { fn install_workspace_navigation(webview: *mut std::ffi::c_void); } install_workspace_navigation(webview.inner().cast()); }) .map_err(|_| "connectionError")?; let handle = app.clone(); window.on_window_event(move |event| { if let WindowEvent::CloseRequested { api, .. } = event { let current = handle .state::>() .lock() .unwrap() .label .as_deref() == Some(&label); if current { api.prevent_close(); handle.exit(0); } } }); Ok(()) } fn handle_launcher_action(app: &AppHandle, destination: &Url) { let result = match destination.path() { "/action/updates" => { #[cfg(target_os = "macos")] crate::updates::show(app); Ok(()) } "/action/open" => { let input = destination .query_pairs() .find(|(key, _)| key == "server") .map(|(_, value)| value.into_owned()) .unwrap_or_default(); open_workspace(app, &input) } "/action/resume" => { let label = app .state::>() .lock() .unwrap() .label .clone(); if let Some(window) = label.and_then(|label| app.get_webview_window(&label)) { let _ = window.show(); let _ = window.set_focus(); } Ok(()) } _ => Ok(()), }; if let Err(error) = result { app.state::>().lock().unwrap().phase = error; show_launcher(app); } } pub fn context() -> tauri::Context { tauri::generate_context!() } fn about_metadata(version: String) -> AboutMetadata<'static> { AboutMetadata { name: Some(en::APP_NAME.into()), version: Some(version), #[cfg(target_os = "macos")] short_version: Some(crate::metadata::build_number().into()), ..Default::default() } } pub fn builder() -> tauri::Builder { tauri::Builder::default().setup(|app| { let (server, phase) = match Server::load(&configuration_path(app.handle())?) { Ok(Some(server)) => (Some(server), "connecting"), Ok(None) => (None, "idle"), Err(_) => (None, "storageError"), }; let startup_server = server.clone(); app.manage(Mutex::new(Connection { server, phase, ..Default::default() })); let menu = MenuBuilder::new(app) .item( &SubmenuBuilder::with_id(app, "app-menu", en::APP_NAME) .about_with_text( en::ABOUT, Some(about_metadata(app.package_info().version.to_string())), ) .text("check-updates", en::CHECK_UPDATES) .item(&MenuItem::with_id( app, "install-update", en::INSTALL_UPDATE, false, None::<&str>, )?) .separator() .text("server-settings", en::SERVER_SETTINGS) .separator() .quit() .build()?, ) .item( &SubmenuBuilder::new(app, en::EDIT) .undo() .redo() .separator() .cut() .copy() .paste() .select_all() .build()?, ) .item( &SubmenuBuilder::new(app, en::NAVIGATION) .text("workspace-back", en::BACK) .text("workspace-forward", en::FORWARD) .build()?, ) .build()?; app.set_menu(menu)?; #[cfg(target_os = "macos")] crate::updates::setup(app.handle())?; app.on_menu_event(|app, event| { #[cfg(target_os = "macos")] match event.id().as_ref() { "check-updates" => { crate::updates::check(app, true); return; } "install-update" => { crate::updates::show(app); return; } _ => {} } if event.id().as_ref() == "server-settings" { show_launcher(app); } else if let Some(script) = match event.id().as_ref() { "workspace-back" => Some("window.history.back()"), "workspace-forward" => Some("window.history.forward()"), _ => None, } { let label = app .state::>() .lock() .unwrap() .label .clone(); if let Some(window) = label.and_then(|label| app.get_webview_window(&label)) { let _ = window.eval(script); } } }); let handle = app.handle().clone(); let launcher = WebviewWindowBuilder::new(app, "launcher", WebviewUrl::App("index.html".into())) .title(en::APP_NAME) .inner_size(560.0, 720.0) .min_inner_size(375.0, 640.0) .visible(false) .center() .on_navigation(move |destination| { if !allows_launcher_navigation(destination) { return false; } if destination.path().starts_with("/action/") { let handle = handle.clone(); let destination = destination.clone(); let _ = handle.clone().run_on_main_thread(move || { handle_launcher_action(&handle, &destination) }); return false; } matches!(destination.path(), "" | "/" | "/index.html") }) .on_new_window(|_, _| NewWindowResponse::Deny) .on_page_load(|window, payload| { if payload.event() == PageLoadEvent::Finished { let app = window.app_handle().clone(); update_launcher(&app); #[cfg(target_os = "macos")] crate::updates::refresh(&app); let needs_setup = app .state::>() .lock() .unwrap() .server .is_none(); if needs_setup { show_launcher(&app); } } }) .build()?; let window = launcher.clone(); launcher.on_window_event(move |event| { if let WindowEvent::CloseRequested { api, .. } = event { api.prevent_close(); let has_workspace = window .app_handle() .state::>() .lock() .unwrap() .ready; if has_workspace { let _ = window.hide(); } else { window.app_handle().exit(0); } } }); if let Some(server) = startup_server { if let Err(error) = open_workspace(app.handle(), server.url.as_str()) { app.state::>().lock().unwrap().phase = error; show_launcher(app.handle()); } } Ok(()) }) } #[cfg(test)] mod tests { use super::*; #[cfg(target_os = "macos")] #[test] fn about_shows_app_version_followed_by_the_actual_build_number() { let metadata = about_metadata("9.8.7".into()); assert_eq!(metadata.version.as_deref(), Some("9.8.7")); assert_eq!( metadata.short_version.as_deref(), Some(crate::metadata::build_number()) ); assert_ne!(metadata.version, metadata.short_version); } #[test] fn external_links_accept_only_web_urls_without_credentials() { for input in [ "https://git.example.com/org/repo/pulls/1#diff", "http://git.example.com/repo", ] { assert!(allows_external_navigation(&Url::parse(input).unwrap())); } for input in [ "https://user:secret@git.example.com/", "file:///etc/passwd", "javascript:alert(1)", "tauri://localhost/action/open", "mailto:user@example.com", "data:text/html,test", ] { assert!(!allows_external_navigation(&Url::parse(input).unwrap())); } } #[test] fn navigation_is_limited_to_the_selected_https_origin() { let server = validate_url("https://example.com:8443/app/").unwrap(); for path in [ "/login", "/two-factor-challenge", "/projects/1?ticket=52#details", ] { assert!(allows_server_navigation( &server, &server.join(path).unwrap() )); } for destination in [ "http://example.com:8443/", "https://example.com/", "https://other.example.com:8443/", "https://user:secret@example.com:8443/", "tauri://localhost/action/open?server=https://evil.test", "file:///etc/passwd", "data:text/html,hello", "javascript:alert(1)", ] { assert!(!allows_server_navigation( &server, &Url::parse(destination).unwrap() )); } } #[test] fn launcher_actions_are_restricted_to_the_bundled_origin() { assert!(allows_launcher_navigation( &Url::parse("tauri://localhost/action/open").unwrap() )); for input in [ DEFAULT_SERVER, "https://localhost/", "tauri://other/index.html", "tauri://user@localhost/index.html", ] { assert!(!allows_launcher_navigation(&Url::parse(input).unwrap())); } } #[test] fn stale_timeouts_do_not_replace_newer_connections_or_loaded_forms() { let mut state = Connection::default(); let first = state.begin_navigation(); let second = state.begin_navigation(); assert!(!state.timeout(first)); state.ready = true; assert!(state.timeout(second)); assert!(state.ready); assert!(!state.timeout(second)); let third = state.begin_navigation(); state.phase = "connected"; assert!(!state.timeout(third)); } #[test] fn remote_pages_cannot_invoke_native_commands() { let builder = tauri::test::mock_builder(); #[cfg(target_os = "macos")] let builder = builder.plugin(tauri_plugin_updater::Builder::new().build()); let app = builder.build(context()).unwrap(); let window = WebviewWindowBuilder::new( &app, "workspace-1", WebviewUrl::External(Url::parse(DEFAULT_SERVER).unwrap()), ) .build() .unwrap(); for command in [ "plugin:updater|check", "plugin:updater|download_and_install", "plugin:window|close", "plugin:webview|create_webview_window", "plugin:path|resolve_directory", ] { let response = tauri::test::get_ipc_response( &window, tauri::webview::InvokeRequest { cmd: command.into(), callback: tauri::ipc::CallbackFn(0), error: tauri::ipc::CallbackFn(1), url: Url::parse(DEFAULT_SERVER).unwrap(), body: tauri::ipc::InvokeBody::default(), headers: Default::default(), invoke_key: tauri::test::INVOKE_KEY.into(), }, ); assert!(response .unwrap_err() .as_str() .unwrap() .contains("not allowed")); } } #[test] fn packaged_app_exposes_no_native_capabilities_or_global_api() { let config: serde_json::Value = serde_json::from_str(include_str!("../tauri.conf.json")).unwrap(); assert_eq!(config["bundle"]["macOS"]["minimumSystemVersion"], "14.0"); assert_eq!(config["app"]["withGlobalTauri"], false); assert_eq!( config["app"]["security"]["capabilities"], serde_json::json!([]) ); } }