From 8cf8910e0aeb092ef0480225b314b47747146eef Mon Sep 17 00:00:00 2001 From: gcw_VNOUEmGJ Date: Thu, 12 Mar 2026 15:33:02 +0800 Subject: [PATCH 1/2] installer: harden uninstall path handling --- .../src-tauri/src/installer/commands.rs | 406 +++++++++++++++--- BitFun-Installer/src/hooks/useInstaller.ts | 17 +- 2 files changed, 359 insertions(+), 64 deletions(-) diff --git a/BitFun-Installer/src-tauri/src/installer/commands.rs b/BitFun-Installer/src-tauri/src/installer/commands.rs index 1bc99a0c..a4a213a2 100644 --- a/BitFun-Installer/src-tauri/src/installer/commands.rs +++ b/BitFun-Installer/src-tauri/src/installer/commands.rs @@ -3,10 +3,10 @@ use super::extract::{self, ESTIMATED_INSTALL_SIZE}; use super::types::{ConnectionTestResult, DiskSpaceInfo, InstallOptions, InstallProgress, ModelConfig}; use reqwest::header::{HeaderMap, HeaderName, HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_TYPE}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use std::fs::File; -use std::io::Cursor; +use std::io::{Cursor, Read}; use std::path::{Path, PathBuf}; use std::time::Duration; use tauri::{Emitter, Manager, Window}; @@ -23,9 +23,26 @@ struct WindowsInstallState { const MIN_WINDOWS_APP_EXE_BYTES: u64 = 5 * 1024 * 1024; const PAYLOAD_MANIFEST_FILE: &str = "payload-manifest.json"; +const INSTALL_MANIFEST_FILE: &str = ".bitfun-install-manifest.json"; const EMBEDDED_PAYLOAD_ZIP: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/embedded_payload.zip")); +#[derive(Debug, Clone, Deserialize)] +struct PayloadManifest { + files: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +struct PayloadManifestFile { + path: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct InstalledManifest { + version: u32, + files: Vec, +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct LaunchContext { @@ -162,44 +179,15 @@ pub fn get_launch_context() -> LaunchContext { #[tauri::command] pub fn validate_install_path(path: String) -> Result { let path = PathBuf::from(&path); - - // Check if the path is absolute - if !path.is_absolute() { - return Err("Installation path must be absolute".into()); - } - - // Check if we can create the directory - if path.exists() { - if !path.is_dir() { - return Err("Path exists but is not a directory".into()); - } - // Directory exists - check if it's writable - let test_file = path.join(".bitfun_install_test"); - match std::fs::write(&test_file, "test") { - Ok(_) => { - let _ = std::fs::remove_file(&test_file); - Ok(true) - } - Err(_) => Err("Directory is not writable".into()), - } - } else { - // Try to find the nearest existing ancestor - let ancestor = find_existing_ancestor(&path); - let test_file = ancestor.join(".bitfun_install_test"); - match std::fs::write(&test_file, "test") { - Ok(_) => { - let _ = std::fs::remove_file(&test_file); - Ok(true) - } - Err(_) => Err("Cannot write to the parent directory".into()), - } - } + validate_install_target(&path)?; + Ok(true) } /// Main installation command. Emits progress events to the frontend. #[tauri::command] pub async fn start_installation(window: Window, options: InstallOptions) -> Result<(), String> { let install_path = PathBuf::from(&options.install_path); + validate_install_target(&install_path)?; let install_dir_was_absent = !install_path.exists(); #[cfg(target_os = "windows")] let mut windows_state = WindowsInstallState::default(); @@ -216,10 +204,19 @@ pub async fn start_installation(window: Window, options: InstallOptions) -> Resu let mut extracted = false; let mut used_debug_placeholder = false; let mut checked_locations: Vec = Vec::new(); + let mut installed_files: Vec = Vec::new(); if embedded_payload_available() { checked_locations.push("embedded payload zip".to_string()); preflight_validate_payload_zip_bytes(EMBEDDED_PAYLOAD_ZIP, "embedded payload zip")?; + installed_files = read_payload_manifest_from_zip_bytes( + EMBEDDED_PAYLOAD_ZIP, + "embedded payload zip", + )? + .files + .into_iter() + .map(|entry| entry.path) + .collect(); extract::extract_zip_bytes_with_filter( EMBEDDED_PAYLOAD_ZIP, &install_path, @@ -245,6 +242,14 @@ pub async fn start_installation(window: Window, options: InstallOptions) -> Resu continue; } preflight_validate_payload_zip_file(&candidate.path, &candidate.label)?; + installed_files = read_payload_manifest_from_zip_file( + &candidate.path, + &candidate.label, + )? + .files + .into_iter() + .map(|entry| entry.path) + .collect(); extract::extract_zip_with_filter( &candidate.path, &install_path, @@ -261,6 +266,11 @@ pub async fn start_installation(window: Window, options: InstallOptions) -> Resu continue; } preflight_validate_payload_dir(&candidate.path, &candidate.label)?; + installed_files = read_payload_manifest_from_dir(&candidate.path, &candidate.label)? + .files + .into_iter() + .map(|entry| entry.path) + .collect(); extract::copy_directory_with_filter( &candidate.path, &install_path, @@ -282,6 +292,7 @@ pub async fn start_installation(window: Window, options: InstallOptions) -> Resu std::fs::write(&placeholder, "placeholder") .map_err(|e| format!("Failed to write placeholder: {}", e))?; } + installed_files.push("BitFun.exe".to_string()); used_debug_placeholder = true; } else { return Err(format!( @@ -312,6 +323,7 @@ pub async fn start_installation(window: Window, options: InstallOptions) -> Resu uninstaller_path.display(), install_path.display() ); + installed_files.push("uninstall.exe".to_string()); emit_progress(&window, "registry", 60, "Registering application..."); registry::register_uninstall_entry( @@ -359,6 +371,8 @@ pub async fn start_installation(window: Window, options: InstallOptions) -> Resu } } + write_installed_manifest(&install_path, installed_files)?; + // Step 4: Save first-launch language preference for BitFun app. emit_progress(&window, "config", 92, "Applying startup preferences..."); apply_first_launch_language(&options.app_language) @@ -383,6 +397,7 @@ pub async fn start_installation(window: Window, options: InstallOptions) -> Resu #[tauri::command] pub async fn uninstall(install_path: String) -> Result<(), String> { let install_path = PathBuf::from(&install_path); + let uninstall_targets = collect_uninstall_targets(&install_path)?; #[cfg(target_os = "windows")] { @@ -424,29 +439,25 @@ pub async fn uninstall(install_path: String) -> Result<(), String> { running_from_install_dir )); - if running_uninstall_binary || running_from_install_dir { - if install_path.exists() { - schedule_windows_self_uninstall_cleanup(&install_path)?; - } else { - append_uninstall_runtime_log(&format!( - "install path does not exist, skip cleanup schedule: {}", - install_path.display() - )); - } - return Ok(()); - } - } + let current_exe_path = current_exe.as_deref(); + remove_installed_targets(&install_path, &uninstall_targets, current_exe_path)?; - if install_path.exists() { - std::fs::remove_dir_all(&install_path) - .map_err(|e| format!("Failed to remove files: {}", e))?; + if (running_uninstall_binary || running_from_install_dir) + && current_exe_path + .map(|exe| windows_path_eq_case_insensitive(exe, &install_path.join("uninstall.exe"))) + .unwrap_or(false) + { + schedule_windows_self_uninstall_cleanup(current_exe_path.unwrap())?; + } } + #[cfg(not(target_os = "windows"))] + remove_installed_targets(&install_path, &uninstall_targets, None)?; Ok(()) } #[cfg(target_os = "windows")] -fn schedule_windows_self_uninstall_cleanup(install_path: &Path) -> Result<(), String> { +fn schedule_windows_self_uninstall_cleanup(uninstall_exe_path: &Path) -> Result<(), String> { use std::os::windows::process::CommandExt; const CREATE_NO_WINDOW: u32 = 0x08000000; @@ -465,19 +476,18 @@ if "%TARGET%"=="" exit /b 2 if "%LOG%"=="" set "LOG=%TEMP%\bitfun-uninstall-cleanup.log" echo [%DATE% %TIME%] cleanup start > "%LOG%" cd /d "%TEMP%" -taskkill /f /im BitFun.exe >> "%LOG%" 2>&1 -set "DONE=0" for /L %%i in (1,1,30) do ( - rmdir /s /q "%TARGET%" >> "%LOG%" 2>&1 if not exist "%TARGET%" ( echo [%DATE% %TIME%] cleanup success on try %%i >> "%LOG%" - set "DONE=1" - goto :cleanup_done + exit /b 0 + ) + del /f /q "%TARGET%" >> "%LOG%" 2>&1 + if not exist "%TARGET%" ( + echo [%DATE% %TIME%] cleanup success on try %%i >> "%LOG%" + exit /b 0 ) timeout /t 1 /nobreak >nul ) -:cleanup_done -if "%DONE%"=="1" exit /b 0 echo [%DATE% %TIME%] cleanup failed after retries >> "%LOG%" exit /b 1 "# @@ -489,7 +499,7 @@ exit /b 1 append_uninstall_runtime_log(&format!( "scheduled cleanup script='{}', target='{}', cleanup_log='{}'", script_path.display(), - install_path.display(), + uninstall_exe_path.display(), log_path.display() )); @@ -497,7 +507,7 @@ exit /b 1 .arg("/C") .arg("call") .arg(&script_path) - .arg(install_path) + .arg(uninstall_exe_path) .arg(&log_path) .current_dir(&temp_dir) .creation_flags(CREATE_NO_WINDOW) @@ -941,6 +951,81 @@ fn find_existing_ancestor(path: &Path) -> PathBuf { current } +fn validate_install_target(path: &Path) -> Result<(), String> { + if !path.is_absolute() { + return Err("Installation path must be absolute".into()); + } + + if path.parent().is_none() { + return Err("Refusing to install into a filesystem root directory".into()); + } + + #[cfg(target_os = "windows")] + reject_sensitive_windows_install_path(path)?; + + if path.exists() { + if !path.is_dir() { + return Err("Path exists but is not a directory".into()); + } + if directory_has_entries(path)? + && !path.join(INSTALL_MANIFEST_FILE).exists() + && !path.join("BitFun.exe").exists() + { + return Err( + "Installation directory must be empty or already contain a BitFun installation" + .into(), + ); + } + } + + let writable_dir = if path.exists() { + path.to_path_buf() + } else { + find_existing_ancestor(path) + }; + let test_file = writable_dir.join(".bitfun_install_test"); + match std::fs::write(&test_file, "test") { + Ok(_) => { + let _ = std::fs::remove_file(&test_file); + Ok(()) + } + Err(_) if path.exists() => Err("Directory is not writable".into()), + Err(_) => Err("Cannot write to the parent directory".into()), + } +} + +fn directory_has_entries(path: &Path) -> Result { + let mut entries = std::fs::read_dir(path) + .map_err(|e| format!("Failed to inspect installation directory: {}", e))?; + Ok(entries.next().transpose().map_err(|e| e.to_string())?.is_some()) +} + +#[cfg(target_os = "windows")] +fn reject_sensitive_windows_install_path(path: &Path) -> Result<(), String> { + let sensitive_dirs = [ + dirs::home_dir(), + dirs::desktop_dir(), + dirs::document_dir(), + dirs::download_dir(), + dirs::picture_dir(), + dirs::audio_dir(), + dirs::video_dir(), + dirs::data_local_dir(), + dirs::config_dir(), + ]; + + for sensitive_dir in sensitive_dirs.into_iter().flatten() { + if windows_path_eq_case_insensitive(path, &sensitive_dir) { + return Err(format!( + "Refusing to install directly into sensitive directory: {}", + sensitive_dir.display() + )); + } + } + + Ok(()) +} + fn ensure_app_config_path() -> Result { let config_root = dirs::config_dir() .ok_or_else(|| "Failed to get user config directory".to_string())? @@ -1209,6 +1294,68 @@ fn validate_payload_exe_size(size: u64, source_label: &str) -> Result<(), String Ok(()) } +fn read_payload_manifest_from_zip_bytes( + zip_bytes: &[u8], + source_label: &str, +) -> Result { + let reader = Cursor::new(zip_bytes); + let mut archive = zip::ZipArchive::new(reader) + .map_err(|e| format!("Invalid zip from {source_label}: {e}"))?; + read_payload_manifest_from_zip_archive(&mut archive, source_label) +} + +fn read_payload_manifest_from_zip_file( + path: &Path, + source_label: &str, +) -> Result { + let file = File::open(path) + .map_err(|e| format!("Failed to open payload zip ({source_label}): {e}"))?; + let mut archive = zip::ZipArchive::new(file) + .map_err(|e| format!("Invalid payload zip ({source_label}): {e}"))?; + read_payload_manifest_from_zip_archive(&mut archive, source_label) +} + +fn read_payload_manifest_from_zip_archive( + archive: &mut zip::ZipArchive, + source_label: &str, +) -> Result { + for i in 0..archive.len() { + let mut file = archive + .by_index(i) + .map_err(|e| format!("Failed to read payload entry ({source_label}): {e}"))?; + let file_name = zip_entry_file_name(file.name()); + if !file_name.eq_ignore_ascii_case(PAYLOAD_MANIFEST_FILE) { + continue; + } + let mut raw = String::new(); + file.read_to_string(&mut raw) + .map_err(|e| format!("Failed to read payload manifest ({source_label}): {e}"))?; + return parse_payload_manifest(&raw, source_label); + } + + Err(format!( + "Payload manifest is missing from {source_label}. Refusing unsafe install." + )) +} + +fn read_payload_manifest_from_dir(path: &Path, source_label: &str) -> Result { + let manifest_path = path.join(PAYLOAD_MANIFEST_FILE); + let raw = std::fs::read_to_string(&manifest_path).map_err(|e| { + format!( + "Failed to read payload manifest from {} ({}): {}", + source_label, + manifest_path.display(), + e + ) + })?; + parse_payload_manifest(&raw, source_label) +} + +fn parse_payload_manifest(raw: &str, source_label: &str) -> Result { + serde_json::from_str(raw) + .map_err(|e| format!("Invalid payload manifest from {source_label}: {}", e)) +} + fn zip_entry_file_name(entry_name: &str) -> &str { entry_name .rsplit(&['/', '\\'][..]) @@ -1228,6 +1375,135 @@ fn should_install_payload_path(relative_path: &Path) -> bool { !is_payload_manifest_path(relative_path) } +fn write_installed_manifest(install_path: &Path, files: Vec) -> Result<(), String> { + let mut normalized: Vec = files + .into_iter() + .map(|entry| sanitize_manifest_relative_path(&entry)) + .collect::, _>>()? + .into_iter() + .map(path_buf_to_manifest_string) + .collect(); + normalized.sort(); + normalized.dedup(); + + let manifest = InstalledManifest { + version: 1, + files: normalized, + }; + let path = install_path.join(INSTALL_MANIFEST_FILE); + let body = serde_json::to_string_pretty(&manifest) + .map_err(|e| format!("Failed to serialize install manifest: {}", e))?; + std::fs::write(&path, body) + .map_err(|e| format!("Failed to write install manifest: {}", e)) +} + +fn read_installed_manifest(install_path: &Path) -> Result, String> { + let path = install_path.join(INSTALL_MANIFEST_FILE); + if !path.exists() { + return Ok(None); + } + + let raw = std::fs::read_to_string(&path) + .map_err(|e| format!("Failed to read install manifest: {}", e))?; + let manifest = serde_json::from_str::(&raw) + .map_err(|e| format!("Invalid install manifest: {}", e))?; + Ok(Some(manifest)) +} + +fn collect_uninstall_targets(install_path: &Path) -> Result, String> { + let mut relative_paths = match read_installed_manifest(install_path)? { + Some(manifest) => manifest.files, + None => vec!["BitFun.exe".to_string(), "uninstall.exe".to_string()], + }; + relative_paths.push(INSTALL_MANIFEST_FILE.to_string()); + + let mut targets: Vec = relative_paths + .into_iter() + .map(|entry| sanitize_manifest_relative_path(&entry)) + .collect::, _>>()? + .into_iter() + .map(|entry| install_path.join(entry)) + .collect(); + targets.sort(); + targets.dedup(); + Ok(targets) +} + +fn remove_installed_targets( + install_path: &Path, + targets: &[PathBuf], + skip_file: Option<&Path>, +) -> Result<(), String> { + for path in targets { + if skip_file + .map(|skip| paths_equal_for_platform(path, skip)) + .unwrap_or(false) + { + continue; + } + + if !path.exists() { + continue; + } + + if path.is_file() { + std::fs::remove_file(path) + .map_err(|e| format!("Failed to remove installed file {}: {}", path.display(), e))?; + } + } + + for dir in collect_parent_directories(install_path, targets) { + let _ = std::fs::remove_dir(&dir); + } + + Ok(()) +} + +fn collect_parent_directories(root: &Path, paths: &[PathBuf]) -> Vec { + let mut dirs: Vec = Vec::new(); + for path in paths { + let mut current = path.parent().map(|p| p.to_path_buf()); + while let Some(dir) = current { + if paths_equal_for_platform(&dir, root) { + break; + } + if dirs.iter().any(|existing| existing == &dir) { + break; + } + dirs.push(dir.clone()); + current = dir.parent().map(|p| p.to_path_buf()); + } + } + + dirs.sort_by(|a, b| { + b.components() + .count() + .cmp(&a.components().count()) + .then_with(|| a.cmp(b)) + }); + dirs +} + +fn sanitize_manifest_relative_path(raw: &str) -> Result { + let path = PathBuf::from(raw); + if path.is_absolute() { + return Err(format!("Manifest entry must be relative: {}", raw)); + } + + if path + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return Err(format!("Manifest entry escapes install directory: {}", raw)); + } + + Ok(path) +} + +fn path_buf_to_manifest_string(path: PathBuf) -> String { + path.to_string_lossy().replace('\\', "/") +} + fn verify_installed_payload(install_path: &Path) -> Result<(), String> { let app_exe = install_path.join("BitFun.exe"); let app_meta = std::fs::metadata(&app_exe) @@ -1242,6 +1518,18 @@ fn verify_installed_payload(install_path: &Path) -> Result<(), String> { Ok(()) } +fn paths_equal_for_platform(a: &Path, b: &Path) -> bool { + #[cfg(target_os = "windows")] + { + windows_path_eq_case_insensitive(a, b) + } + + #[cfg(not(target_os = "windows"))] + { + a == b + } +} + #[cfg(target_os = "windows")] fn rollback_installation( install_path: &Path, diff --git a/BitFun-Installer/src/hooks/useInstaller.ts b/BitFun-Installer/src/hooks/useInstaller.ts index a4504f69..674a6a1f 100644 --- a/BitFun-Installer/src/hooks/useInstaller.ts +++ b/BitFun-Installer/src/hooks/useInstaller.ts @@ -146,13 +146,14 @@ export function useInstaller(): UseInstallerReturn { const install = useCallback(async () => { setError(null); - setIsInstalling(true); - setInstallationCompleted(false); - setCanConfirmProgress(false); - setStep('progress'); - setProgress({ step: 'prepare', percent: 0, message: '' }); if (MOCK_INSTALL_FOR_DEBUG) { + setIsInstalling(true); + setInstallationCompleted(false); + setCanConfirmProgress(false); + setStep('progress'); + setProgress({ step: 'prepare', percent: 0, message: '' }); + const durationMs = 5000; const startedAt = Date.now(); @@ -184,6 +185,12 @@ export function useInstaller(): UseInstallerReturn { } try { + await invoke('validate_install_path', { path: options.installPath }); + setIsInstalling(true); + setInstallationCompleted(false); + setCanConfirmProgress(false); + setStep('progress'); + setProgress({ step: 'prepare', percent: 0, message: '' }); await invoke('start_installation', { options }); setInstallationCompleted(true); setStep('model'); From b586a5464b3854621e41a9612eef818b77d92dfb Mon Sep 17 00:00:00 2001 From: gcw_VNOUEmGJ Date: Thu, 12 Mar 2026 16:30:07 +0800 Subject: [PATCH 2/2] installer: resolve non-empty install paths --- .../src-tauri/src/installer/commands.rs | 78 +++++---- BitFun-Installer/src/App.tsx | 1 + BitFun-Installer/src/hooks/useInstaller.ts | 20 ++- BitFun-Installer/src/pages/Options.tsx | 161 +++++++++++++++--- BitFun-Installer/src/types/installer.ts | 4 + 5 files changed, 209 insertions(+), 55 deletions(-) diff --git a/BitFun-Installer/src-tauri/src/installer/commands.rs b/BitFun-Installer/src-tauri/src/installer/commands.rs index a4a213a2..71caf166 100644 --- a/BitFun-Installer/src-tauri/src/installer/commands.rs +++ b/BitFun-Installer/src-tauri/src/installer/commands.rs @@ -51,6 +51,12 @@ pub struct LaunchContext { pub app_language: Option, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InstallPathValidation { + pub install_path: String, +} + /// Get the default installation path. #[tauri::command] pub fn get_default_install_path() -> String { @@ -177,17 +183,18 @@ pub fn get_launch_context() -> LaunchContext { /// Validate the installation path. #[tauri::command] -pub fn validate_install_path(path: String) -> Result { - let path = PathBuf::from(&path); - validate_install_target(&path)?; - Ok(true) +pub fn validate_install_path(path: String) -> Result { + let requested_path = PathBuf::from(&path); + let install_path = prepare_install_target(&requested_path)?; + Ok(InstallPathValidation { + install_path: install_path.to_string_lossy().to_string(), + }) } /// Main installation command. Emits progress events to the frontend. #[tauri::command] pub async fn start_installation(window: Window, options: InstallOptions) -> Result<(), String> { - let install_path = PathBuf::from(&options.install_path); - validate_install_target(&install_path)?; + let install_path = prepare_install_target(Path::new(&options.install_path))?; let install_dir_was_absent = !install_path.exists(); #[cfg(target_os = "windows")] let mut windows_state = WindowsInstallState::default(); @@ -951,25 +958,27 @@ fn find_existing_ancestor(path: &Path) -> PathBuf { current } -fn validate_install_target(path: &Path) -> Result<(), String> { - if !path.is_absolute() { +fn prepare_install_target(requested_path: &Path) -> Result { + if !requested_path.is_absolute() { return Err("Installation path must be absolute".into()); } - if path.parent().is_none() { + if requested_path.parent().is_none() { return Err("Refusing to install into a filesystem root directory".into()); } #[cfg(target_os = "windows")] - reject_sensitive_windows_install_path(path)?; + let install_path = resolve_windows_install_target(requested_path)?; + #[cfg(not(target_os = "windows"))] + let install_path = requested_path.to_path_buf(); - if path.exists() { - if !path.is_dir() { + if install_path.exists() { + if !install_path.is_dir() { return Err("Path exists but is not a directory".into()); } - if directory_has_entries(path)? - && !path.join(INSTALL_MANIFEST_FILE).exists() - && !path.join("BitFun.exe").exists() + if directory_has_entries(&install_path)? + && !install_path.join(INSTALL_MANIFEST_FILE).exists() + && !install_path.join("BitFun.exe").exists() { return Err( "Installation directory must be empty or already contain a BitFun installation" @@ -978,18 +987,18 @@ fn validate_install_target(path: &Path) -> Result<(), String> { } } - let writable_dir = if path.exists() { - path.to_path_buf() + let writable_dir = if install_path.exists() { + install_path.clone() } else { - find_existing_ancestor(path) + find_existing_ancestor(&install_path) }; let test_file = writable_dir.join(".bitfun_install_test"); match std::fs::write(&test_file, "test") { Ok(_) => { let _ = std::fs::remove_file(&test_file); - Ok(()) + Ok(install_path) } - Err(_) if path.exists() => Err("Directory is not writable".into()), + Err(_) if install_path.exists() => Err("Directory is not writable".into()), Err(_) => Err("Cannot write to the parent directory".into()), } } @@ -1001,7 +1010,11 @@ fn directory_has_entries(path: &Path) -> Result { } #[cfg(target_os = "windows")] -fn reject_sensitive_windows_install_path(path: &Path) -> Result<(), String> { +fn resolve_windows_install_target(requested_path: &Path) -> Result { + if requested_path.exists() && !requested_path.is_dir() { + return Err("Path exists but is not a directory".into()); + } + let sensitive_dirs = [ dirs::home_dir(), dirs::desktop_dir(), @@ -1014,16 +1027,23 @@ fn reject_sensitive_windows_install_path(path: &Path) -> Result<(), String> { dirs::config_dir(), ]; - for sensitive_dir in sensitive_dirs.into_iter().flatten() { - if windows_path_eq_case_insensitive(path, &sensitive_dir) { - return Err(format!( - "Refusing to install directly into sensitive directory: {}", - sensitive_dir.display() - )); - } + if sensitive_dirs + .into_iter() + .flatten() + .any(|sensitive_dir| windows_path_eq_case_insensitive(requested_path, &sensitive_dir)) + { + return Ok(requested_path.join("BitFun")); } - Ok(()) + if requested_path.exists() + && directory_has_entries(requested_path)? + && !requested_path.join(INSTALL_MANIFEST_FILE).exists() + && !requested_path.join("BitFun.exe").exists() + { + return Ok(requested_path.join("BitFun")); + } + + Ok(requested_path.to_path_buf()) } fn ensure_app_config_path() -> Result { diff --git a/BitFun-Installer/src/App.tsx b/BitFun-Installer/src/App.tsx index ac6a3be5..ab7fea64 100644 --- a/BitFun-Installer/src/App.tsx +++ b/BitFun-Installer/src/App.tsx @@ -47,6 +47,7 @@ function App() { options={installer.options} setOptions={installer.setOptions} diskSpace={installer.diskSpace} + error={installer.error} refreshDiskSpace={installer.refreshDiskSpace} onBack={installer.back} onInstall={installer.install} diff --git a/BitFun-Installer/src/hooks/useInstaller.ts b/BitFun-Installer/src/hooks/useInstaller.ts index 674a6a1f..4193d4a4 100644 --- a/BitFun-Installer/src/hooks/useInstaller.ts +++ b/BitFun-Installer/src/hooks/useInstaller.ts @@ -10,6 +10,7 @@ import type { ModelConfig, ConnectionTestResult, LaunchContext, + InstallPathValidation, } from '../types/installer'; import { DEFAULT_OPTIONS } from '../types/installer'; @@ -123,6 +124,12 @@ export function useInstaller(): UseInstallerReturn { return () => { unlisten.then((fn) => fn()); }; }, []); + useEffect(() => { + if (step === 'options' && error) { + setError(null); + } + }, [error, options.installPath, step]); + const goTo = useCallback((s: InstallStep) => setStep(s), []); const next = useCallback(() => { @@ -185,13 +192,22 @@ export function useInstaller(): UseInstallerReturn { } try { - await invoke('validate_install_path', { path: options.installPath }); + const validated = await invoke('validate_install_path', { + path: options.installPath, + }); + const effectiveOptions = { + ...options, + installPath: validated.installPath, + }; + if (validated.installPath !== options.installPath) { + setOptions((prev) => ({ ...prev, installPath: validated.installPath })); + } setIsInstalling(true); setInstallationCompleted(false); setCanConfirmProgress(false); setStep('progress'); setProgress({ step: 'prepare', percent: 0, message: '' }); - await invoke('start_installation', { options }); + await invoke('start_installation', { options: effectiveOptions }); setInstallationCompleted(true); setStep('model'); } catch (err: any) { diff --git a/BitFun-Installer/src/pages/Options.tsx b/BitFun-Installer/src/pages/Options.tsx index 6b08d461..a40dca52 100644 --- a/BitFun-Installer/src/pages/Options.tsx +++ b/BitFun-Installer/src/pages/Options.tsx @@ -8,13 +8,20 @@ interface OptionsProps { options: InstallOptions; setOptions: React.Dispatch>; diskSpace: DiskSpaceInfo | null; + error: string | null; refreshDiskSpace: (path: string) => Promise; onBack: () => void; onInstall: () => void; } export function Options({ - options, setOptions, diskSpace, refreshDiskSpace, onBack, onInstall, + options, + setOptions, + diskSpace, + error, + refreshDiskSpace, + onBack, + onInstall, }: OptionsProps) { const { t } = useTranslation(); @@ -23,8 +30,14 @@ export function Options({ }, [options.installPath, refreshDiskSpace]); const handleBrowse = async () => { - const selected = await open({ directory: true, defaultPath: options.installPath, title: t('options.pathLabel') }); - if (selected && typeof selected === 'string') setOptions((p) => ({ ...p, installPath: selected })); + const selected = await open({ + directory: true, + defaultPath: options.installPath, + title: t('options.pathLabel'), + }); + if (selected && typeof selected === 'string') { + setOptions((prev) => ({ ...prev, installPath: selected })); + } }; const formatBytes = (bytes: number): string => { @@ -36,40 +49,93 @@ export function Options({ }; const update = (key: keyof InstallOptions, value: boolean) => { - setOptions((p) => ({ ...p, [key]: value })); + setOptions((prev) => ({ ...prev, [key]: value })); }; return ( -
+
{t('options.subtitle')}
- + {t('options.pathLabel')}
setOptions((p) => ({ ...p, installPath: e.target.value }))} + className="input" + type="text" + value={options.installPath} + onChange={(e) => setOptions((prev) => ({ ...prev, installPath: e.target.value }))} placeholder={t('options.pathPlaceholder')} /> -
{diskSpace && ( -
+
{t('options.required')}: {formatBytes(diskSpace.required)} - {t('options.available')}: {diskSpace.available < Number.MAX_SAFE_INTEGER ? formatBytes(diskSpace.available) : '—'} - {!diskSpace.sufficient && {t('options.insufficientSpace')}} + + {t('options.available')}:{' '} + {diskSpace.available < Number.MAX_SAFE_INTEGER ? formatBytes(diskSpace.available) : '-'} + + {!diskSpace.sufficient && ( + {t('options.insufficientSpace')} + )} +
+ )} + {error && ( +
+ {error}
)}
@@ -77,24 +143,71 @@ export function Options({
{t('options.optionsLabel')}
- update('desktopShortcut', v)} label={t('options.desktopShortcut')} /> - update('startMenu', v)} label={t('options.startMenu')} /> - update('contextMenu', v)} label={t('options.contextMenu')} /> - update('addToPath', v)} label={t('options.addToPath')} /> + update('desktopShortcut', value)} + label={t('options.desktopShortcut')} + /> + update('startMenu', value)} + label={t('options.startMenu')} + /> + update('contextMenu', value)} + label={t('options.contextMenu')} + /> + update('addToPath', value)} + label={t('options.addToPath')} + />
-
+
diff --git a/BitFun-Installer/src/types/installer.ts b/BitFun-Installer/src/types/installer.ts index 41c540ab..6df2d13f 100644 --- a/BitFun-Installer/src/types/installer.ts +++ b/BitFun-Installer/src/types/installer.ts @@ -7,6 +7,10 @@ export interface LaunchContext { appLanguage?: 'zh-CN' | 'en-US' | null; } +export interface InstallPathValidation { + installPath: string; +} + export type ThemeId = | 'bitfun-dark' | 'bitfun-light'