From 7dc51c5e0f51378cd0062aa27f1d2ec5abd6cd01 Mon Sep 17 00:00:00 2001 From: Louis Date: Tue, 3 Jun 2025 21:23:42 +0700 Subject: [PATCH] fix: relocate jan data folder (#5179) * fix: relocate jan data folder failed * fix: avoid infinite recursion * chore: kill background processes to unblock factory reset * chore: stop models before reset factory * chore: clean up * chore: clean up * fix: show error * chore: get active models should not have retry --- .../inference-cortex-extension/src/index.ts | 8 +++++- src-tauri/src/core/cmd.rs | 6 +++++ src-tauri/src/core/setup.rs | 24 ++++++++++++----- web-app/src/routes/settings/general.tsx | 26 ++++++++++++++----- web-app/src/services/app.ts | 19 +++++++++----- web-app/src/services/models.ts | 12 +++++++++ 6 files changed, 75 insertions(+), 20 deletions(-) diff --git a/extensions/inference-cortex-extension/src/index.ts b/extensions/inference-cortex-extension/src/index.ts index 7d3cf0c4d..9280d926c 100644 --- a/extensions/inference-cortex-extension/src/index.ts +++ b/extensions/inference-cortex-extension/src/index.ts @@ -270,7 +270,13 @@ export default class JanInferenceCortexExtension extends LocalOAIEngine { async activeModels(): Promise<(object & { id: string })[]> { return await this.apiInstance() - .then((e) => e.get('inferences/server/models')) + .then((e) => + e.get('inferences/server/models', { + retry: { + limit: 0, // Do not retry + }, + }) + ) .then((e) => e.json()) .then((e) => (e as LoadedModelResponse).data ?? []) .catch(() => []) diff --git a/src-tauri/src/core/cmd.rs b/src-tauri/src/core/cmd.rs index a84d59135..37b89dc7c 100644 --- a/src-tauri/src/core/cmd.rs +++ b/src-tauri/src/core/cmd.rs @@ -316,6 +316,12 @@ pub fn change_app_data_folder( new_data_folder_path ); + // Check if this is a parent directory to avoid infinite recursion + if new_data_folder_path.starts_with(¤t_data_folder) { + return Err( + "New data folder cannot be a subdirectory of the current data folder".to_string(), + ); + } copy_dir_recursive(¤t_data_folder, &new_data_folder_path) .map_err(|e| format!("Failed to copy data to new folder: {}", e))?; } else { diff --git a/src-tauri/src/core/setup.rs b/src-tauri/src/core/setup.rs index e1b575b4d..8b9b6dda0 100644 --- a/src-tauri/src/core/setup.rs +++ b/src-tauri/src/core/setup.rs @@ -212,16 +212,17 @@ pub fn setup_mcp(app: &App) { pub fn setup_sidecar(app: &App) -> Result<(), String> { let app_handle = app.handle().clone(); + let app_handle_for_spawn = app_handle.clone(); tauri::async_runtime::spawn(async move { const MAX_RESTARTS: u32 = 5; const RESTART_DELAY_MS: u64 = 5000; - let app_state = app_handle.state::(); + let app_state = app_handle_for_spawn.state::(); let cortex_restart_count_state = app_state.cortex_restart_count.clone(); - let app_data_dir = get_jan_data_folder_path(app_handle.clone()); + let app_data_dir = get_jan_data_folder_path(app_handle_for_spawn.clone()); let sidecar_command_builder = || { - let mut cmd = app_handle + let mut cmd = app_handle_for_spawn .shell() .sidecar("cortex-server") .expect("Failed to get sidecar command") @@ -243,14 +244,17 @@ pub fn setup_sidecar(app: &App) -> Result<(), String> { ]); #[cfg(target_os = "windows")] { - cmd = cmd.current_dir(app_handle.path().resource_dir().unwrap()); + cmd = cmd.current_dir(app_handle_for_spawn.path().resource_dir().unwrap()); } #[cfg(not(target_os = "windows"))] { cmd = cmd.env("LD_LIBRARY_PATH", { - let current_app_data_dir = - app_handle.path().resource_dir().unwrap().join("binaries"); + let current_app_data_dir = app_handle_for_spawn + .path() + .resource_dir() + .unwrap() + .join("binaries"); let dest = current_app_data_dir.to_str().unwrap(); let ld_path_env = std::env::var("LD_LIBRARY_PATH").unwrap_or_default(); format!("{}{}{}", ld_path_env, ":", dest) @@ -262,9 +266,15 @@ pub fn setup_sidecar(app: &App) -> Result<(), String> { let child_process: Arc>> = Arc::new(Mutex::new(None)); let child_process_clone_for_kill = child_process.clone(); + let app_handle_for_kill = app_handle.clone(); app_handle.listen("kill-sidecar", move |_event| { + let app_handle = app_handle_for_kill.clone(); let child_to_kill_arc = child_process_clone_for_kill.clone(); tauri::async_runtime::spawn(async move { + let app_state = app_handle.state::(); + let mut count = app_state.cortex_restart_count.lock().await; + *count = 5; + drop(count); log::info!("Received kill-sidecar event (processing async)."); if let Some(child) = child_to_kill_arc.lock().await.take() { log::info!("Attempting to kill sidecar process..."); @@ -286,7 +296,7 @@ pub fn setup_sidecar(app: &App) -> Result<(), String> { "Cortex server reached maximum restart attempts ({}). Giving up.", current_restart_count ); - if let Err(e) = app_handle.emit("cortex_max_restarts_reached", ()) { + if let Err(e) = app_handle_for_spawn.emit("cortex_max_restarts_reached", ()) { log::error!("Failed to emit cortex_max_restarts_reached event: {}", e); } break; diff --git a/web-app/src/routes/settings/general.tsx b/web-app/src/routes/settings/general.tsx index a66d5785e..f3aae9e6e 100644 --- a/web-app/src/routes/settings/general.tsx +++ b/web-app/src/routes/settings/general.tsx @@ -42,6 +42,8 @@ import { WebviewWindow } from '@tauri-apps/api/webviewWindow' import { windowKey } from '@/constants/windows' import { toast } from 'sonner' import { isDev } from '@/lib/utils' +import { emit } from '@tauri-apps/api/event' +import { stopAllModels } from '@/services/models' // eslint-disable-next-line @typescript-eslint/no-explicit-any export const Route = createFileRoute(route.settings.general as any)({ @@ -146,12 +148,24 @@ function General() { const confirmDataFolderChange = async () => { if (selectedNewPath) { try { - setJanDataFolder(selectedNewPath) - await relocateJanDataFolder(selectedNewPath) - // Only relaunch if relocation was successful - window.core?.api?.relaunch() - setSelectedNewPath(null) - setIsDialogOpen(false) + await stopAllModels() + emit('kill-sidecar') + setTimeout(async () => { + try { + await relocateJanDataFolder(selectedNewPath) + setJanDataFolder(selectedNewPath) + // Only relaunch if relocation was successful + window.core?.api?.relaunch() + setSelectedNewPath(null) + setIsDialogOpen(false) + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : 'Failed to relocate Jan data folder' + ) + } + }, 1000) } catch (error) { console.error('Failed to relocate data folder:', error) // Revert the data folder path on error diff --git a/web-app/src/services/app.ts b/web-app/src/services/app.ts index 288da8394..487039cb6 100644 --- a/web-app/src/services/app.ts +++ b/web-app/src/services/app.ts @@ -1,5 +1,7 @@ import { AppConfiguration, fs } from '@janhq/core' import { invoke } from '@tauri-apps/api/core' +import { emit } from '@tauri-apps/api/event' +import { stopAllModels } from './models' /** * @description This function is used to reset the app to its factory settings. @@ -7,11 +9,16 @@ import { invoke } from '@tauri-apps/api/core' * @returns {Promise} */ export const factoryReset = async () => { - const janDataFolderPath = await getJanDataFolder() - if (janDataFolderPath) await fs.rm(janDataFolderPath) - window.localStorage.clear() - await window.core?.api?.installExtensions() - await window.core?.api?.relaunch() + // Kill background processes and remove data folder + await stopAllModels() + emit('kill-sidecar') + setTimeout(async () => { + const janDataFolderPath = await getJanDataFolder() + if (janDataFolderPath) await fs.rm(janDataFolderPath) + window.localStorage.clear() + await window.core?.api?.installExtensions() + await window.core?.api?.relaunch() + }, 1000) } /** @@ -71,5 +78,5 @@ export const getJanDataFolder = async (): Promise => { * @param path The new path for the Jan data folder */ export const relocateJanDataFolder = async (path: string) => { - window.core?.api?.changeAppDataFolder({ newDataFolder: path }) + await window.core?.api?.changeAppDataFolder({ newDataFolder: path }) } diff --git a/web-app/src/services/models.ts b/web-app/src/services/models.ts index 58f653a85..16adaaaa7 100644 --- a/web-app/src/services/models.ts +++ b/web-app/src/services/models.ts @@ -260,6 +260,18 @@ export const stopModel = async (model: string, provider?: string) => { } } +/** + * Stops all active models. + * @returns + */ +export const stopAllModels = async () => { + const models = await getActiveModels() + if (models) + await Promise.all( + models.map((model: { id: string }) => stopModel(model.id)) + ) +} + /** * @fileoverview Helper function to start a model. * This function loads the model from the provider.