diff --git a/runtime/src/file_actions.rs b/runtime/src/file_actions.rs index b917158f..8df525a8 100644 --- a/runtime/src/file_actions.rs +++ b/runtime/src/file_actions.rs @@ -7,19 +7,6 @@ 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; - #[derive(Clone, Deserialize)] pub struct PreviousDirectory { path: String, @@ -325,7 +312,7 @@ pub async fn open_path_in_file_manager( let Some(target) = resolve_file_manager_target(&requested_path) else { let issue = format!( - "The path does not exist and its parent folder could not be found: {}", + "The path does not exist or is not a file or folder: {}", requested_path.to_string_lossy(), ); error!(Source = "Tauri"; "{issue}"); @@ -335,51 +322,35 @@ pub async fn open_path_in_file_manager( }); }; - #[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(), - }) - } + info!( + Source = "Tauri"; + "Opening resolved file manager target: requested='{}', target='{}', reveal_file={}.", + requested_path.display(), + target.path.display(), + target.reveal_file, + ); - Err(issue) => { - error!(Source = "Tauri"; "{issue}"); - Json(OpenPathResponse { - success: false, - issue, - }) - } - }; - } + match open_file_manager_target(&target) { + Ok(()) => { + info!( + Source = "Tauri"; + "Opened file manager target: requested='{}', target='{}', reveal_file={}.", + requested_path.display(), + target.path.display(), + target.reveal_file, + ); + Json(OpenPathResponse { + success: true, + issue: String::new(), + }) + } - #[cfg(any(windows, target_os = "macos"))] - { - let mut command = create_file_manager_command(&target); - - #[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, - }) - } + Err(issue) => { + error!(Source = "Tauri"; "{issue}"); + Json(OpenPathResponse { + success: false, + issue, + }) } } } @@ -402,126 +373,34 @@ struct FileManagerTarget { 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() { + let metadata = requested_path.metadata().ok()?; + + if metadata.is_file() { return Some(FileManagerTarget { path: requested_path.to_path_buf(), reveal_file: true, }); } - if requested_path.is_dir() { + if metadata.is_dir() { return Some(FileManagerTarget { path: requested_path.to_path_buf(), reveal_file: false, }); } - requested_path.parent() - .filter(|parent| parent.is_dir()) - .map(|parent| FileManagerTarget { - path: parent.to_path_buf(), - reveal_file: false, - }) + None } -#[cfg(any(target_os = "linux", test))] -fn linux_portal_operation(target: &FileManagerTarget) -> LinuxPortalOperation { +fn open_file_manager_target(target: &FileManagerTarget) -> Result<(), String> { 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"); - if target.reveal_file { - command.arg(format!("/select,{}", target.path.to_string_lossy())); - } else { - command.arg(&target.path); + return tauri_plugin_opener::reveal_item_in_dir(&target.path) + .map_err(|error| format!("Failed to reveal '{}' in the file manager: {error}", target.path.display())); } - command -} - -#[cfg(target_os = "macos")] -fn create_file_manager_command(target: &FileManagerTarget) -> Command { - let mut command = Command::new("open"); - if target.reveal_file { - command.arg("-R"); - } - - command.arg(&target.path); - command + tauri_plugin_opener::open_path(&target.path, None::<&str>) + .map_err(|error| format!("Failed to open '{}' in the file manager: {error}", target.path.display())) } #[cfg(test)] @@ -530,7 +409,7 @@ mod tests { use std::fs; #[test] - fn existing_file_is_revealed_and_falls_back_to_its_parent() { + fn existing_file_is_revealed() { let temp_dir = tempfile::tempdir().unwrap(); let file_path = temp_dir.path().join("application.log"); fs::write(&file_path, "log").unwrap(); @@ -539,8 +418,6 @@ mod tests { 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] @@ -551,21 +428,14 @@ mod tests { 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() { + fn missing_file_with_existing_parent_is_rejected() { 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()); + assert!(resolve_file_manager_target(&missing_file).is_none()); } #[test] diff --git a/runtime/src/log.rs b/runtime/src/log.rs index fc9a39e3..a3c06f3d 100644 --- a/runtime/src/log.rs +++ b/runtime/src/log.rs @@ -65,7 +65,7 @@ pub fn init_logging(bundle_identifier: &str) { .suffix("log"); // Store the startup log path: - store_startup_log_path(&LOG_STARTUP_PATH, &log_path); + let startup_log_path = store_startup_log_path(&LOG_STARTUP_PATH, &log_path); let runtime_logger = Logger::try_with_str(log_config).expect("Cannot create logging") .log_to_file(log_path) @@ -83,13 +83,17 @@ pub fn init_logging(bundle_identifier: &str) { LOGGER.set(runtime_logger).expect("Cannot set LOGGER"); + log::info!(Source = "Tauri"; "Startup log file path: {startup_log_path}"); + if let Some(fallback_warning) = fallback_warning { log::warn!("{fallback_warning}"); } } -fn store_startup_log_path(storage: &OnceLock, log_path: &FileSpec) { - let _ = storage.set(convert_log_path_to_string(log_path)); +fn store_startup_log_path(storage: &OnceLock, log_path: &FileSpec) -> String { + let log_path = convert_log_path_to_string(log_path); + let _ = storage.set(log_path.clone()); + log_path } fn convert_log_path_to_string(log_path: &FileSpec) -> String { @@ -129,11 +133,14 @@ fn get_startup_log_path(bundle_identifier: &str) -> (PathBuf, Option) { ).unwrap_or_else(|error| panic!("Cannot prepare a Flatpak startup log directory: {error}")); } - (get_non_flatpak_startup_log_path( + get_non_flatpak_startup_log_path( + bundle_identifier, + dirs::data_local_dir(), home_directory(), current_dir().ok(), temp_dir(), - ), None) + ensure_log_directory_is_writable, + ).unwrap_or_else(|error| panic!("Cannot prepare a startup log directory: {error}")) } // Note: Rust plans to remove the deprecation flag for std::env::home_dir() in Rust 1.86.0. @@ -142,25 +149,35 @@ fn home_directory() -> Option { std::env::home_dir() } -fn get_non_flatpak_startup_log_path( +fn get_non_flatpak_startup_log_path( + bundle_identifier: &str, + data_local_directory: Option, home_directory: Option, working_directory: Option, temporary_directory: PathBuf, -) -> PathBuf { - match home_directory { - // Case: We could determine the home directory: - Some(home_directory) => home_directory, - - // Case: We could not determine the home directory. Let's try to use the working directory: - None => match working_directory { - - // Case: We could determine the working directory: - Some(working_directory) => working_directory, - - // Case: We could not determine the working directory. Let's use the temporary directory: - None => temporary_directory, - }, + mut ensure_writable: F, +) -> Result<(PathBuf, Option), String> +where + F: FnMut(&Path) -> Result<(), String>, +{ + let standard_directory = data_local_directory.map(|directory| directory.join(bundle_identifier).join("data")); + let mut fallback_candidates = Vec::new(); + if let Some(home_directory) = home_directory { + fallback_candidates.push(("home directory", home_directory)); } + + if let Some(working_directory) = working_directory { + fallback_candidates.push(("working directory", working_directory)); + } + + fallback_candidates.push(("temporary directory", temporary_directory)); + + select_startup_log_path( + "standard path", + standard_directory, + fallback_candidates, + &mut ensure_writable, + ) } fn select_flatpak_startup_log_path( @@ -174,49 +191,56 @@ where F: FnMut(&Path) -> Result<(), String>, { let standard_directory = data_local_directory.map(|directory| directory.join(bundle_identifier).join("data")); - let persistent_fallback = persistent_data_directory.join(bundle_identifier).join("data"); - let temporary_fallback = temporary_directory.join(bundle_identifier).join("data"); + let fallback_candidates = vec![ + ("persistent fallback", persistent_data_directory.join(bundle_identifier).join("data")), + ("temporary fallback", temporary_directory.join(bundle_identifier).join("data")), + ]; + + select_startup_log_path( + "standard Flatpak path", + standard_directory, + fallback_candidates, + &mut ensure_writable, + ) +} + +fn select_startup_log_path( + standard_name: &str, + standard_directory: Option, + fallback_candidates: Vec<(&'static str, PathBuf)>, + ensure_writable: &mut F, +) -> Result<(PathBuf, Option), String> +where + F: FnMut(&Path) -> Result<(), String>, +{ let mut failures = Vec::new(); if let Some(standard_directory) = standard_directory { match ensure_writable(&standard_directory) { Ok(()) => return Ok((standard_directory, None)), - Err(error) => failures.push(format!("standard path failed: {error}")), + Err(error) => failures.push(format!("{standard_name} failed: {error}")), } } else { - failures.push(String::from("standard path failed: dirs::data_local_dir() returned no path")); + failures.push(format!("{standard_name} failed: dirs::data_local_dir() returned no path")); } - match ensure_writable(&persistent_fallback) { - Ok(()) => { - let warning = format!( - "The standard Flatpak startup log directory was unavailable; using persistent fallback '{}'. {}", - persistent_fallback.display(), - failures.join("; "), - ); - - return Ok((persistent_fallback, Some(warning))); - }, - - Err(error) => failures.push(format!("persistent fallback failed: {error}")), + for (fallback_name, fallback_directory) in fallback_candidates { + match ensure_writable(&fallback_directory) { + Ok(()) => { + let warning = format!( + "The standard startup log directory was unavailable; using {fallback_name} '{}'. {}", + fallback_directory.display(), + failures.join("; "), + ); + + return Ok((fallback_directory, Some(warning))); + } + + Err(error) => failures.push(format!("{fallback_name} failed: {error}")), + } } - match ensure_writable(&temporary_fallback) { - Ok(()) => { - let warning = format!( - "The standard and persistent Flatpak startup log directories were unavailable; using temporary fallback '{}'. {}", - temporary_fallback.display(), - failures.join("; "), - ); - - Ok((temporary_fallback, Some(warning))) - }, - - Err(error) => { - failures.push(format!("temporary fallback failed: {error}")); - Err(failures.join("; ")) - }, - } + Err(failures.join("; ")) } fn ensure_log_directory_is_writable(directory: &Path) -> Result<(), String> { @@ -242,8 +266,10 @@ pub fn switch_to_file_logging(logger_path: PathBuf) -> Result<(), Box .basename("events") .suppress_timestamp() .suffix("log"); - let _ = LOG_APP_PATH.set(convert_log_path_to_string(&log_path)); + let app_log_path = convert_log_path_to_string(&log_path); + let _ = LOG_APP_PATH.set(app_log_path.clone()); LOGGER.get().expect("No LOGGER was set").handle.reset_flw(&FileLogWriter::builder(log_path))?; + log::info!(Source = "Tauri"; "Usage log file path: {app_log_path}"); Ok(()) } @@ -338,9 +364,18 @@ fn file_logger_format( } pub async fn get_log_paths(_token: APIToken) -> Json { + let log_startup_path = LOG_STARTUP_PATH.get().expect("No startup log path was set").clone(); + let log_app_path = LOG_APP_PATH.get().expect("No app log path was set").clone(); + log::info!( + Source = "Tauri"; + "Returning log file paths: startup='{}', usage='{}'.", + log_startup_path, + log_app_path, + ); + Json(LogPathsResponse { - log_startup_path: LOG_STARTUP_PATH.get().expect("No startup log path was set").clone(), - log_app_path: LOG_APP_PATH.get().expect("No app log path was set").clone(), + log_startup_path, + log_app_path, }) } @@ -497,23 +532,51 @@ mod tests { } #[test] - fn non_flatpak_path_selection_keeps_existing_fallback_order() { + fn non_flatpak_path_selection_prefers_local_app_data_path() { + let local_data = PathBuf::from("/local-data"); + let expected = local_data.join(BUNDLE_IDENTIFIER).join("data"); let home = PathBuf::from("/home/user"); let working = PathBuf::from("/working"); let temporary = PathBuf::from("/tmp"); - assert_eq!( - get_non_flatpak_startup_log_path(Some(home.clone()), Some(working.clone()), temporary.clone()), - home, - ); - assert_eq!( - get_non_flatpak_startup_log_path(None, Some(working.clone()), temporary.clone()), - working, - ); - assert_eq!( - get_non_flatpak_startup_log_path(None, None, temporary.clone()), + let (selected, warning) = get_non_flatpak_startup_log_path( + BUNDLE_IDENTIFIER, + Some(local_data), + Some(home), + Some(working), temporary, - ); + |_| Ok(()), + ).unwrap(); + + assert_eq!(selected, expected); + assert!(warning.is_none()); + } + + #[test] + fn non_flatpak_path_selection_falls_back_to_home_when_local_app_data_is_unwritable() { + let local_data = PathBuf::from("/local-data"); + let standard = local_data.join(BUNDLE_IDENTIFIER).join("data"); + let home = PathBuf::from("/home/user"); + let working = PathBuf::from("/working"); + let temporary = PathBuf::from("/tmp"); + + let (selected, warning) = get_non_flatpak_startup_log_path( + BUNDLE_IDENTIFIER, + Some(local_data), + Some(home.clone()), + Some(working), + temporary, + |candidate| { + if candidate == standard { + Err(String::from("read-only")) + } else { + Ok(()) + } + }, + ).unwrap(); + + assert_eq!(selected, home); + assert!(warning.unwrap().contains("home directory")); } #[test] @@ -533,7 +596,7 @@ mod tests { }, ).unwrap(); let log_path = FileSpec::default() - .directory(selected) + .directory(selected.clone()) .basename(".AI Studio Events") .suppress_timestamp() .suffix("log"); @@ -542,8 +605,8 @@ mod tests { store_startup_log_path(&storage, &log_path); assert_eq!( - storage.get().unwrap(), - "/tmp/org.mindworkai.AIStudio/data/.AI Studio Events.log", + PathBuf::from(storage.get().unwrap()), + absolute(selected.join(".AI Studio Events.log")).unwrap(), ); } } \ No newline at end of file