use std::{ path::{Path, PathBuf}, sync::Arc, }; use dbx_core::storage::DesktopSettings; use tauri::{AppHandle, Manager, State}; use super::connection::AppState; use crate::{apply_debug_log_level, apply_desktop_settings}; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct DriverStoreMigrationResult { pub driver_store_dir: Option, pub plugin_store_dir: Option, pub agent_store_dir: Option, pub plugins_dir: String, pub agents_dir: String, pub migrated_plugins: bool, pub migrated_agents: bool, } #[tauri::command] pub async fn load_desktop_settings(state: State<'_, Arc>) -> Result { state.storage.load_desktop_settings().await } #[tauri::command] pub async fn save_desktop_settings( app: AppHandle, state: State<'_, Arc>, settings: DesktopSettings, ) -> Result<(), String> { state.storage.save_desktop_settings(&settings).await?; apply_debug_log_level(settings.debug_logging_enabled); if let Err(err) = apply_desktop_settings(&app, &settings) { eprintln!("Failed to apply desktop settings: {err}"); } Ok(()) } #[tauri::command] pub async fn load_pinned_tree_node_ids(state: State<'_, Arc>) -> Result, String> { state.storage.load_pinned_tree_node_ids().await } #[tauri::command] pub async fn save_pinned_tree_node_ids(state: State<'_, Arc>, ids: Vec) -> Result<(), String> { state.storage.save_pinned_tree_node_ids(&ids).await } #[tauri::command] pub async fn load_native_debug_logs(app: AppHandle) -> Result { let log_dir = app.path().app_log_dir().map_err(|e| e.to_string())?; tauri::async_runtime::spawn_blocking(move || load_native_debug_logs_from_dir(log_dir)) .await .map_err(|err| err.to_string())? } #[derive(Debug, Clone, serde::Serialize)] pub struct DriverStorePathInfo { pub driver_store_dir: Option, pub plugin_store_dir: Option, pub agent_store_dir: Option, pub plugins_dir: String, pub agents_dir: String, } #[tauri::command] pub async fn get_driver_store_path(state: State<'_, Arc>) -> Result { let settings = state.storage.load_desktop_settings().await.unwrap_or_default(); Ok(DriverStorePathInfo { driver_store_dir: settings.driver_store_dir, plugin_store_dir: settings.plugin_store_dir, agent_store_dir: settings.agent_store_dir, plugins_dir: state.plugins.root_dir().to_string_lossy().to_string(), agents_dir: state.agent_manager.base_dir().to_string_lossy().to_string(), }) } #[tauri::command] pub async fn set_driver_store_dir( app: AppHandle, state: State<'_, Arc>, new_dir: Option, ) -> Result { let new_dir = normalize_store_dir(new_dir); let current_plugins_dir = state.plugins.root_dir().to_path_buf(); let current_agents_dir = state.agent_manager.base_dir().clone(); let (target_plugins_dir, target_agents_dir) = match new_dir.as_ref() { Some(dir) => { let driver_base = PathBuf::from(dir); (driver_base.join("plugins"), driver_base.join("agents")) } None => default_store_dirs(&app)?, }; state.agent_manager.stop_daemons().await; let migrated_plugins = migrate_store_directory(¤t_plugins_dir, &target_plugins_dir)?; let migrated_agents = migrate_store_directory(¤t_agents_dir, &target_agents_dir)?; let mut settings = state.storage.load_desktop_settings().await.unwrap_or_default(); settings.driver_store_dir = new_dir.clone(); settings.plugin_store_dir = None; settings.agent_store_dir = None; state.storage.save_desktop_settings(&settings).await?; Ok(driver_store_migration_result( settings, &target_plugins_dir, &target_agents_dir, migrated_plugins, migrated_agents, )) } #[tauri::command] pub async fn set_plugin_store_dir( app: AppHandle, state: State<'_, Arc>, new_dir: Option, ) -> Result { let new_dir = normalize_store_dir(new_dir); let current_plugins_dir = state.plugins.root_dir().to_path_buf(); let current_agents_dir = state.agent_manager.base_dir().clone(); let target_plugins_dir = match new_dir.as_ref() { Some(dir) => PathBuf::from(dir), None => default_plugin_store_dir(&app)?, }; let migrated_plugins = migrate_store_directory(¤t_plugins_dir, &target_plugins_dir)?; let mut settings = state.storage.load_desktop_settings().await.unwrap_or_default(); convert_legacy_driver_store(&mut settings, ¤t_plugins_dir, ¤t_agents_dir); settings.plugin_store_dir = new_dir; state.storage.save_desktop_settings(&settings).await?; Ok(driver_store_migration_result(settings, &target_plugins_dir, ¤t_agents_dir, migrated_plugins, false)) } #[tauri::command] pub async fn set_agent_store_dir( app: AppHandle, state: State<'_, Arc>, new_dir: Option, ) -> Result { let new_dir = normalize_store_dir(new_dir); let current_plugins_dir = state.plugins.root_dir().to_path_buf(); let current_agents_dir = state.agent_manager.base_dir().clone(); let target_agents_dir = match new_dir.as_ref() { Some(dir) => PathBuf::from(dir), None => default_agent_store_dir(&app)?, }; state.agent_manager.stop_daemons().await; let migrated_agents = migrate_store_directory(¤t_agents_dir, &target_agents_dir)?; let mut settings = state.storage.load_desktop_settings().await.unwrap_or_default(); convert_legacy_driver_store(&mut settings, ¤t_plugins_dir, ¤t_agents_dir); settings.agent_store_dir = new_dir; state.storage.save_desktop_settings(&settings).await?; Ok(driver_store_migration_result(settings, ¤t_plugins_dir, &target_agents_dir, false, migrated_agents)) } fn normalize_store_dir(dir: Option) -> Option { dir.and_then(|value| { let trimmed = value.trim(); (!trimmed.is_empty()).then(|| trimmed.to_string()) }) } fn default_store_dirs(app: &AppHandle) -> Result<(PathBuf, PathBuf), String> { Ok((default_plugin_store_dir(app)?, default_agent_store_dir(app)?)) } fn default_plugin_store_dir(app: &AppHandle) -> Result { let default_data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; Ok(crate::data_dir::resolve_data_dir(default_data_dir).join("plugins")) } fn default_agent_store_dir(app: &AppHandle) -> Result { let default_data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; let data_dir = crate::data_dir::resolve_data_dir(default_data_dir); Ok(if crate::data_dir::uses_custom_data_dir() { data_dir.join("agents") } else { dbx_core::connection::default_agent_dir() }) } pub(crate) fn resolve_driver_store_dirs_from_settings( settings: &DesktopSettings, data_dir: &Path, default_agent_dir: Option, ) -> (PathBuf, Option) { let legacy_driver_base = settings.driver_store_dir.as_ref().filter(|value| !value.trim().is_empty()).map(PathBuf::from); let plugin_dir = settings .plugin_store_dir .as_ref() .filter(|value| !value.trim().is_empty()) .map(PathBuf::from) .or_else(|| legacy_driver_base.as_ref().map(|base| base.join("plugins"))) .unwrap_or_else(|| data_dir.join("plugins")); let agent_dir = settings .agent_store_dir .as_ref() .filter(|value| !value.trim().is_empty()) .map(PathBuf::from) .or_else(|| legacy_driver_base.as_ref().map(|base| base.join("agents"))) .or(default_agent_dir); (plugin_dir, agent_dir) } fn convert_legacy_driver_store(settings: &mut DesktopSettings, current_plugins_dir: &Path, current_agents_dir: &Path) { if settings.driver_store_dir.is_none() { return; } if settings.plugin_store_dir.is_none() { settings.plugin_store_dir = Some(current_plugins_dir.to_string_lossy().to_string()); } if settings.agent_store_dir.is_none() { settings.agent_store_dir = Some(current_agents_dir.to_string_lossy().to_string()); } settings.driver_store_dir = None; } fn driver_store_migration_result( settings: DesktopSettings, plugins_dir: &Path, agents_dir: &Path, migrated_plugins: bool, migrated_agents: bool, ) -> DriverStoreMigrationResult { DriverStoreMigrationResult { driver_store_dir: settings.driver_store_dir, plugin_store_dir: settings.plugin_store_dir, agent_store_dir: settings.agent_store_dir, plugins_dir: plugins_dir.to_string_lossy().to_string(), agents_dir: agents_dir.to_string_lossy().to_string(), migrated_plugins, migrated_agents, } } fn migrate_store_directory(current_dir: &Path, target_dir: &Path) -> Result { std::fs::create_dir_all(target_dir) .map_err(|e| format!("Failed to create directory {}: {e}", target_dir.display()))?; let target_canonical = target_dir.canonicalize().map_err(|e| format!("Invalid path {}: {e}", target_dir.display()))?; if !current_dir.exists() { return Ok(false); } let current_canonical = current_dir.canonicalize().map_err(|e| format!("Invalid path {}: {e}", current_dir.display()))?; if current_canonical == target_canonical { return Ok(false); } if target_canonical.starts_with(¤t_canonical) { return Err(format!( "Target directory {} cannot be inside current directory {}", target_dir.display(), current_dir.display() )); } if target_dir_has_entries(target_dir)? { return Err(format!( "Target directory {} is not empty. Please choose an empty directory or remove the existing data first.", target_dir.display() )); } migrate_directory(current_dir, target_dir)?; verify_migration(current_dir, target_dir)?; if let Err(err) = std::fs::remove_dir_all(current_dir) { log::warn!("Failed to remove old driver store dir {}: {err}", current_dir.display()); } Ok(true) } fn target_dir_has_entries(dir: &Path) -> Result { let mut entries = std::fs::read_dir(dir).map_err(|e| format!("Failed to read {}: {e}", dir.display()))?; Ok(entries.next().transpose().map_err(|e| e.to_string())?.is_some()) } /// Recursively copy a directory to a new location. fn migrate_directory(src: &Path, dst: &Path) -> Result<(), String> { if !src.exists() { return Ok(()); } std::fs::create_dir_all(dst).map_err(|e| format!("Failed to create {}: {e}", dst.display()))?; copy_dir_recursive(src, dst) } fn copy_dir_recursive(src: &std::path::Path, dst: &std::path::Path) -> Result<(), String> { std::fs::create_dir_all(dst).map_err(|e| format!("Failed to create {}: {e}", dst.display()))?; for entry in std::fs::read_dir(src).map_err(|e| format!("Failed to read {}: {e}", src.display()))? { let entry = entry.map_err(|e| e.to_string())?; let src_path = entry.path(); let dst_path = dst.join(entry.file_name()); if src_path.is_dir() { copy_dir_recursive(&src_path, &dst_path)?; } else { std::fs::copy(&src_path, &dst_path) .map_err(|e| format!("Failed to copy {} to {}: {e}", src_path.display(), dst_path.display()))?; } } Ok(()) } /// Verify that the migrated directory has the same file count and total size. fn verify_migration(src: &Path, dst: &Path) -> Result<(), String> { let src_info = count_files_recursive(src)?; let dst_info = count_files_recursive(dst)?; if src_info.count != dst_info.count || src_info.total_size != dst_info.total_size { // Rollback: remove the incomplete destination let _ = std::fs::remove_dir_all(dst); return Err(format!( "Migration verification failed: source had {} files ({} bytes), destination has {} files ({} bytes)", src_info.count, src_info.total_size, dst_info.count, dst_info.total_size )); } Ok(()) } struct DirStats { count: usize, total_size: u64, } fn count_files_recursive(dir: &std::path::Path) -> Result { let mut count = 0usize; let mut total_size = 0u64; fn walk(dir: &std::path::Path, count: &mut usize, total_size: &mut u64) -> Result<(), String> { for entry in std::fs::read_dir(dir).map_err(|e| e.to_string())? { let entry = entry.map_err(|e| e.to_string())?; let path = entry.path(); if path.is_dir() { walk(&path, count, total_size)?; } else { *count += 1; *total_size += entry.metadata().map(|m| m.len()).unwrap_or(0); } } Ok(()) } walk(dir, &mut count, &mut total_size)?; Ok(DirStats { count, total_size }) } fn load_native_debug_logs_from_dir(log_dir: PathBuf) -> Result { const MAX_FILES: usize = 6; const MAX_FILE_BYTES: u64 = 512 * 1024; if !log_dir.exists() { return Ok(format!("Native log dir does not exist yet: {}", log_dir.display())); } let mut files = std::fs::read_dir(&log_dir) .map_err(|e| e.to_string())? .filter_map(|entry| entry.ok()) .filter_map(|entry| { let path = entry.path(); let metadata = entry.metadata().ok()?; if !metadata.is_file() { return None; } let modified = metadata.modified().ok()?; Some((path, modified, metadata.len())) }) .collect::>(); files.sort_by_key(|(_, modified, _)| *modified); files.reverse(); let mut output = String::new(); output.push_str(&format!("Native log dir: {}\n", log_dir.display())); for (path, _, len) in files.into_iter().take(MAX_FILES) { let name = path.file_name().and_then(|name| name.to_str()).unwrap_or("unknown"); output.push_str(&format!("\n===== {name} =====\n")); let bytes = std::fs::read(&path).map_err(|e| e.to_string())?; let start = if len > MAX_FILE_BYTES { bytes.len().saturating_sub(MAX_FILE_BYTES as usize) } else { 0 }; if start > 0 { output.push_str("[truncated to last 512 KiB]\n"); } output.push_str(&String::from_utf8_lossy(&bytes[start..])); output.push('\n'); } Ok(output) } #[cfg(test)] mod tests { use super::{driver_store_migration_result, resolve_driver_store_dirs_from_settings, DesktopSettings}; use std::path::PathBuf; #[test] fn plugin_store_result_uses_selected_directory_without_agent_prefix() { let settings = DesktopSettings { plugin_store_dir: Some(path("D:/develop/DBX")), agent_store_dir: Some(path("D:/develop/DBX/agents")), ..Default::default() }; let plugins_dir = PathBuf::from(path("D:/develop/DBX")); let agents_dir = PathBuf::from(path("D:/develop/DBX/agents")); let result = driver_store_migration_result(settings, &plugins_dir, &agents_dir, true, false); assert_eq!(result.plugin_store_dir.as_deref(), Some(path("D:/develop/DBX").as_str())); assert_eq!(result.plugins_dir, path("D:/develop/DBX")); assert_eq!(result.agents_dir, path("D:/develop/DBX/agents")); assert!(!result.plugins_dir.contains(&path("agents/com.dbx.app/plugins"))); } #[test] fn legacy_driver_store_result_keeps_plugins_and_agents_as_siblings() { let settings = DesktopSettings { driver_store_dir: Some(path("D:/develop/DBX")), ..Default::default() }; let plugins_dir = PathBuf::from(path("D:/develop/DBX/plugins")); let agents_dir = PathBuf::from(path("D:/develop/DBX/agents")); let result = driver_store_migration_result(settings, &plugins_dir, &agents_dir, true, true); assert_eq!(result.driver_store_dir.as_deref(), Some(path("D:/develop/DBX").as_str())); assert_eq!(result.plugins_dir, path("D:/develop/DBX/plugins")); assert_eq!(result.agents_dir, path("D:/develop/DBX/agents")); } #[test] fn resolves_separate_plugin_store_dir_as_exact_selected_dir() { let settings = DesktopSettings { driver_store_dir: Some(path("D:/legacy-base")), plugin_store_dir: Some(path("D:/develop/DBX")), agent_store_dir: Some(path("D:/develop/DBX/agents")), ..Default::default() }; let (plugins_dir, agents_dir) = resolve_driver_store_dirs_from_settings( &settings, &PathBuf::from(path("C:/Users/lenovo/AppData/Roaming/com.dbx.app")), Some(PathBuf::from(path("C:/Users/lenovo/.dbx/agents"))), ); assert_eq!(plugins_dir, PathBuf::from(path("D:/develop/DBX"))); assert_eq!(agents_dir, Some(PathBuf::from(path("D:/develop/DBX/agents")))); } #[test] fn resolves_legacy_driver_store_dir_to_sibling_plugins_and_agents() { let settings = DesktopSettings { driver_store_dir: Some(path("D:/develop/DBX")), ..Default::default() }; let (plugins_dir, agents_dir) = resolve_driver_store_dirs_from_settings( &settings, &PathBuf::from(path("C:/Users/lenovo/AppData/Roaming/com.dbx.app")), Some(PathBuf::from(path("C:/Users/lenovo/.dbx/agents"))), ); assert_eq!(plugins_dir, PathBuf::from(path("D:/develop/DBX/plugins"))); assert_eq!(agents_dir, Some(PathBuf::from(path("D:/develop/DBX/agents")))); } fn path(value: &str) -> String { value.replace('/', std::path::MAIN_SEPARATOR_STR) } }