Refactor file manager path opening logic for Linux systems

This commit is contained in:
Thorsten Sommer 2026-07-15 19:30:02 +02:00
parent 7d671e407d
commit 5ad6f9aaa7
Signed by untrusted user who does not match committer: tsommer
GPG Key ID: 371BBA77A02C0108
5 changed files with 224 additions and 120 deletions

View File

@ -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)
{

View File

@ -87,28 +87,26 @@ public sealed partial class RustService
return await result.Content.ReadFromJsonAsync<FileSaveResponse>(this.jsonRustSerializerOptions);
}
public async Task<OpenPathResponse> 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<OpenPathResponse> TryOpenPathInRuntimeFileManager(string path)
public async Task<OpenPathResponse> 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<OpenPathResponse>(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);
}
}

17
runtime/Cargo.lock generated
View File

@ -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",

View File

@ -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"] }

View File

@ -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<R: tauri::Runtime>(file_dialog: FileDialogBuilder<R>, 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<FileManagerTarget> {
if requested_path.is_file() {
return Some(FileManagerTarget {
@ -394,6 +432,75 @@ fn resolve_file_manager_target(requested_path: &Path) -> Option<FileManagerTarge
})
}
#[cfg(any(target_os = "linux", test))]
fn linux_portal_operation(target: &FileManagerTarget) -> 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());
}
}