import { useState, useEffect, useCallback } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import i18n from '../i18n';
import { detectInstallerUiLanguage, mapUiLanguageToAppLanguage } from '../i18n/languages';
import type {
InstallStep,
InstallOptions,
InstallProgress,
DiskSpaceInfo,
ModelConfig,
ConnectionTestResult,
LaunchContext,
InstallPathValidation,
ExistingInstallation,
} from '../types/installer';
import { DEFAULT_OPTIONS } from '../types/installer';
export interface UseInstallerReturn {
step: InstallStep;
goTo: (step: InstallStep) => void;
next: () => void;
back: () => void;
options: InstallOptions;
setOptions: React.Dispatch<React.SetStateAction<InstallOptions>>;
progress: InstallProgress;
isInstalling: boolean;
installationCompleted: boolean;
error: string | null;
diskSpace: DiskSpaceInfo | null;
existingInstall: ExistingInstallation | null;
launchRegisteredUninstaller: () => Promise<void>;
install: () => Promise<void>;
canConfirmProgress: boolean;
confirmProgress: () => void;
retryInstall: () => Promise<void>;
backToOptions: () => void;
saveModelConfig: () => Promise<void>;
testModelConnection: (modelConfig: ModelConfig) => Promise<ConnectionTestResult>;
launchApp: () => Promise<void>;
closeInstaller: () => void;
refreshDiskSpace: (path: string) => Promise<void>;
clearInstallError: () => void;
isUninstallMode: boolean;
isUninstalling: boolean;
uninstallCompleted: boolean;
uninstallError: string | null;
uninstallProgress: number;
startUninstall: () => Promise<void>;
}
const STEPS: InstallStep[] = ['lang', 'options', 'progress', 'model', 'theme'];
const MOCK_INSTALL_FOR_DEBUG = import.meta.env.DEV && import.meta.env.VITE_MOCK_INSTALL === 'true';
export function useInstaller(): UseInstallerReturn {
const [step, setStep] = useState<InstallStep>('lang');
const [options, setOptions] = useState<InstallOptions>(DEFAULT_OPTIONS);
const [progress, setProgress] = useState<InstallProgress>({
step: '',
percent: 0,
message: '',
});
const [isInstalling, setIsInstalling] = useState(false);
const [installationCompleted, setInstallationCompleted] = useState(false);
const [canConfirmProgress, setCanConfirmProgress] = useState(false);
const [error, setError] = useState<string | null>(null);
const [diskSpace, setDiskSpace] = useState<DiskSpaceInfo | null>(null);
const [existingInstall, setExistingInstall] = useState<ExistingInstallation | null>(null);
const [isUninstallMode, setIsUninstallMode] = useState(false);
const [isUninstalling, setIsUninstalling] = useState(false);
const [uninstallCompleted, setUninstallCompleted] = useState(false);
const [uninstallError, setUninstallError] = useState<string | null>(null);
const [uninstallProgress, setUninstallProgress] = useState(0);
const emptyExistingInstall: ExistingInstallation = {
detected: false,
installLocation: null,
displayVersion: null,
uninstallString: null,
mainBinaryPresent: false,
source: null,
};
useEffect(() => {
let mounted = true;
(async () => {
try {
const context = await invoke<LaunchContext>('get_launch_context');
if (!mounted) return;
const uiLanguage = detectInstallerUiLanguage(context.appLanguage ?? null);
await i18n.changeLanguage(uiLanguage);
if (!mounted) return;
setOptions((prev) => ({
...prev,
appLanguage: mapUiLanguageToAppLanguage(uiLanguage),
}));
if (context.mode === 'uninstall') {
setIsUninstallMode(true);
setStep('uninstall');
const uninstallPath = context.uninstallPath;
if (uninstallPath) {
setOptions((prev) => ({ ...prev, installPath: uninstallPath }));
}
return;
}
} catch (err) {
console.warn('Failed to detect launch context:', err);
}
try {
const path = await invoke<string>('get_initial_install_path');
if (mounted) {
setOptions((prev) => ({ ...prev, installPath: path }));
}
} catch (err) {
console.warn('Failed to get default install path:', err);
}
})();
return () => { mounted = false; };
}, []);
useEffect(() => {
const unlisten = listen<InstallProgress>('install-progress', (event) => {
setProgress(event.payload);
});
return () => { unlisten.then((fn) => fn()); };
}, []);
const clearInstallError = useCallback(() => {
setError(null);
}, []);
useEffect(() => {
setError(null);
}, [options.installPath, step]);
const readExistingInstall = useCallback(async (): Promise<ExistingInstallation> => {
try {
return await invoke<ExistingInstallation>('get_existing_installation');
} catch (err) {
console.warn('Failed to detect existing installation:', err);
return emptyExistingInstall;
}
}, []);
const refreshExistingInstall = useCallback(async () => {
const info = await readExistingInstall();
setExistingInstall(info);
return info;
}, [readExistingInstall]);
useEffect(() => {
if (step !== 'options') return;
let mounted = true;
(async () => {
const info = await readExistingInstall();
if (mounted) {
setExistingInstall(info);
}
})();
return () => {
mounted = false;
};
}, [readExistingInstall, step]);
useEffect(() => {
if (step !== 'options') return;
const refreshIfVisible = () => {
if (document.visibilityState === 'visible') {
void refreshExistingInstall();
}
};
const refreshOnFocus = () => {
void refreshExistingInstall();
};
window.addEventListener('focus', refreshOnFocus);
document.addEventListener('visibilitychange', refreshIfVisible);
return () => {
window.removeEventListener('focus', refreshOnFocus);
document.removeEventListener('visibilitychange', refreshIfVisible);
};
}, [refreshExistingInstall, step]);
const goTo = useCallback((s: InstallStep) => setStep(s), []);
const next = useCallback(() => {
const idx = STEPS.indexOf(step);
if (idx < STEPS.length - 1) setStep(STEPS[idx + 1]);
}, [step]);
const back = useCallback(() => {
const idx = STEPS.indexOf(step);
if (idx > 0) setStep(STEPS[idx - 1]);
}, [step]);
const refreshDiskSpace = useCallback(async (path: string) => {
try {
const info = await invoke<DiskSpaceInfo>('get_disk_space', { path });
setDiskSpace(info);
} catch (err) {
console.warn('Failed to get disk space:', err);
}
}, []);
const launchRegisteredUninstaller = useCallback(async () => {
setError(null);
const latestInstall = await refreshExistingInstall();
if (!latestInstall.detected) {
return;
}
const cmd = latestInstall.uninstallString?.trim();
if (!cmd) {
setError('No uninstall command is registered for this installation.');
return;
}
try {
await invoke('launch_registered_uninstaller', {
uninstallCommand: cmd,
installPath: latestInstall.installLocation ?? null,
});
window.setTimeout(() => {
void refreshExistingInstall();
}, 1500);
window.setTimeout(() => {
void refreshExistingInstall();
}, 5000);
} catch (err: unknown) {
setError(typeof err === 'string' ? err : (err as Error)?.message || 'Failed to start uninstaller');
}
}, [refreshExistingInstall]);
const install = useCallback(async () => {
setError(null);
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();
await new Promise<void>((resolve) => {
const timer = window.setInterval(() => {
const elapsed = Date.now() - startedAt;
const ratio = Math.min(elapsed / durationMs, 1);
const percent = Math.round(ratio * 100);
const mockStep =
percent < 20 ? 'prepare' :
percent < 50 ? 'extract' :
percent < 75 ? 'config' :
percent < 100 ? 'complete' :
'complete';
setProgress({ step: mockStep, percent, message: '' });
if (ratio >= 1) {
window.clearInterval(timer);
resolve();
}
}, 100);
});
setIsInstalling(false);
setInstallationCompleted(true);
setCanConfirmProgress(true);
return;
}
setIsInstalling(true);
setInstallationCompleted(false);
setCanConfirmProgress(false);
try {
const validated = await invoke<InstallPathValidation>('validate_install_path', {
path: options.installPath,
});
const effectiveOptions = {
...options,
installPath: validated.installPath,
};
if (validated.installPath !== options.installPath) {
setOptions((prev) => ({ ...prev, installPath: validated.installPath }));
}
setStep('progress');
setProgress({ step: 'prepare', percent: 0, message: '' });
await invoke('start_installation', { options: effectiveOptions });
setInstallationCompleted(true);
setStep('model');
try {
const info = await readExistingInstall();
setExistingInstall(info);
} catch {
}
} catch (err: any) {
const raw = typeof err === 'string' ? err : err?.message;
setError((raw && String(raw).trim()) ? String(raw) : i18n.t('errors.install.failed'));
} finally {
setIsInstalling(false);
}
}, [options, readExistingInstall]);
const confirmProgress = useCallback(() => {
if (!canConfirmProgress) return;
setCanConfirmProgress(false);
setStep('model');
}, [canConfirmProgress]);
const retryInstall = useCallback(async () => {
if (isInstalling) return;
await install();
}, [install, isInstalling]);
const backToOptions = useCallback(() => {
if (isInstalling) return;
setError(null);
setCanConfirmProgress(false);
setStep('options');
}, [isInstalling]);
const saveModelConfig = useCallback(async () => {
if (!options.modelConfig) return;
await invoke('set_model_config', { modelConfig: options.modelConfig });
}, [options.modelConfig]);
const testModelConnection = useCallback(async (modelConfig: ModelConfig) => {
return invoke<ConnectionTestResult>('test_model_config_connection', { modelConfig });
}, []);
const launchApp = useCallback(async () => {
await invoke('launch_application', { installPath: options.installPath });
}, [options.installPath]);
const closeInstaller = useCallback(() => {
invoke('close_installer');
}, []);
const startUninstall = useCallback(async () => {
if (isUninstalling) return;
setUninstallError(null);
setUninstallCompleted(false);
setIsUninstalling(true);
setUninstallProgress(0);
try {
await new Promise<void>((resolve) => {
const durationMs = 1800;
const startedAt = Date.now();
const timer = window.setInterval(() => {
const elapsed = Date.now() - startedAt;
const ratio = Math.min(elapsed / durationMs, 1);
const percent = Math.round(ratio * 85);
setUninstallProgress(percent);
if (ratio >= 1) {
window.clearInterval(timer);
resolve();
}
}, 80);
});
await invoke('uninstall', { installPath: options.installPath });
setUninstallProgress(100);
setUninstallCompleted(true);
window.setTimeout(() => {
closeInstaller();
}, 600);
} catch (err: any) {
setUninstallError(typeof err === 'string' ? err : err.message || 'Uninstall failed');
setUninstallProgress(0);
} finally {
setIsUninstalling(false);
}
}, [closeInstaller, isUninstalling, options.installPath]);
return {
step, goTo, next, back,
options, setOptions,
progress, isInstalling, installationCompleted, error, diskSpace,
existingInstall, launchRegisteredUninstaller,
install, canConfirmProgress, confirmProgress, retryInstall, backToOptions,
saveModelConfig, testModelConnection, launchApp, closeInstaller, refreshDiskSpace, clearInstallError,
isUninstallMode, isUninstalling, uninstallCompleted, uninstallError, uninstallProgress, startUninstall,
};
}