Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 19 additions & 12 deletions BitFun-Installer/src-tauri/src/installer/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf, String> {
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
));
}
}

Expand All @@ -980,15 +983,19 @@ fn prepare_install_target(requested_path: &Path) -> Result<PathBuf, String> {
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<bool, String> {
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<PathBuf, String> {
Expand Down
2 changes: 2 additions & 0 deletions BitFun-Installer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ function App() {
refreshDiskSpace={installer.refreshDiskSpace}
onBack={installer.back}
onInstall={installer.install}
isInstalling={installer.isInstalling}
clearInstallError={installer.clearInstallError}
/>
);
case 'model':
Expand Down
74 changes: 74 additions & 0 deletions BitFun-Installer/src/components/InstallErrorPanel.tsx
Original file line number Diff line number Diff line change
@@ -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 ? (
<div
style={{
marginTop: 10,
padding: '10px 12px',
borderRadius: 10,
border: '1px solid color-mix(in srgb, var(--border-base) 70%, transparent)',
background: 'color-mix(in srgb, var(--element-bg-subtle) 80%, transparent)',
color: 'var(--color-text-secondary)',
fontSize: 11,
lineHeight: 1.55,
textAlign: variant === 'bare' ? 'center' : 'left',
}}
>
{t('errors.installPath.adminHint')}
</div>
) : null;

if (variant === 'bare') {
return (
<>
<div
style={{
color: 'var(--color-text-muted)',
fontSize: 12,
lineHeight: 1.6,
textAlign: 'center',
maxWidth: 320,
}}
>
{text}
</div>
{adminBlock}
</>
);
}

return (
<div
style={{
marginTop: 10,
padding: '10px 12px',
borderRadius: 10,
border: '1px solid color-mix(in srgb, var(--color-error) 55%, transparent)',
background: 'color-mix(in srgb, var(--color-error) 10%, transparent)',
color: 'var(--color-text-primary)',
fontSize: 12,
lineHeight: 1.5,
}}
>
{text}
{adminBlock}
</div>
);
}
20 changes: 10 additions & 10 deletions BitFun-Installer/src/hooks/useInstaller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export interface UseInstallerReturn {
launchApp: () => Promise<void>;
closeInstaller: () => void;
refreshDiskSpace: (path: string) => Promise<void>;
clearInstallError: () => void;
isUninstallMode: boolean;
isUninstalling: boolean;
uninstallCompleted: boolean;
Expand Down Expand Up @@ -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), []);

Expand Down Expand Up @@ -191,6 +190,9 @@ export function useInstaller(): UseInstallerReturn {
return;
}

setIsInstalling(true);
setInstallationCompleted(false);
setCanConfirmProgress(false);
try {
const validated = await invoke<InstallPathValidation>('validate_install_path', {
path: options.installPath,
Expand All @@ -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);
}
Expand Down Expand Up @@ -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,
};
}
16 changes: 16 additions & 0 deletions BitFun-Installer/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -27,6 +42,7 @@
"launchAfterInstall": "Launch BitFun after setup",
"back": "Back",
"install": "Install",
"installing": "Preparing…",
"nextModel": "Next: Configure model"
},
"model": {
Expand Down
16 changes: 16 additions & 0 deletions BitFun-Installer/src/i18n/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "确认安装位置与安装偏好",
Expand All @@ -27,6 +42,7 @@
"launchAfterInstall": "安装后启动 BitFun",
"back": "返回",
"install": "安装",
"installing": "准备中…",
"nextModel": "下一步:配置模型"
},
"model": {
Expand Down
46 changes: 24 additions & 22 deletions BitFun-Installer/src/pages/Options.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -12,7 +13,9 @@ interface OptionsProps {
error: string | null;
refreshDiskSpace: (path: string) => Promise<void>;
onBack: () => void;
onInstall: () => void;
onInstall: () => Promise<void>;
isInstalling: boolean;
clearInstallError: () => void;
}

export function Options({
Expand All @@ -23,6 +26,8 @@ export function Options({
refreshDiskSpace,
onBack,
onInstall,
isInstalling,
clearInstallError,
}: OptionsProps) {
const { t } = useTranslation();

Expand All @@ -45,6 +50,7 @@ export function Options({
} catch {
setOptions((prev) => ({ ...prev, installPath: selected }));
}
clearInstallError();
}
};

Expand Down Expand Up @@ -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')}
/>
<button
className="btn"
type="button"
disabled={isInstalling}
onClick={handleBrowse}
style={{ padding: '10px 14px', flexShrink: 0 }}
>
Expand Down Expand Up @@ -121,22 +133,7 @@ export function Options({
)}
</div>
)}
{error && (
<div
style={{
marginTop: 10,
padding: '10px 12px',
borderRadius: 10,
border: '1px solid color-mix(in srgb, var(--color-error) 55%, transparent)',
background: 'color-mix(in srgb, var(--color-error) 10%, transparent)',
color: 'var(--color-text-primary)',
fontSize: 12,
lineHeight: 1.5,
}}
>
{error}
</div>
)}
{error && <InstallErrorPanel message={error} variant="options" />}
</div>

<div>
Expand Down Expand Up @@ -168,7 +165,7 @@ export function Options({
</div>

<div className="page-footer page-footer--split">
<button className="btn btn-ghost" onClick={onBack}>
<button className="btn btn-ghost" type="button" disabled={isInstalling} onClick={onBack}>
<svg
width="14"
height="14"
Expand All @@ -185,10 +182,15 @@ export function Options({
</button>
<button
className="btn btn-primary"
onClick={onInstall}
disabled={!options.installPath || (diskSpace !== null && !diskSpace.sufficient)}
type="button"
onClick={() => { void onInstall(); }}
disabled={
!options.installPath
|| (diskSpace !== null && !diskSpace.sufficient)
|| isInstalling
}
>
{t('options.install')}
{isInstalling ? t('options.installing') : t('options.install')}
<svg
width="14"
height="14"
Expand Down
Loading
Loading