diff --git a/.gitignore b/.gitignore index 3175fdb15..6c081ead0 100644 --- a/.gitignore +++ b/.gitignore @@ -169,3 +169,6 @@ orleans.codegen.cs # Ignore GitHub Copilot migration files: **/copilot.data.migration.*.xml + +# Tauri generated schemas/manifests +/runtime/gen/ diff --git a/app/Build/Commands/UpdateMetadataCommands.cs b/app/Build/Commands/UpdateMetadataCommands.cs index 5ec929ab8..f3b0799e0 100644 --- a/app/Build/Commands/UpdateMetadataCommands.cs +++ b/app/Build/Commands/UpdateMetadataCommands.cs @@ -245,7 +245,7 @@ public async Task Build() Console.WriteLine("- Start building the Rust runtime ..."); var pathRuntime = Environment.GetRustRuntimeDirectory(); - var rustBuildOutput = await this.ReadCommandOutput(pathRuntime, "cargo", "tauri build --bundles none", true); + var rustBuildOutput = await this.ReadCommandOutput(pathRuntime, "cargo", "tauri build --no-bundle", true); var rustBuildOutputLines = rustBuildOutput.Split([global::System.Environment.NewLine], StringSplitOptions.RemoveEmptyEntries); var foundRustIssue = false; foreach (var buildOutputLine in rustBuildOutputLines) diff --git a/metadata.txt b/metadata.txt index 77e953d56..767f65f1e 100644 --- a/metadata.txt +++ b/metadata.txt @@ -5,7 +5,7 @@ 9.0.13 (commit 9ecbfd4f3f) 1.93.1 (commit 01f6ddf75) 8.15.0 -1.8.1 +2.10.3 3eb367d4c9e, release osx-arm64 144.0.7543.0 diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index 6a71f79c6..370aff29c 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -1,16 +1,16 @@ [package] name = "mindwork-ai-studio" version = "26.2.2" -edition = "2021" +edition = "2024" description = "MindWork AI Studio" authors = ["Thorsten Sommer"] [build-dependencies] -tauri-build = { version = "1.5.6", features = [] } +tauri-build = { version = "2.5.6", features = [] } [dependencies] -tauri = { version = "1.8.3", features = [ "http-all", "updater", "shell-sidecar", "shell-open", "dialog", "global-shortcut"] } -tauri-plugin-window-state = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" } +tauri = { version = "2.10.3", features = [] } +tauri-plugin-window-state = { version = "2.4.1" } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" keyring = { version = "3.6.2", features = ["apple-native", "windows-native", "sync-secret-service"] } @@ -46,6 +46,10 @@ sysinfo = "0.38.0" # Fixes security vulnerability downstream, where the upstream is not fixed yet: time = "0.3.47" # -> Rocket bytes = "1.11.1" # -> almost every dependency +tauri-plugin-fs = "2" +tauri-plugin-http = "2" +tauri-plugin-shell = "2.3.5" +tauri-plugin-dialog = "2.6.0" [target.'cfg(target_os = "linux")'.dependencies] # See issue https://github.com/tauri-apps/tauri/issues/4470 @@ -57,5 +61,9 @@ openssl = "0.10.75" [target.'cfg(target_os = "windows")'.dependencies] windows-registry = "0.6.1" +[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] +tauri-plugin-global-shortcut = "2" +tauri-plugin-updater = "2.10.0" + [features] custom-protocol = ["tauri/custom-protocol"] diff --git a/runtime/build.rs b/runtime/build.rs index c4d1f7498..80a92985a 100644 --- a/runtime/build.rs +++ b/runtime/build.rs @@ -53,6 +53,18 @@ fn update_cargo_toml(cargo_path: &str, version: &str) { let cargo_toml_lines = cargo_toml.lines(); let mut new_cargo_toml = String::new(); + // Return early when the version already matches to avoid unnecessary rewrites. + let current_version = cargo_toml.lines().find_map(|line| { + let trimmed = line.trim_start(); + let rest = trimmed.strip_prefix("\"version\": ")?; + let quoted = rest.strip_prefix('"')?; + let end_idx = quoted.find('"')?; + Some("ed[..end_idx]) + }); + if current_version == Some(version) { + return; + } + for line in cargo_toml_lines { if line.starts_with("version = ") { new_cargo_toml.push_str(&format!("version = \"{version}\"")); @@ -67,6 +79,19 @@ fn update_cargo_toml(cargo_path: &str, version: &str) { fn update_tauri_conf(tauri_conf_path: &str, version: &str) { let tauri_conf = std::fs::read_to_string(tauri_conf_path).unwrap(); + + // Return early when the version already matches to avoid unnecessary rewrites. + let current_version = tauri_conf.lines().find_map(|line| { + let trimmed = line.trim_start(); + let rest = trimmed.strip_prefix("\"version\": ")?; + let quoted = rest.strip_prefix('"')?; + let end_idx = quoted.find('"')?; + Some("ed[..end_idx]) + }); + if current_version == Some(version) { + return; + } + let tauri_conf_lines = tauri_conf.lines(); let mut new_tauri_conf = String::new(); @@ -75,7 +100,7 @@ fn update_tauri_conf(tauri_conf_path: &str, version: &str) { // "version": "0.1.0-alpha.0" // Please notice, that the version number line might have a leading tab, etc. if line.contains("\"version\": ") { - new_tauri_conf.push_str(&format!("\t\"version\": \"{version}\"")); + new_tauri_conf.push_str(&format!(" \"version\": \"{version}\",")); } else { new_tauri_conf.push_str(line); } diff --git a/runtime/capabilities/default.json b/runtime/capabilities/default.json new file mode 100644 index 000000000..e9825c5ae --- /dev/null +++ b/runtime/capabilities/default.json @@ -0,0 +1,48 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Default capability for MindWork AI Studio", + "windows": [ + "main" + ], + "permissions": [ + "core:default", + "updater:default", + "shell:allow-open", + { + "identifier": "shell:allow-spawn", + "allow": [ + { + "name": "mindworkAIStudioServer", + "sidecar": true, + "args": true + }, + { + "name": "qdrant", + "sidecar": true, + "args": true + } + ] + }, + { + "identifier": "http:default", + "allow": [ + { + "url": "http://localhost" + }, + { + "url": "http://localhost/*" + } + ] + }, + "fs:default", + { + "identifier": "fs:scope", + "allow": [ + { + "path": "$RESOURCE/resources/**" + } + ] + } + ] +} diff --git a/runtime/src/app_window.rs b/runtime/src/app_window.rs index c7e7bd740..9fe7bcf39 100644 --- a/runtime/src/app_window.rs +++ b/runtime/src/app_window.rs @@ -9,9 +9,13 @@ use rocket::serde::json::Json; use rocket::serde::Serialize; use serde::Deserialize; use strum_macros::Display; -use tauri::updater::UpdateResponse; -use tauri::{FileDropEvent, GlobalShortcutManager, UpdaterEvent, RunEvent, Manager, PathResolver, Window, WindowEvent, generate_context}; -use tauri::api::dialog::blocking::FileDialogBuilder; +use tauri::{DragDropEvent,RunEvent, Manager, WindowEvent, generate_context}; +use tauri::path::PathResolver; +use tauri::WebviewWindow; +use tauri_plugin_dialog::{DialogExt, FileDialogBuilder}; +use tauri_plugin_updater::{UpdaterExt, Update}; +use tauri_plugin_global_shortcut::GlobalShortcutExt; +use tauri_plugin_shell::ShellExt; use tokio::sync::broadcast; use tokio::time; use crate::api_token::APIToken; @@ -24,10 +28,10 @@ use crate::qdrant::{cleanup_qdrant, start_qdrant_server, stop_qdrant_server}; use crate::dotnet::create_startup_env_file; /// The Tauri main window. -static MAIN_WINDOW: Lazy>> = Lazy::new(|| Mutex::new(None)); +static MAIN_WINDOW: Lazy>> = Lazy::new(|| Mutex::new(None)); /// The update response coming from the Tauri updater. -static CHECK_UPDATE_RESPONSE: Lazy>>> = Lazy::new(|| Mutex::new(None)); +static CHECK_UPDATE_RESPONSE: Lazy>> = Lazy::new(|| Mutex::new(None)); /// The event broadcast sender for Tauri events. static EVENT_BROADCAST: Lazy>>> = Lazy::new(|| Mutex::new(None)); @@ -76,10 +80,33 @@ pub fn start_tauri() { }); let app = tauri::Builder::default() + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_shell::init()) + .plugin( + tauri::plugin::Builder::::new("external-link-handler") + .on_navigation(|webview, url| { + if !should_open_in_system_browser(webview, url) { + return true; + } + + match webview.app_handle().shell().open(url.as_str(), None) { + Ok(_) => { + info!(Source = "Tauri"; "Opening external URL in system browser: {url}"); + }, + Err(error) => { + error!(Source = "Tauri"; "Failed to open external URL '{url}' in system browser: {error}"); + }, + } + false + }) + .build(), + ) + .plugin(tauri_plugin_global_shortcut::Builder::new().build()) + .plugin(tauri_plugin_updater::Builder::new().build()) .setup(move |app| { // Get the main window: - let window = app.get_window("main").expect("Failed to get main window."); + let window = app.get_webview_window("main").expect("Failed to get main window."); // Register a callback for window events, such as file drops. We have to use // this handler in addition to the app event handler, because file drop events @@ -100,12 +127,12 @@ pub fn start_tauri() { *MAIN_WINDOW.lock().unwrap() = Some(window); info!(Source = "Bootloader Tauri"; "Setup is running."); - let data_path = app.path_resolver().app_local_data_dir().unwrap(); + let data_path = app.path().app_local_data_dir().unwrap(); let data_path = data_path.join("data"); // Get and store the data and config directories: DATA_DIRECTORY.set(data_path.to_str().unwrap().to_string()).map_err(|_| error!("Was not able to set the data directory.")).unwrap(); - CONFIG_DIRECTORY.set(app.path_resolver().app_config_dir().unwrap().to_str().unwrap().to_string()).map_err(|_| error!("Was not able to set the config directory.")).unwrap(); + CONFIG_DIRECTORY.set(app.path().app_config_dir().unwrap().to_str().unwrap().to_string()).map_err(|_| error!("Was not able to set the config directory.")).unwrap(); cleanup_qdrant(); cleanup_dotnet_server(); @@ -114,13 +141,13 @@ pub fn start_tauri() { #[cfg(debug_assertions)] create_startup_env_file(); } else { - start_dotnet_server(); + start_dotnet_server(app.handle().clone()); } - start_qdrant_server(); + start_qdrant_server(app.handle().clone()); info!(Source = "Bootloader Tauri"; "Reconfigure the file logger to use the app data directory {data_path:?}"); switch_to_file_logging(data_path).map_err(|e| error!("Failed to switch logging to file: {e}")).unwrap(); - set_pdfium_path(app.path_resolver()); + set_pdfium_path(app.path()); Ok(()) }) @@ -129,7 +156,7 @@ pub fn start_tauri() { .expect("Error while running Tauri application"); // The app event handler: - app.run(|app_handle, event| { + app.run(|_app_handle, event| { if !matches!(event, RunEvent::MainEventsCleared) { debug!(Source = "Tauri"; "Tauri event received: location=app event handler , event={event:?}"); } @@ -149,54 +176,6 @@ pub fn start_tauri() { } } - RunEvent::Updater(updater_event) => { - match updater_event { - UpdaterEvent::UpdateAvailable { body, date, version } => { - let body_len = body.len(); - info!(Source = "Tauri"; "Updater: update available: body size={body_len} time={date:?} version={version}"); - } - - UpdaterEvent::Pending => { - info!(Source = "Tauri"; "Updater: update is pending!"); - } - - UpdaterEvent::DownloadProgress { chunk_length, content_length: _ } => { - trace!(Source = "Tauri"; "Updater: downloading chunk of {chunk_length} bytes"); - } - - UpdaterEvent::Downloaded => { - info!(Source = "Tauri"; "Updater: update has been downloaded!"); - warn!(Source = "Tauri"; "Try to stop the .NET server now..."); - - if is_prod() { - stop_dotnet_server(); - stop_qdrant_server(); - } else { - warn!(Source = "Tauri"; "Development environment detected; do not stop the .NET server."); - } - } - - UpdaterEvent::Updated => { - info!(Source = "Tauri"; "Updater: app has been updated"); - warn!(Source = "Tauri"; "Try to restart the app now..."); - - if is_prod() { - app_handle.restart(); - } else { - warn!(Source = "Tauri"; "Development environment detected; do not restart the app."); - } - } - - UpdaterEvent::AlreadyUpToDate => { - info!(Source = "Tauri"; "Updater: app is already up to date"); - } - - UpdaterEvent::Error(error) => { - warn!(Source = "Tauri"; "Updater: failed to update: {error}"); - } - } - } - RunEvent::ExitRequested { .. } => { warn!(Source = "Tauri"; "Run event: exit was requested."); stop_qdrant_server(); @@ -217,6 +196,27 @@ pub fn start_tauri() { warn!(Source = "Tauri"; "Tauri app was stopped."); } +fn is_local_host(host: Option<&str>) -> bool { + matches!(host, Some("localhost") | Some("127.0.0.1") | Some("::1") | Some("[::1]")) +} + +fn should_open_in_system_browser(webview: &tauri::Webview, url: &tauri::Url) -> bool { + if !matches!(url.scheme(), "http" | "https") { + return false; + } + + if let Ok(current_url) = webview.url() { + let same_origin = current_url.scheme() == url.scheme() + && current_url.host_str() == url.host_str() + && current_url.port_or_known_default() == url.port_or_known_default(); + if same_origin { + return false; + } + } + + !is_local_host(url.host_str()) +} + /// Our event API endpoint for Tauri events. We try to send an endless stream of events to the client. /// If no events are available for a certain time, we send a ping event to keep the connection alive. /// When the client disconnects, the stream is closed. But we try to not lose events in between. @@ -303,23 +303,21 @@ impl Event { /// Creates an Event instance from a Tauri WindowEvent. pub fn from_window_event(window_event: &WindowEvent) -> Self { match window_event { - WindowEvent::FileDrop(drop_event) => { + WindowEvent::DragDrop(drop_event) => { match drop_event { - FileDropEvent::Hovered(files) => Event::new(TauriEventType::FileDropHovered, - files.iter().map(|f| f.to_string_lossy().to_string()).collect(), + DragDropEvent::Enter { paths, .. } => Event::new( + TauriEventType::FileDropHovered, + paths.iter().map(|p| p.display().to_string()).collect(), ), - FileDropEvent::Dropped(files) => Event::new(TauriEventType::FileDropDropped, - files.iter().map(|f| f.to_string_lossy().to_string()).collect(), + DragDropEvent::Drop { paths, .. } => Event::new( + TauriEventType::FileDropDropped, + paths.iter().map(|p| p.display().to_string()).collect(), ), - FileDropEvent::Cancelled => Event::new(TauriEventType::FileDropCanceled, - Vec::new(), - ), + DragDropEvent::Leave => Event::new(TauriEventType::FileDropCanceled, Vec::new()), - _ => Event::new(TauriEventType::Unknown, - Vec::new(), - ), + _ => Event::new(TauriEventType::Unknown, Vec::new()), } }, @@ -402,46 +400,67 @@ pub async fn check_for_update(_token: APIToken) -> Json { }); } - let app_handle = MAIN_WINDOW.lock().unwrap().as_ref().unwrap().app_handle(); - let response = app_handle.updater().check().await; - match response { - Ok(update_response) => match update_response.is_update_available() { - true => { - *CHECK_UPDATE_RESPONSE.lock().unwrap() = Some(update_response.clone()); - let new_version = update_response.latest_version(); - info!(Source = "Updater"; "An update to version '{new_version}' is available."); - let changelog = update_response.body(); - Json(CheckUpdateResponse { - update_is_available: true, - error: false, - new_version: new_version.to_string(), - changelog: match changelog { - Some(c) => c.to_string(), - None => String::from(""), - }, - }) - }, - - false => { - info!(Source = "Updater"; "No updates are available."); - Json(CheckUpdateResponse { + let app_handle = { + let main_window = MAIN_WINDOW.lock().unwrap(); + match main_window.as_ref() { + Some(window) => window.app_handle().clone(), + None => { + error!(Source = "Updater"; "Cannot check updates: main window not available."); + return Json(CheckUpdateResponse { update_is_available: false, - error: false, + error: true, new_version: String::from(""), changelog: String::from(""), - }) - }, - }, + }); + } + } + }; + let response = match app_handle.updater() { + Ok(updater) => updater.check().await, + Err(e) => { + warn!(Source = "Updater"; "Failed to get updater instance: {e}"); + return Json(CheckUpdateResponse { + update_is_available: false, + error: true, + new_version: String::from(""), + changelog: String::from(""), + }); + } + }; + match response { + Ok(Some(update)) => { + let body_len = update.body.as_ref().map_or(0, |body| body.len()); + let date = update.date; + let new_version = update.version.clone(); + info!(Source = "Tauri"; "Updater: update available: body size={body_len} time={date:?} version={new_version}"); + let changelog = update.body.clone().unwrap_or_default(); + *CHECK_UPDATE_RESPONSE.lock().unwrap() = Some(update); + Json(CheckUpdateResponse { + update_is_available: true, + error: false, + new_version, + changelog, + }) + } + Ok(None) => { + info!(Source = "Tauri"; "Updater: app is already up to date"); + Json(CheckUpdateResponse { + update_is_available: false, + error: false, + new_version: String::from(""), + changelog: String::from(""), + }) + } Err(e) => { - warn!(Source = "Updater"; "Failed to check for updates: {e}."); + warn!(Source = "Tauri"; "Updater: failed to update: {e}"); Json(CheckUpdateResponse { update_is_available: false, error: true, new_version: String::from(""), changelog: String::from(""), }) - }, + } } } @@ -463,9 +482,51 @@ pub async fn install_update(_token: APIToken) { } let cloned_response_option = CHECK_UPDATE_RESPONSE.lock().unwrap().clone(); + let app_handle = MAIN_WINDOW + .lock() + .unwrap() + .as_ref() + .map(|window| window.app_handle().clone()); + match cloned_response_option { Some(update_response) => { - update_response.download_and_install().await.unwrap(); + info!(Source = "Tauri"; "Updater: update is pending!"); + let result = update_response.download_and_install( + |chunk_length, _content_length| { + trace!(Source = "Tauri"; "Updater: downloading chunk of {chunk_length} bytes"); + }, + || { + info!(Source = "Tauri"; "Updater: update has been downloaded!"); + warn!(Source = "Tauri"; "Try to stop the .NET server now..."); + + if is_prod() { + stop_dotnet_server(); + stop_qdrant_server(); + } else { + warn!(Source = "Tauri"; "Development environment detected; do not stop the .NET server."); + } + }, + ).await; + + match result { + Ok(_) => { + info!(Source = "Tauri"; "Updater: app has been updated"); + warn!(Source = "Tauri"; "Try to restart the app now..."); + + if is_prod() { + if let Some(handle) = app_handle { + handle.restart(); + } else { + warn!(Source = "Tauri"; "Cannot restart after update: main window not available."); + } + } else { + warn!(Source = "Tauri"; "Development environment detected; do not restart the app."); + } + } + Err(e) => { + warn!(Source = "Tauri"; "Updater: failed to update: {e}"); + } + } }, None => { @@ -477,29 +538,43 @@ pub async fn install_update(_token: APIToken) { /// Let the user select a directory. #[post("/select/directory?", data = "<previous_directory>")] pub fn select_directory(_token: APIToken, title: &str, previous_directory: Option<Json<PreviousDirectory>>) -> Json<DirectorySelectionResponse> { - let folder_path = match previous_directory { - Some(previous) => { - let previous_path = previous.path.as_str(); - FileDialogBuilder::new() - .set_title(title) - .set_directory(previous_path) - .pick_folder() - }, - + let main_window_lock = MAIN_WINDOW.lock().unwrap(); + let main_window = match main_window_lock.as_ref() { + Some(window) => window, None => { - FileDialogBuilder::new() - .set_title(title) - .pick_folder() - }, + error!(Source = "Tauri"; "Cannot open directory dialog: main window not available."); + return Json(DirectorySelectionResponse { + user_cancelled: true, + selected_directory: String::from(""), + }); + } }; + + let mut dialog = main_window.app_handle().dialog().file().set_title(title); + if let Some(previous) = previous_directory { + dialog = dialog.set_directory(previous.path.clone()); + } + + let folder_path = dialog.blocking_pick_folder(); match folder_path { Some(path) => { - info!("User selected directory: {path:?}"); - Json(DirectorySelectionResponse { - user_cancelled: false, - selected_directory: path.to_str().unwrap().to_string(), - }) + match path.into_path() { + Ok(pb) => { + info!("User selected directory: {pb:?}"); + Json(DirectorySelectionResponse { + user_cancelled: false, + selected_directory: pb.to_string_lossy().to_string(), + }) + } + Err(e) => { + error!(Source = "Tauri"; "Failed to convert directory path: {e}"); + Json(DirectorySelectionResponse { + user_cancelled: true, + selected_directory: String::new(), + }) + } + } }, None => { @@ -548,33 +623,47 @@ pub struct DirectorySelectionResponse { pub fn select_file(_token: APIToken, payload: Json<SelectFileOptions>) -> Json<FileSelectionResponse> { // Create a new file dialog builder: - let file_dialog = FileDialogBuilder::new(); - - // Set the title of the file dialog: - let file_dialog = file_dialog.set_title(&payload.title); + let file_dialog = MAIN_WINDOW + .lock() + .unwrap() + .as_ref() + .map(|w| w.app_handle().dialog().file().set_title(&payload.title)); + + let Some(mut file_dialog) = file_dialog else { + error!(Source = "Tauri"; "Cannot open file dialog: main window not available."); + return Json(FileSelectionResponse { + user_cancelled: true, + selected_file_path: String::from(""), + }); + }; // Set the file type filter if provided: - let file_dialog = apply_filter(file_dialog, &payload.filter); + file_dialog = apply_filter(file_dialog, &payload.filter); // Set the previous file path if provided: - let file_dialog = match &payload.previous_file { - Some(previous) => { - let previous_path = previous.file_path.as_str(); - file_dialog.set_directory(previous_path) - }, - - None => file_dialog, - }; + if let Some(previous) = &payload.previous_file { + let previous_path = previous.file_path.as_str(); + file_dialog = file_dialog.set_directory(previous_path); + } // Show the file dialog and get the selected file path: - let file_path = file_dialog.pick_file(); + let file_path = file_dialog.blocking_pick_file(); match file_path { - Some(path) => { - info!("User selected file: {path:?}"); - Json(FileSelectionResponse { - user_cancelled: false, - selected_file_path: path.to_str().unwrap().to_string(), - }) + Some(path) => match path.into_path() { + Ok(pb) => { + info!("User selected file: {pb:?}"); + Json(FileSelectionResponse { + user_cancelled: false, + selected_file_path: pb.to_string_lossy().to_string(), + }) + } + Err(e) => { + error!(Source = "Tauri"; "Failed to convert file path: {e}"); + Json(FileSelectionResponse { + user_cancelled: true, + selected_file_path: String::new(), + }) + } }, None => { @@ -592,32 +681,38 @@ pub fn select_file(_token: APIToken, payload: Json<SelectFileOptions>) -> Json<F pub fn select_files(_token: APIToken, payload: Json<SelectFileOptions>) -> Json<FilesSelectionResponse> { // Create a new file dialog builder: - let file_dialog = FileDialogBuilder::new(); - - // Set the title of the file dialog: - let file_dialog = file_dialog.set_title(&payload.title); + let file_dialog = MAIN_WINDOW + .lock() + .unwrap() + .as_ref() + .map(|w| w.app_handle().dialog().file().set_title(&payload.title)); + + let Some(mut file_dialog) = file_dialog else { + error!(Source = "Tauri"; "Cannot open file dialog: main window not available."); + return Json(FilesSelectionResponse { + user_cancelled: true, + selected_file_paths: Vec::new(), + }); + }; // Set the file type filter if provided: - let file_dialog = apply_filter(file_dialog, &payload.filter); + file_dialog = apply_filter(file_dialog, &payload.filter); // Set the previous file path if provided: - let file_dialog = match &payload.previous_file { - Some(previous) => { - let previous_path = previous.file_path.as_str(); - file_dialog.set_directory(previous_path) - }, - - None => file_dialog, - }; + if let Some(previous) = &payload.previous_file { + let previous_path = previous.file_path.as_str(); + file_dialog = file_dialog.set_directory(previous_path); + } // Show the file dialog and get the selected file path: - let file_paths = file_dialog.pick_files(); + let file_paths = file_dialog.blocking_pick_files(); match file_paths { Some(paths) => { - info!("User selected {} files.", paths.len()); + let converted: Vec<String> = paths.into_iter().filter_map(|p| p.into_path().ok()).map(|pb| pb.to_string_lossy().to_string()).collect(); + info!("User selected {} files.", converted.len()); Json(FilesSelectionResponse { user_cancelled: false, - selected_file_paths: paths.iter().map(|p| p.to_str().unwrap().to_string()).collect(), + selected_file_paths: converted, }) } @@ -635,33 +730,47 @@ pub fn select_files(_token: APIToken, payload: Json<SelectFileOptions>) -> Json< pub fn save_file(_token: APIToken, payload: Json<SaveFileOptions>) -> Json<FileSaveResponse> { // Create a new file dialog builder: - let file_dialog = FileDialogBuilder::new(); - - // Set the title of the file dialog: - let file_dialog = file_dialog.set_title(&payload.title); + let file_dialog = MAIN_WINDOW + .lock() + .unwrap() + .as_ref() + .map(|w| w.app_handle().dialog().file().set_title(&payload.title)); + + let Some(mut file_dialog) = file_dialog else { + error!(Source = "Tauri"; "Cannot open save dialog: main window not available."); + return Json(FileSaveResponse { + user_cancelled: true, + save_file_path: String::from(""), + }); + }; // Set the file type filter if provided: - let file_dialog = apply_filter(file_dialog, &payload.filter); + file_dialog = apply_filter(file_dialog, &payload.filter); // Set the previous file path if provided: - let file_dialog = match &payload.name_file { - Some(previous) => { - let previous_path = previous.file_path.as_str(); - file_dialog.set_directory(previous_path) - }, - - None => file_dialog, - }; + if let Some(previous) = &payload.name_file { + let previous_path = previous.file_path.as_str(); + file_dialog = file_dialog.set_directory(previous_path); + } // Displays the file dialogue box and select the file: - let file_path = file_dialog.save_file(); + let file_path = file_dialog.blocking_save_file(); match file_path { - Some(path) => { - info!("User selected file for writing operation: {path:?}"); - Json(FileSaveResponse { - user_cancelled: false, - save_file_path: path.to_str().unwrap().to_string(), - }) + Some(path) => match path.into_path() { + Ok(pb) => { + info!("User selected file for writing operation: {pb:?}"); + Json(FileSaveResponse { + user_cancelled: false, + save_file_path: pb.to_string_lossy().to_string(), + }) + } + Err(e) => { + error!(Source = "Tauri"; "Failed to convert save file path: {e}"); + Json(FileSaveResponse { + user_cancelled: true, + save_file_path: String::new(), + }) + } }, None => { @@ -680,7 +789,7 @@ pub struct PreviousFile { } /// Applies an optional file type filter to a FileDialogBuilder. -fn apply_filter(file_dialog: FileDialogBuilder, filter: &Option<FileTypeFilter>) -> FileDialogBuilder { +fn apply_filter<R: tauri::Runtime>(file_dialog: FileDialogBuilder<R>, filter: &Option<FileTypeFilter>) -> FileDialogBuilder<R> { match filter { Some(f) => file_dialog.add_filter( &f.filter_name, @@ -730,29 +839,23 @@ pub struct ShortcutResponse { /// Internal helper function to register a shortcut with its callback. /// This is used by both `register_shortcut` and `resume_shortcuts` to /// avoid code duplication. -fn register_shortcut_with_callback( - shortcut_manager: &mut impl GlobalShortcutManager, +fn register_shortcut_with_callback<R: tauri::Runtime>( + app_handle: &tauri::AppHandle<R>, shortcut: &str, shortcut_id: Shortcut, event_sender: broadcast::Sender<Event>, -) -> Result<(), tauri::Error> { - // - // Match the shortcut registration to transform the Tauri result into the Rust result: - // - match shortcut_manager.register(shortcut, move || { +) -> Result<(), tauri_plugin_global_shortcut::Error> { + let shortcut_manager = app_handle.global_shortcut(); + shortcut_manager.on_shortcut(shortcut, move |_app, _shortcut, _event| { info!(Source = "Tauri"; "Global shortcut triggered for '{}'.", shortcut_id); let event = Event::new(TauriEventType::GlobalShortcutPressed, vec![shortcut_id.to_string()]); let sender = event_sender.clone(); tauri::async_runtime::spawn(async move { - match sender.send(event) { - Ok(_) => {} - Err(error) => error!(Source = "Tauri"; "Failed to send global shortcut event: {error}"), + if let Err(error) = sender.send(event) { + error!(Source = "Tauri"; "Failed to send global shortcut event: {error}"); } }); - }) { - Ok(_) => Ok(()), - Err(e) => Err(e.into()), - } + }) } /// Registers or updates a global shortcut. If the shortcut string is empty, @@ -785,7 +888,8 @@ pub fn register_shortcut(_token: APIToken, payload: Json<RegisterShortcutRequest } }; - let mut shortcut_manager = main_window.app_handle().global_shortcut_manager(); + let app_handle = main_window.app_handle(); + let shortcut_manager = app_handle.global_shortcut(); let mut registered_shortcuts = REGISTERED_SHORTCUTS.lock().unwrap(); // Unregister the old shortcut if one exists for this name: @@ -824,7 +928,7 @@ pub fn register_shortcut(_token: APIToken, payload: Json<RegisterShortcutRequest drop(event_broadcast_lock); // Register the new shortcut: - match register_shortcut_with_callback(&mut shortcut_manager, &new_shortcut, id, event_sender) { + match register_shortcut_with_callback(app_handle, &new_shortcut, id, event_sender) { Ok(_) => { info!(Source = "Tauri"; "Global shortcut '{new_shortcut}' registered successfully for '{}'.", id); registered_shortcuts.insert(id, new_shortcut); @@ -934,7 +1038,8 @@ pub fn suspend_shortcuts(_token: APIToken) -> Json<ShortcutResponse> { } }; - let mut shortcut_manager = main_window.app_handle().global_shortcut_manager(); + let app_handle = main_window.app_handle(); + let shortcut_manager = app_handle.global_shortcut(); let registered_shortcuts = REGISTERED_SHORTCUTS.lock().unwrap(); // Unregister all shortcuts from the OS (but keep them in our map): @@ -970,7 +1075,7 @@ pub fn resume_shortcuts(_token: APIToken) -> Json<ShortcutResponse> { } }; - let mut shortcut_manager = main_window.app_handle().global_shortcut_manager(); + let app_handle = main_window.app_handle(); let registered_shortcuts = REGISTERED_SHORTCUTS.lock().unwrap(); // Get the event broadcast sender for the shortcut callbacks: @@ -995,7 +1100,7 @@ pub fn resume_shortcuts(_token: APIToken) -> Json<ShortcutResponse> { continue; } - match register_shortcut_with_callback(&mut shortcut_manager, shortcut, *shortcut_id, event_sender.clone()) { + match register_shortcut_with_callback(&app_handle, shortcut, *shortcut_id, event_sender.clone()) { Ok(_) => { info!(Source = "Tauri"; "Re-registered shortcut '{shortcut}' for '{}'.", shortcut_id); success_count += 1; @@ -1056,15 +1161,31 @@ fn validate_shortcut_syntax(shortcut: &str) -> bool { has_key } -fn set_pdfium_path(path_resolver: PathResolver) { - let pdfium_relative_source_path = String::from("resources/libraries/"); - let pdfium_source_path = path_resolver.resolve_resource(pdfium_relative_source_path); - if pdfium_source_path.is_none() { - error!(Source = "Bootloader Tauri"; "Failed to set the PDFium library path."); - return; - } +fn set_pdfium_path<R: tauri::Runtime>(path_resolver: &PathResolver<R>) { + let resource_dir = match path_resolver.resource_dir() { + Ok(path) => path, + Err(error) => { + error!(Source = "Bootloader Tauri"; "Failed to resolve resource dir: {error}"); + return; + } + }; + + let candidate_paths = [ + resource_dir.join("resources").join("libraries"), + resource_dir.join("libraries"), + ]; + + let pdfium_source_path = candidate_paths + .iter() + .find(|path| path.exists()) + .map(|path| path.to_string_lossy().to_string()); - let pdfium_source_path = pdfium_source_path.unwrap(); - let pdfium_source_path = pdfium_source_path.to_str().unwrap().to_string(); - *PDFIUM_LIB_PATH.lock().unwrap() = Some(pdfium_source_path.clone()); -} \ No newline at end of file + match pdfium_source_path { + Some(path) => { + *PDFIUM_LIB_PATH.lock().unwrap() = Some(path); + } + None => { + error!(Source = "Bootloader Tauri"; "Failed to set the PDFium library path."); + } + } +} diff --git a/runtime/src/dotnet.rs b/runtime/src/dotnet.rs index 11cc3db5a..7cca4599e 100644 --- a/runtime/src/dotnet.rs +++ b/runtime/src/dotnet.rs @@ -6,8 +6,9 @@ use base64::prelude::BASE64_STANDARD; use log::{error, info, warn}; use once_cell::sync::Lazy; use rocket::get; -use tauri::api::process::{Command, CommandChild, CommandEvent}; use tauri::Url; +use tauri_plugin_shell::process::{CommandChild, CommandEvent}; +use tauri_plugin_shell::ShellExt; use crate::api_token::APIToken; use crate::runtime_api_token::API_TOKEN; use crate::app_window::change_location_to; @@ -130,14 +131,14 @@ pub fn create_startup_env_file() { } /// Starts the .NET server in a separate process. -pub fn start_dotnet_server() { +pub fn start_dotnet_server<R: tauri::Runtime>(app_handle: tauri::AppHandle<R>) { // Get the secret password & salt and convert it to a base64 string: let secret_password = BASE64_STANDARD.encode(ENCRYPTION.secret_password); let secret_key_salt = BASE64_STANDARD.encode(ENCRYPTION.secret_key_salt); let api_port = *API_SERVER_PORT; - let dotnet_server_environment = HashMap::from_iter([ + let dotnet_server_environment: HashMap<String, String> = HashMap::from_iter([ (String::from("AI_STUDIO_SECRET_PASSWORD"), secret_password), (String::from("AI_STUDIO_SECRET_KEY_SALT"), secret_key_salt), (String::from("AI_STUDIO_CERTIFICATE_FINGERPRINT"), CERTIFICATE_FINGERPRINT.get().unwrap().to_string()), @@ -148,11 +149,13 @@ pub fn start_dotnet_server() { info!("Try to start the .NET server..."); let server_spawn_clone = DOTNET_SERVER.clone(); tauri::async_runtime::spawn(async move { - let (mut rx, child) = Command::new_sidecar("mindworkAIStudioServer") - .expect("Failed to create sidecar") - .envs(dotnet_server_environment) - .spawn() - .expect("Failed to spawn .NET server process."); + let shell = app_handle.shell(); + let (mut rx, child) = shell + .sidecar("mindworkAIStudioServer") + .expect("Failed to create sidecar") + .envs(dotnet_server_environment) + .spawn() + .expect("Failed to spawn .NET server process."); let server_pid = child.pid(); info!(Source = "Bootloader .NET"; "The .NET server process started with PID={server_pid}."); log_potential_stale_process(Path::new(DATA_DIRECTORY.get().unwrap()).join(PID_FILE_NAME), server_pid, SIDECAR_TYPE); @@ -163,10 +166,13 @@ pub fn start_dotnet_server() { // Log the output of the .NET server: // NOTE: Log events are sent via structured HTTP API calls. // This loop serves for fundamental output (e.g., startup errors). - while let Some(CommandEvent::Stdout(line)) = rx.recv().await { - let line = sanitize_stdout_line(line.trim_end()); - if !line.trim().is_empty() { - info!(Source = ".NET Server (stdout)"; "{line}"); + while let Some(event) = rx.recv().await { + if let CommandEvent::Stdout(line) = event { + let line_utf8 = String::from_utf8_lossy(&line).to_string(); + let line = sanitize_stdout_line(line_utf8.trim_end()); + if !line.trim().is_empty() { + info!(Source = ".NET Server (stdout)"; "{line}"); + } } } }); diff --git a/runtime/src/qdrant.rs b/runtime/src/qdrant.rs index 41429431f..0ce0d9dbb 100644 --- a/runtime/src/qdrant.rs +++ b/runtime/src/qdrant.rs @@ -10,7 +10,6 @@ use once_cell::sync::Lazy; use rocket::get; use rocket::serde::json::Json; use rocket::serde::Serialize; -use tauri::api::process::{Command, CommandChild, CommandEvent}; use crate::api_token::{APIToken}; use crate::environment::DATA_DIRECTORY; use crate::certificate_factory::generate_certificate; @@ -18,6 +17,8 @@ use std::path::PathBuf; use tempfile::{TempDir, Builder}; use crate::stale_process_cleanup::{kill_stale_process, log_potential_stale_process}; use crate::sidecar_types::SidecarType; +use tauri_plugin_shell::process::{CommandChild, CommandEvent}; +use tauri_plugin_shell::ShellExt; // Qdrant server process started in a separate process and can communicate // via HTTP or gRPC with the .NET server and the runtime process @@ -63,7 +64,7 @@ pub fn qdrant_port(_token: APIToken) -> Json<ProvideQdrantInfo> { } /// Starts the Qdrant server in a separate process. -pub fn start_qdrant_server(){ +pub fn start_qdrant_server<R: tauri::Runtime>(app_handle: tauri::AppHandle<R>){ let base_path = DATA_DIRECTORY.get().unwrap(); let path = Path::new(base_path).join("databases").join("qdrant"); @@ -78,7 +79,7 @@ pub fn start_qdrant_server(){ let snapshot_path = path.join("snapshots").to_str().unwrap().to_string(); let init_path = path.join(".qdrant-initalized").to_str().unwrap().to_string(); - let qdrant_server_environment = HashMap::from_iter([ + let qdrant_server_environment: HashMap<String, String> = HashMap::from_iter([ (String::from("QDRANT__SERVICE__HTTP_PORT"), QDRANT_SERVER_PORT_HTTP.to_string()), (String::from("QDRANT__SERVICE__GRPC_PORT"), QDRANT_SERVER_PORT_GRPC.to_string()), (String::from("QDRANT_INIT_FILE_PATH"), init_path), @@ -92,7 +93,9 @@ pub fn start_qdrant_server(){ let server_spawn_clone = QDRANT_SERVER.clone(); tauri::async_runtime::spawn(async move { - let (mut rx, child) = Command::new_sidecar("qdrant") + let shell = app_handle.shell(); + let (mut rx, child) = shell + .sidecar("qdrant") .expect("Failed to create sidecar for Qdrant") .args(["--config-path", "resources/databases/qdrant/config.yaml"]) .envs(qdrant_server_environment) @@ -110,7 +113,8 @@ pub fn start_qdrant_server(){ while let Some(event) = rx.recv().await { match event { CommandEvent::Stdout(line) => { - let line = line.trim_end(); + let line_utf8 = String::from_utf8_lossy(&line).to_string(); + let line = line_utf8.trim_end(); if line.contains("INFO") || line.contains("info") { info!(Source = "Qdrant Server"; "{line}"); } else if line.contains("WARN") || line.contains("warning") { @@ -123,7 +127,8 @@ pub fn start_qdrant_server(){ }, CommandEvent::Stderr(line) => { - error!(Source = "Qdrant Server (stderr)"; "{line}"); + let line_utf8 = String::from_utf8_lossy(&line).to_string(); + error!(Source = "Qdrant Server (stderr)"; "{line_utf8}"); }, _ => {} diff --git a/runtime/tauri.conf.json b/runtime/tauri.conf.json index 27e0aae7d..042ee450a 100644 --- a/runtime/tauri.conf.json +++ b/runtime/tauri.conf.json @@ -1,44 +1,47 @@ { "build": { - "devPath": "ui/", - "distDir": "ui/", - "withGlobalTauri": false + "frontendDist": "ui/" }, - "package": { - "productName": "MindWork AI Studio", - "version": "26.2.2" + "bundle": { + "active": true, + "targets": "all", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ], + "externalBin": [ + "../app/MindWork AI Studio/bin/dist/mindworkAIStudioServer", + "target/databases/qdrant/qdrant" + ], + "resources": [ + "resources/databases/qdrant/config.yaml", + "resources/libraries/*" + ], + "macOS": { + "exceptionDomain": "localhost" + }, + "createUpdaterArtifacts": "v1Compatible" }, - "tauri": { - "allowlist": { - "all": false, - "shell": { - "sidecar": true, - "all": false, - "open": true, - "scope": [ - { - "name": "../app/MindWork AI Studio/bin/dist/mindworkAIStudioServer", - "sidecar": true, - "args": true - }, - { - "name": "target/databases/qdrant/qdrant", - "sidecar": true, - "args": true - } - ] - }, - "http" : { - "all": true, - "request": true, - "scope": [ - "http://localhost" - ] + "productName": "MindWork AI Studio", + "mainBinaryName": "MindWork AI Studio", + "version": "26.2.2", + "identifier": "com.github.mindwork-ai.ai-studio", + "plugins": { + "updater": { + "windows": { + "installMode": "passive" }, - "fs": { - "scope": ["$RESOURCE/resources/*"] - } - }, + "endpoints": [ + "https://github.com/MindWorkAI/AI-Studio/releases/latest/download/latest.json" + ], + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDM3MzE4MTM4RTNDMkM0NEQKUldSTnhNTGpPSUV4TjFkczFxRFJOZWgydzFQN1dmaFlKbXhJS1YyR1RKS1RnR09jYUpMaGsrWXYK" + } + }, + "app": { + "withGlobalTauri": false, "windows": [ { "fullscreen": false, @@ -46,51 +49,12 @@ "title": "MindWork AI Studio", "width": 1920, "height": 1080, - "fileDropEnabled": true + "dragDropEnabled": true, + "useHttpsScheme": true } ], "security": { - "csp": null, - "dangerousRemoteDomainIpcAccess": [ - { - "domain": "localhost", - "windows": ["main"], - "enableTauriAPI": true - } - ] - }, - "bundle": { - "active": true, - "targets": "all", - "identifier": "com.github.mindwork-ai.ai-studio", - "externalBin": [ - "../app/MindWork AI Studio/bin/dist/mindworkAIStudioServer", - "target/databases/qdrant/qdrant" - ], - "resources": [ - "resources/*" - ], - "macOS": { - "exceptionDomain": "localhost" - }, - "icon": [ - "icons/32x32.png", - "icons/128x128.png", - "icons/128x128@2x.png", - "icons/icon.icns", - "icons/icon.ico" - ] - }, - "updater": { - "active": true, - "endpoints": [ - "https://github.com/MindWorkAI/AI-Studio/releases/latest/download/latest.json" - ], - "dialog": false, - "windows": { - "installMode": "passive" - }, - "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDM3MzE4MTM4RTNDMkM0NEQKUldSTnhNTGpPSUV4TjFkczFxRFJOZWgydzFQN1dmaFlKbXhJS1YyR1RKS1RnR09jYUpMaGsrWXYK" + "csp": null } } }