From 22b6539e2d9df4564502192c71c18c732d459064 Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Tue, 31 Mar 2026 12:44:34 +0800 Subject: [PATCH] fix(installer): show up install_path_validation error to avoid click install-btn with no response --- .../src-tauri/src/installer/commands.rs | 31 +++++--- BitFun-Installer/src/App.tsx | 2 + .../src/components/InstallErrorPanel.tsx | 74 +++++++++++++++++++ BitFun-Installer/src/hooks/useInstaller.ts | 20 ++--- BitFun-Installer/src/i18n/locales/en.json | 16 ++++ BitFun-Installer/src/i18n/locales/zh.json | 16 ++++ BitFun-Installer/src/pages/Options.tsx | 46 ++++++------ BitFun-Installer/src/pages/Progress.tsx | 28 +++---- .../src/utils/installPathErrors.ts | 32 ++++++++ 9 files changed, 207 insertions(+), 58 deletions(-) create mode 100644 BitFun-Installer/src/components/InstallErrorPanel.tsx create mode 100644 BitFun-Installer/src/utils/installPathErrors.ts diff --git a/BitFun-Installer/src-tauri/src/installer/commands.rs b/BitFun-Installer/src-tauri/src/installer/commands.rs index 6dc06bd7..15353243 100644 --- a/BitFun-Installer/src-tauri/src/installer/commands.rs +++ b/BitFun-Installer/src-tauri/src/installer/commands.rs @@ -939,33 +939,36 @@ fn with_bitfun_install_subdir(path: PathBuf) -> PathBuf { } } +/// Stable codes for `validate_install_path` / `prepare_install_target`; localized in the frontend. +const INSTALL_PATH_ERR_PREFIX: &str = "INSTALL_PATH::"; + fn prepare_install_target(requested_path: &Path) -> Result { if !requested_path.is_absolute() { - return Err("Installation path must be absolute".into()); + return Err(format!("{}not_absolute", INSTALL_PATH_ERR_PREFIX)); } if requested_path.parent().is_none() { - return Err("Refusing to install into a filesystem root directory".into()); + return Err(format!("{}filesystem_root", INSTALL_PATH_ERR_PREFIX)); } if requested_path.exists() && !requested_path.is_dir() { - return Err("Path exists but is not a directory".into()); + return Err(format!("{}path_not_directory", INSTALL_PATH_ERR_PREFIX)); } let install_path = with_bitfun_install_subdir(requested_path.to_path_buf()); if install_path.exists() { if !install_path.is_dir() { - return Err("Path exists but is not a directory".into()); + return Err(format!("{}path_not_directory", INSTALL_PATH_ERR_PREFIX)); } 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" - .into(), - ); + return Err(format!( + "{}directory_must_be_empty_or_bitfun", + INSTALL_PATH_ERR_PREFIX + )); } } @@ -980,15 +983,19 @@ fn prepare_install_target(requested_path: &Path) -> Result { let _ = std::fs::remove_file(&test_file); Ok(install_path) } - Err(_) if install_path.exists() => Err("Directory is not writable".into()), - Err(_) => Err("Cannot write to the parent directory".into()), + Err(_) if install_path.exists() => Err(format!("{}directory_not_writable", INSTALL_PATH_ERR_PREFIX)), + Err(_) => Err(format!("{}parent_not_writable", INSTALL_PATH_ERR_PREFIX)), } } 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()) + .map_err(|_| format!("{}inspect_directory_failed", INSTALL_PATH_ERR_PREFIX))?; + Ok(entries + .next() + .transpose() + .map_err(|_| format!("{}inspect_directory_failed", INSTALL_PATH_ERR_PREFIX))? + .is_some()) } fn ensure_app_config_path() -> Result { diff --git a/BitFun-Installer/src/App.tsx b/BitFun-Installer/src/App.tsx index 0d92b985..cf333006 100644 --- a/BitFun-Installer/src/App.tsx +++ b/BitFun-Installer/src/App.tsx @@ -53,6 +53,8 @@ function App() { refreshDiskSpace={installer.refreshDiskSpace} onBack={installer.back} onInstall={installer.install} + isInstalling={installer.isInstalling} + clearInstallError={installer.clearInstallError} /> ); case 'model': diff --git a/BitFun-Installer/src/components/InstallErrorPanel.tsx b/BitFun-Installer/src/components/InstallErrorPanel.tsx new file mode 100644 index 00000000..c9f8bcd8 --- /dev/null +++ b/BitFun-Installer/src/components/InstallErrorPanel.tsx @@ -0,0 +1,74 @@ +import { useTranslation } from 'react-i18next'; +import { + formatInstallPathError, + installPathErrorShowsAdminHint, + parseInstallPathErrorCode, +} from '../utils/installPathErrors'; + +interface InstallErrorPanelProps { + message: string; + /** Options page: red alert box. Progress: plain text under title. */ + variant?: 'options' | 'bare'; +} + +export function InstallErrorPanel({ message, variant = 'options' }: InstallErrorPanelProps) { + const { t } = useTranslation(); + const text = formatInstallPathError(message, t); + const code = parseInstallPathErrorCode(message); + const showAdmin = installPathErrorShowsAdminHint(code); + + const adminBlock = showAdmin ? ( +
+ {t('errors.installPath.adminHint')} +
+ ) : null; + + if (variant === 'bare') { + return ( + <> +
+ {text} +
+ {adminBlock} + + ); + } + + return ( +
+ {text} + {adminBlock} +
+ ); +} diff --git a/BitFun-Installer/src/hooks/useInstaller.ts b/BitFun-Installer/src/hooks/useInstaller.ts index f3220782..e1a34a8e 100644 --- a/BitFun-Installer/src/hooks/useInstaller.ts +++ b/BitFun-Installer/src/hooks/useInstaller.ts @@ -36,6 +36,7 @@ export interface UseInstallerReturn { launchApp: () => Promise; closeInstaller: () => void; refreshDiskSpace: (path: string) => Promise; + clearInstallError: () => void; isUninstallMode: boolean; isUninstalling: boolean; uninstallCompleted: boolean; @@ -124,11 +125,9 @@ export function useInstaller(): UseInstallerReturn { return () => { unlisten.then((fn) => fn()); }; }, []); - useEffect(() => { - if (step === 'options' && error) { - setError(null); - } - }, [error, options.installPath, step]); + const clearInstallError = useCallback(() => { + setError(null); + }, []); const goTo = useCallback((s: InstallStep) => setStep(s), []); @@ -191,6 +190,9 @@ export function useInstaller(): UseInstallerReturn { return; } + setIsInstalling(true); + setInstallationCompleted(false); + setCanConfirmProgress(false); try { const validated = await invoke('validate_install_path', { path: options.installPath, @@ -202,16 +204,14 @@ export function useInstaller(): UseInstallerReturn { 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: effectiveOptions }); setInstallationCompleted(true); setStep('model'); } catch (err: any) { - setError(typeof err === 'string' ? err : err.message || 'Installation failed'); + const raw = typeof err === 'string' ? err : err?.message; + setError((raw && String(raw).trim()) ? String(raw) : i18n.t('errors.install.failed')); } finally { setIsInstalling(false); } @@ -293,7 +293,7 @@ export function useInstaller(): UseInstallerReturn { options, setOptions, progress, isInstalling, installationCompleted, error, diskSpace, install, canConfirmProgress, confirmProgress, retryInstall, backToOptions, - saveModelConfig, testModelConnection, launchApp, closeInstaller, refreshDiskSpace, + saveModelConfig, testModelConnection, launchApp, closeInstaller, refreshDiskSpace, clearInstallError, isUninstallMode, isUninstalling, uninstallCompleted, uninstallError, uninstallProgress, startUninstall, }; } diff --git a/BitFun-Installer/src/i18n/locales/en.json b/BitFun-Installer/src/i18n/locales/en.json index f242015d..cc4696fe 100644 --- a/BitFun-Installer/src/i18n/locales/en.json +++ b/BitFun-Installer/src/i18n/locales/en.json @@ -4,6 +4,21 @@ "subtitle": "Select your preferred language", "continue": "Continue" }, + "errors": { + "install": { + "failed": "Installation failed" + }, + "installPath": { + "notAbsolute": "The installation path must be an absolute path (for example C:\\…).", + "filesystemRoot": "Cannot install to a drive root. Choose a folder inside the drive.", + "pathNotDirectory": "The selected path exists but is not a folder.", + "directoryMustBeEmptyOrBitfun": "The installation folder must be empty, or already contain a BitFun installation.", + "inspectDirectoryFailed": "Could not read the installation folder. Check permissions and try again.", + "directoryNotWritable": "The installation folder is not writable. Choose another location or run the installer as administrator (see below).", + "parentNotWritable": "The parent folder is not writable. System folders such as Program Files often require administrator rights (see below).", + "adminHint": "To install under protected locations (for example C:\\Program Files), close this installer, right‑click the installer executable, choose \"Run as administrator\", then try again. Alternatively install under your user profile, for example %LOCALAPPDATA%\\Programs, which does not require elevation." + } + }, "options": { "title": "Options", "subtitle": "Review install location and preferences", @@ -27,6 +42,7 @@ "launchAfterInstall": "Launch BitFun after setup", "back": "Back", "install": "Install", + "installing": "Preparing…", "nextModel": "Next: Configure model" }, "model": { diff --git a/BitFun-Installer/src/i18n/locales/zh.json b/BitFun-Installer/src/i18n/locales/zh.json index 01c85e2b..541581aa 100644 --- a/BitFun-Installer/src/i18n/locales/zh.json +++ b/BitFun-Installer/src/i18n/locales/zh.json @@ -4,6 +4,21 @@ "subtitle": "选择您的首选语言", "continue": "继续" }, + "errors": { + "install": { + "failed": "安装失败" + }, + "installPath": { + "notAbsolute": "安装路径必须是绝对路径(例如 C:\\…)。", + "filesystemRoot": "不能安装到磁盘根目录,请选择磁盘下的某个文件夹。", + "pathNotDirectory": "所选路径已存在但不是文件夹。", + "directoryMustBeEmptyOrBitfun": "安装目录必须为空,或已包含 BitFun 安装。", + "inspectDirectoryFailed": "无法读取安装目录,请检查权限后重试。", + "directoryNotWritable": "安装目录不可写入。请更换路径,或以管理员身份运行安装器(见下方说明)。", + "parentNotWritable": "上级目录不可写入。系统目录(如 Program Files)通常需要管理员权限(见下方说明)。", + "adminHint": "若需安装到受保护位置(例如 C:\\Program Files),请关闭本安装器,在安装程序上右键选择「以管理员身份运行」后重新安装。也可安装到当前用户目录(例如 %LOCALAPPDATA%\\Programs),一般无需管理员权限。" + } + }, "options": { "title": "选项", "subtitle": "确认安装位置与安装偏好", @@ -27,6 +42,7 @@ "launchAfterInstall": "安装后启动 BitFun", "back": "返回", "install": "安装", + "installing": "准备中…", "nextModel": "下一步:配置模型" }, "model": { diff --git a/BitFun-Installer/src/pages/Options.tsx b/BitFun-Installer/src/pages/Options.tsx index ad07aeef..0a34e3cc 100644 --- a/BitFun-Installer/src/pages/Options.tsx +++ b/BitFun-Installer/src/pages/Options.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import { invoke } from '@tauri-apps/api/core'; import { open } from '@tauri-apps/plugin-dialog'; import { Checkbox } from '../components/Checkbox'; +import { InstallErrorPanel } from '../components/InstallErrorPanel'; import type { InstallOptions, DiskSpaceInfo, InstallPathValidation } from '../types/installer'; interface OptionsProps { @@ -12,7 +13,9 @@ interface OptionsProps { error: string | null; refreshDiskSpace: (path: string) => Promise; onBack: () => void; - onInstall: () => void; + onInstall: () => Promise; + isInstalling: boolean; + clearInstallError: () => void; } export function Options({ @@ -23,6 +26,8 @@ export function Options({ refreshDiskSpace, onBack, onInstall, + isInstalling, + clearInstallError, }: OptionsProps) { const { t } = useTranslation(); @@ -45,6 +50,7 @@ export function Options({ } catch { setOptions((prev) => ({ ...prev, installPath: selected })); } + clearInstallError(); } }; @@ -88,11 +94,17 @@ export function Options({ className="input" type="text" value={options.installPath} - onChange={(e) => setOptions((prev) => ({ ...prev, installPath: e.target.value }))} + disabled={isInstalling} + onChange={(e) => { + setOptions((prev) => ({ ...prev, installPath: e.target.value })); + clearInstallError(); + }} placeholder={t('options.pathPlaceholder')} /> - + canConfirmProgress && ( +
+ +
+ ) ) : (