diff --git a/app/MindWork AI Studio/Assistants/LogViewer/AssistantLogViewer.razor.cs b/app/MindWork AI Studio/Assistants/LogViewer/AssistantLogViewer.razor.cs index 0569a74e..239763eb 100644 --- a/app/MindWork AI Studio/Assistants/LogViewer/AssistantLogViewer.razor.cs +++ b/app/MindWork AI Studio/Assistants/LogViewer/AssistantLogViewer.razor.cs @@ -226,7 +226,7 @@ public partial class AssistantLogViewer : MSGComponentBase OpenPathResponse response; try { - response = await this.RustService.OpenPathInFileManager(path); + response = await this.RustService.TryOpenPathInRuntimeFileManager(path); } catch (Exception e) { diff --git a/app/MindWork AI Studio/Tools/Services/RustService.FileSystem.cs b/app/MindWork AI Studio/Tools/Services/RustService.FileSystem.cs index 60388996..72b63734 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.FileSystem.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.FileSystem.cs @@ -87,28 +87,26 @@ public sealed partial class RustService return await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); } - public async Task OpenPathInFileManager(string path) - { - var runtimeResponse = await this.TryOpenPathInRuntimeFileManager(path); - if (runtimeResponse.Success) - return runtimeResponse; - - var localResponse = this.TryOpenPathInLocalFileManager(path); - if (localResponse.Success) - return localResponse; - - var issue = string.IsNullOrWhiteSpace(runtimeResponse.Issue) - ? localResponse.Issue - : $"{runtimeResponse.Issue} {localResponse.Issue}"; - - return new OpenPathResponse(false, issue.Trim()); - } - - private async Task TryOpenPathInRuntimeFileManager(string path) + public async Task TryOpenPathInRuntimeFileManager(string path) { + HttpResponseMessage result; + try + { + result = await this.http.PostAsJsonAsync("/open/path", new OpenPathRequest(path), this.jsonRustSerializerOptions); + } + catch (HttpRequestException e) + { + this.logger!.LogWarning(e, "Failed to reach the Rust runtime file manager endpoint."); + return new OpenPathResponse(false, TB("The runtime file manager endpoint is not available.")); + } + catch (TaskCanceledException e) + { + this.logger!.LogWarning(e, "Timed out while reaching the Rust runtime file manager endpoint."); + return new OpenPathResponse(false, TB("The runtime file manager endpoint is not available.")); + } + try { - var result = await this.http.PostAsJsonAsync("/open/path", new OpenPathRequest(path), this.jsonRustSerializerOptions); if (!result.IsSuccessStatusCode) { this.logger!.LogWarning("Failed to open a path in the file manager through the Rust runtime: '{StatusCode}'", result.StatusCode); @@ -116,79 +114,20 @@ public sealed partial class RustService } var response = await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); - return response.Success + var normalizedResponse = response.Success ? response : new OpenPathResponse(false, string.IsNullOrWhiteSpace(response.Issue) ? TB("The runtime file manager endpoint failed without details.") : response.Issue); + + return normalizedResponse; } catch (Exception e) { - this.logger!.LogWarning(e, "Failed to open a path in the file manager through the Rust runtime."); - return new OpenPathResponse(false, TB("The runtime file manager endpoint is not available.")); + this.logger!.LogWarning(e, "Failed to process the Rust runtime file manager endpoint response."); + return new OpenPathResponse(false, TB("The runtime file manager endpoint failed without details.")); + } + finally + { + result.Dispose(); } } - - private OpenPathResponse TryOpenPathInLocalFileManager(string path) - { - try - { - var target = ResolveFileManagerTarget(path); - if (target is null) - return new OpenPathResponse(false, TB("The path does not exist and its parent folder could not be found.")); - - using var process = Process.Start(CreateFileManagerStartInfo(target.Value)); - return process is null - ? new OpenPathResponse(false, TB("The local file manager command did not start.")) - : new OpenPathResponse(true, string.Empty); - } - catch (Exception e) - { - this.logger!.LogWarning(e, "Failed to open a path in the local file manager."); - return new OpenPathResponse(false, string.Format(TB("The local file manager command failed: {0}"), e.Message)); - } - } - - private static FileManagerTarget? ResolveFileManagerTarget(string path) - { - if (string.IsNullOrWhiteSpace(path)) - return null; - - var requestedPath = path.Trim(); - if (File.Exists(requestedPath)) - return new FileManagerTarget(requestedPath, true); - - if (Directory.Exists(requestedPath)) - return new FileManagerTarget(requestedPath, false); - - var parent = Directory.GetParent(requestedPath)?.FullName; - return !string.IsNullOrWhiteSpace(parent) && Directory.Exists(parent) - ? new FileManagerTarget(parent, false) - : null; - } - - private static ProcessStartInfo CreateFileManagerStartInfo(FileManagerTarget target) - { - if (OperatingSystem.IsWindows()) - { - var windowsInfo = new ProcessStartInfo("explorer.exe") { UseShellExecute = false }; - windowsInfo.ArgumentList.Add(target.RevealFile ? $"/select,{target.Path}" : target.Path); - return windowsInfo; - } - - if (OperatingSystem.IsMacOS()) - { - var macOsInfo = new ProcessStartInfo("open") { UseShellExecute = false }; - if (target.RevealFile) - macOsInfo.ArgumentList.Add("-R"); - - macOsInfo.ArgumentList.Add(target.Path); - return macOsInfo; - } - - var linuxInfo = new ProcessStartInfo("xdg-open") { UseShellExecute = false }; - var directory = target.RevealFile ? Path.GetDirectoryName(target.Path) ?? target.Path : target.Path; - linuxInfo.ArgumentList.Add(directory); - return linuxInfo; - } - - private readonly record struct FileManagerTarget(string Path, bool RevealFile); -} +} \ No newline at end of file diff --git a/runtime/Cargo.lock b/runtime/Cargo.lock index 0c8bd4de..d48d820b 100644 --- a/runtime/Cargo.lock +++ b/runtime/Cargo.lock @@ -242,6 +242,20 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "ashpd" +version = "0.13.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "281e6645758940dee594495e28807a7672ce40f11ebf4df6c22c4fcd59e2689f" +dependencies = [ + "enumflags2", + "futures-util", + "getrandom 0.4.2", + "serde", + "tokio", + "zbus", +] + [[package]] name = "asn1-rs" version = "0.7.1" @@ -4072,6 +4086,7 @@ dependencies = [ "aes 0.9.1", "apple-native-keyring-store", "arboard", + "ashpd", "async-stream", "axum", "axum-server", @@ -7664,6 +7679,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", + "tracing", "windows-sys 0.61.2", ] @@ -9566,6 +9582,7 @@ dependencies = [ "rustix 1.1.4", "serde", "serde_repr", + "tokio", "tracing", "uds_windows", "uuid", diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index f86dbfad..1a8f58b5 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -72,6 +72,7 @@ windows-native-keyring-store = "1.1.0" apple-native-keyring-store = { version = "1.0.0", features = ["keychain"] } [target.'cfg(target_os = "linux")'.dependencies] +ashpd = { version = "0.13.12", default-features = false, features = ["tokio", "open_uri"] } dbus-secret-service-keyring-store = { version = "1.0.0", features = ["crypto-rust"] } webkit2gtk = { version = "2.0.2", features = ["v2_8"] } diff --git a/runtime/src/file_actions.rs b/runtime/src/file_actions.rs index 9ff7376a..b917158f 100644 --- a/runtime/src/file_actions.rs +++ b/runtime/src/file_actions.rs @@ -3,13 +3,19 @@ use axum::extract::Query; use axum::Json; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; -use std::process::Command; -#[cfg(windows)] -use std::os::windows::process::CommandExt; use tauri_plugin_dialog::{DialogExt, FileDialogBuilder}; use crate::api_token::APIToken; use crate::app_window::MAIN_WINDOW; +#[cfg(any(windows, target_os = "macos"))] +use std::process::Command; + +#[cfg(target_os = "linux")] +use ashpd::desktop::open_uri::{OpenDirectoryRequest, OpenFileRequest}; + +#[cfg(windows)] +use std::os::windows::process::CommandExt; + /// Microsoft documents CREATE_NO_WINDOW as a process creation flag with value 0x08000000. #[cfg(windows)] const CREATE_NO_WINDOW: u32 = 0x08000000; @@ -329,27 +335,51 @@ pub async fn open_path_in_file_manager( }); }; - let mut command = create_file_manager_command(&target); + #[cfg(target_os = "linux")] + { + return match open_path_in_linux_file_manager(&target).await { + Ok(()) => { + info!("Opened file manager for path: {:?}", target.path); + Json(OpenPathResponse { + success: true, + issue: String::new(), + }) + } - #[cfg(windows)] - command.creation_flags(CREATE_NO_WINDOW); + Err(issue) => { + error!(Source = "Tauri"; "{issue}"); + Json(OpenPathResponse { + success: false, + issue, + }) + } + }; + } - match command.spawn() { - Ok(_) => { - info!("Opened file manager for path: {:?}", target.path); - Json(OpenPathResponse { - success: true, - issue: String::new(), - }) - } + #[cfg(any(windows, target_os = "macos"))] + { + let mut command = create_file_manager_command(&target); - Err(error) => { - let issue = format!("Failed to open the file manager: {error}"); - error!(Source = "Tauri"; "{issue}"); - Json(OpenPathResponse { - success: false, - issue, - }) + #[cfg(windows)] + command.creation_flags(CREATE_NO_WINDOW); + + match command.spawn() { + Ok(_) => { + info!("Opened file manager for path: {:?}", target.path); + Json(OpenPathResponse { + success: true, + issue: String::new(), + }) + } + + Err(error) => { + let issue = format!("Failed to open the file manager: {error}"); + error!(Source = "Tauri"; "{issue}"); + Json(OpenPathResponse { + success: false, + issue, + }) + } } } } @@ -366,11 +396,19 @@ fn apply_filter(file_dialog: FileDialogBuilder, filter: &O } } +#[derive(Debug, PartialEq, Eq)] struct FileManagerTarget { path: PathBuf, reveal_file: bool, } +#[cfg(any(target_os = "linux", test))] +#[derive(Debug, PartialEq, Eq)] +enum LinuxPortalOperation { + RevealFile, + OpenDirectory, +} + fn resolve_file_manager_target(requested_path: &Path) -> Option { if requested_path.is_file() { return Some(FileManagerTarget { @@ -394,6 +432,75 @@ fn resolve_file_manager_target(requested_path: &Path) -> Option LinuxPortalOperation { + if target.reveal_file { + LinuxPortalOperation::RevealFile + } else { + LinuxPortalOperation::OpenDirectory + } +} + +#[cfg(any(target_os = "linux", test))] +fn xdg_open_fallback_path(target: &FileManagerTarget) -> &Path { + if target.reveal_file { + target.path.parent().unwrap_or(&target.path) + } else { + &target.path + } +} + +#[cfg(target_os = "linux")] +enum LinuxPortalError { + Unavailable(String), + RequestFailed(String), +} + +#[cfg(target_os = "linux")] +async fn open_path_with_linux_portal(target: &FileManagerTarget) -> Result<(), LinuxPortalError> { + let file = std::fs::File::open(&target.path) + .map_err(|error| LinuxPortalError::Unavailable(format!("Failed to open the path for the desktop portal: {error}")))?; + + let request = match linux_portal_operation(target) { + LinuxPortalOperation::RevealFile => OpenDirectoryRequest::default().send(&file).await, + LinuxPortalOperation::OpenDirectory => OpenFileRequest::default().send_file(&file).await, + } + .map_err(|error| LinuxPortalError::Unavailable(format!("Desktop portal invocation failed: {error}")))?; + + request.response() + .map_err(|error| LinuxPortalError::RequestFailed(format!("Desktop portal request failed: {error}"))) +} + +#[cfg(target_os = "linux")] +async fn open_path_with_xdg_open(target: &FileManagerTarget) -> Result<(), String> { + let fallback_path = xdg_open_fallback_path(target); + let status = tokio::process::Command::new("xdg-open") + .arg(fallback_path) + .status() + .await + .map_err(|error| format!("xdg-open failed to start for '{}': {error}", fallback_path.to_string_lossy()))?; + + if status.success() { + Ok(()) + } else { + Err(format!("xdg-open failed for '{}' with exit status {status}", fallback_path.to_string_lossy())) + } +} + +#[cfg(target_os = "linux")] +async fn open_path_in_linux_file_manager(target: &FileManagerTarget) -> Result<(), String> { + match open_path_with_linux_portal(target).await { + Ok(()) => Ok(()), + Err(LinuxPortalError::RequestFailed(error)) => Err(error), + Err(LinuxPortalError::Unavailable(portal_error)) => { + match open_path_with_xdg_open(target).await { + Ok(()) => Ok(()), + Err(fallback_error) => Err(format!("{portal_error} Fallback failed: {fallback_error}")), + } + } + } +} + #[cfg(target_os = "windows")] fn create_file_manager_command(target: &FileManagerTarget) -> Command { let mut command = Command::new("explorer.exe"); @@ -417,15 +524,55 @@ fn create_file_manager_command(target: &FileManagerTarget) -> Command { command } -#[cfg(all(unix, not(target_os = "macos")))] -fn create_file_manager_command(target: &FileManagerTarget) -> Command { - let mut command = Command::new("xdg-open"); - let directory = if target.reveal_file { - target.path.parent().unwrap_or(&target.path) - } else { - &target.path - }; +#[cfg(test)] +mod tests { + use super::*; + use std::fs; - command.arg(directory); - command + #[test] + fn existing_file_is_revealed_and_falls_back_to_its_parent() { + let temp_dir = tempfile::tempdir().unwrap(); + let file_path = temp_dir.path().join("application.log"); + fs::write(&file_path, "log").unwrap(); + + let target = resolve_file_manager_target(&file_path).unwrap(); + + assert_eq!(target.path, file_path); + assert!(target.reveal_file); + assert_eq!(linux_portal_operation(&target), LinuxPortalOperation::RevealFile); + assert_eq!(xdg_open_fallback_path(&target), temp_dir.path()); + } + + #[test] + fn existing_directory_is_opened_directly() { + let temp_dir = tempfile::tempdir().unwrap(); + + let target = resolve_file_manager_target(temp_dir.path()).unwrap(); + + assert_eq!(target.path, temp_dir.path()); + assert!(!target.reveal_file); + assert_eq!(linux_portal_operation(&target), LinuxPortalOperation::OpenDirectory); + assert_eq!(xdg_open_fallback_path(&target), temp_dir.path()); + } + + #[test] + fn missing_file_uses_its_existing_parent_directory() { + let temp_dir = tempfile::tempdir().unwrap(); + let missing_file = temp_dir.path().join("missing.log"); + + let target = resolve_file_manager_target(&missing_file).unwrap(); + + assert_eq!(target.path, temp_dir.path()); + assert!(!target.reveal_file); + assert_eq!(linux_portal_operation(&target), LinuxPortalOperation::OpenDirectory); + assert_eq!(xdg_open_fallback_path(&target), temp_dir.path()); + } + + #[test] + fn invalid_path_without_existing_parent_is_rejected() { + let temp_dir = tempfile::tempdir().unwrap(); + let invalid_path = temp_dir.path().join("missing-directory").join("missing.log"); + + assert!(resolve_file_manager_target(&invalid_path).is_none()); + } }