diff --git a/electron/main/index.ts b/electron/main/index.ts index 9d87c76ac..f9bfe0f04 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -4,7 +4,7 @@ import path from 'node:path' import os, { homedir } from 'node:os' import log from 'electron-log' import { update, registerUpdateIpcHandlers } from './update' -import { checkToolInstalled, installDependencies, killProcessOnPort, startBackend } from './init' +import { checkToolInstalled, killProcessOnPort, startBackend } from './init' import { WebViewManager } from './webview' import { FileReader } from './fileReader' import { ChildProcessWithoutNullStreams } from 'node:child_process' @@ -18,9 +18,9 @@ import kill from 'tree-kill'; import { zipFolder } from './utils/log' import axios from 'axios'; import FormData from 'form-data'; +import { checkAndInstallDepsOnUpdate, PromiseReturnType, getInstallationStatus } from './install-deps' const userData = app.getPath('userData'); -const versionFile = path.join(userData, 'version.txt'); // ==================== constants ==================== const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -50,69 +50,6 @@ findAvailablePort(browser_port).then(port => { app.commandLine.appendSwitch('remote-debugging-port', port + ''); }); -// Read last run version and install dependencies on update -async function checkAndInstallDepsOnUpdate(): Promise { - const currentVersion = app.getVersion(); - return new Promise(async (resolve, reject) => { - try { - log.info(' start check version', { currentVersion }); - - // Check if version file exists - const versionExists = fs.existsSync(versionFile); - let savedVersion = ''; - - if (versionExists) { - savedVersion = fs.readFileSync(versionFile, 'utf-8').trim(); - log.info(' read saved version', { savedVersion }); - } else { - log.info(' version file not exist, will create new file'); - } - - // If version file does not exist or version does not match, reinstall dependencies - if (!versionExists || savedVersion !== currentVersion) { - log.info(' version changed, prepare to reinstall uv dependencies...', { - currentVersion, - savedVersion: versionExists ? savedVersion : 'none', - reason: !versionExists ? 'version file not exist' : 'version not match' - }); - - // Notify frontend to update - if (win && !win.isDestroyed()) { - win.webContents.send('update-notification', { - type: 'version-update', - currentVersion, - previousVersion: versionExists ? savedVersion : 'none', - reason: !versionExists ? 'version file not exist' : 'version not match' - }); - } - - // Update version file - fs.writeFileSync(versionFile, currentVersion); - log.info(' version file updated', { currentVersion }); - - // Install dependencies - const result = await installDependencies(); - if (!result) { - log.error(' install dependencies failed'); - resolve(false); - return - } - resolve(true); - log.info(' install dependencies complete'); - return - } else { - log.info(' version not changed, skip install dependencies', { currentVersion }); - resolve(true); - return - } - } catch (error) { - log.error(' check version and install dependencies error:', error); - resolve(false); - return - } - }) -} - // ==================== app config ==================== process.env.APP_ROOT = MAIN_DIST; process.env.VITE_PUBLIC = VITE_PUBLIC; @@ -259,51 +196,6 @@ const checkManagerInstance = (manager: any, name: string) => { return manager; }; -export const handleDependencyInstallation = async () => { - try { - log.info(' start install dependencies...'); - - const isSuccess = await installDependencies(); - if (!isSuccess) { - log.error(' install dependencies failed'); - return { success: false, error: 'install dependencies failed' }; - } - - log.info(' install dependencies success, check tool installed status...'); - const isToolInstalled = await checkToolInstalled(); - log.info('isToolInstalled && !python_process', isToolInstalled && !python_process); - if (isToolInstalled && !python_process) { - log.info(' tool installed, start backend service...'); - python_process = await startBackend((port) => { - backendPort = port; - log.info(' backend service start success', { port }); - }); - - // Notify frontend to install success - if (win && !win.isDestroyed()) { - win.webContents.send('install-dependencies-complete', { success: true, code: 0 }); - } - - python_process?.on('exit', (code, signal) => { - log.info(' python process exit', { code, signal }); - }); - } else if (!isToolInstalled) { - log.warn(' tool not installed, skip backend start'); - } else { - log.info(' backend process already exist, skip start'); - } - - log.info(' install dependencies complete'); - return { success: true }; - } catch (error: any) { - log.error(' install dependencies error:', error); - if (win && !win.isDestroyed()) { - win.webContents.send('install-dependencies-complete', { success: false, code: 2 }); - } - return { success: false, error: error.message }; - } -}; - function registerIpcHandlers() { // ==================== basic info handler ==================== ipcMain.handle('get-browser-port', () => { @@ -934,13 +826,35 @@ function registerIpcHandlers() { }); // ==================== dependency install handler ==================== - ipcMain.handle('install-dependencies', handleDependencyInstallation); - ipcMain.handle('frontend-ready', handleDependencyInstallation); + ipcMain.handle('install-dependencies', async () => { + try { + if(win === null) throw new Error("Window is null"); + //Force installation even if versionFile exists + const isInstalled = await checkAndInstallDepsOnUpdate({win, forceInstall: true}); + return { success: true, isInstalled }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } + }); ipcMain.handle('check-tool-installed', async () => { try { const isInstalled = await checkToolInstalled(); - return { success: true, isInstalled }; + return { success: true, isInstalled: isInstalled.success }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } + }); + + ipcMain.handle('get-installation-status', async () => { + try { + const { isInstalling, hasLockFile } = await getInstallationStatus(); + return { + success: true, + isInstalling, + hasLockFile, + timestamp: Date.now() + }; } catch (error) { return { success: false, error: (error as Error).message }; } @@ -1006,12 +920,13 @@ async function createWindow() { update(win); // ==================== check tool installed ==================== - let res = await checkAndInstallDepsOnUpdate(); - if (!res) { - log.info('checkAndInstallDepsOnUpdate,install dependencies failed'); - win.webContents.send('install-dependencies-complete', { success: false, code: 2 }); + let res:PromiseReturnType = await checkAndInstallDepsOnUpdate({ win }); + if (!res.success) { + log.info("[DEPS INSTALL] Dependency Error: ", res.message); + win.webContents.send('install-dependencies-complete', { success: false, code: 2, error: res.message }); return; - } + } + log.info("[DEPS INSTALL] Dependency Success: ", res.message); await checkAndStartBackend(); } @@ -1069,27 +984,30 @@ const setupExternalLinkHandling = () => { // ==================== check and start backend ==================== const checkAndStartBackend = async () => { log.info('Checking and starting backend service...'); + try { + const isToolInstalled = await checkToolInstalled(); + if (isToolInstalled.success) { + log.info('Tool installed, starting backend service...'); - const isToolInstalled = await checkToolInstalled(); - if (isToolInstalled) { - log.info('Tool installed, starting backend service...'); + // Notify frontend installation success + if (win && !win.isDestroyed()) { + win.webContents.send('install-dependencies-complete', { success: true, code: 0 }); + } - // Notify frontend installation success - if (win && !win.isDestroyed()) { - win.webContents.send('install-dependencies-complete', { success: true, code: 0 }); + python_process = await startBackend((port) => { + backendPort = port; + log.info('Backend service started successfully', { port }); + }); + + python_process?.on('exit', (code, signal) => { + + log.info('Python process exited', { code, signal }); + }); + } else { + log.warn('Tool not installed, cannot start backend service'); } - - python_process = await startBackend((port) => { - backendPort = port; - log.info('Backend service started successfully', { port }); - }); - - python_process?.on('exit', (code, signal) => { - - log.info('Python process exited', { code, signal }); - }); - } else { - log.warn('Tool not installed, cannot start backend service'); + } catch (error) { + log.debug("Cannot Start Backend due to ", error) } }; diff --git a/electron/main/init.ts b/electron/main/init.ts index f34a00eef..709244035 100644 --- a/electron/main/init.ts +++ b/electron/main/init.ts @@ -6,82 +6,30 @@ import path from 'path' import * as net from "net"; import { ipcMain, BrowserWindow, app } from 'electron' import { promisify } from 'util' +import { detectInstallationLogs, PromiseReturnType } from "./install-deps"; const execAsync = promisify(exec); // helper function to get main window -function getMainWindow(): BrowserWindow | null { +export function getMainWindow(): BrowserWindow | null { const windows = BrowserWindow.getAllWindows(); return windows.length > 0 ? windows[0] : null; } export async function checkToolInstalled() { - return new Promise(async (resolve, reject) => { + return new Promise(async (resolve, reject) => { if (!(await isBinaryExists('uv'))) { - resolve(false) + resolve({success: false, message: "uv doesn't exist"}) + return } if (!(await isBinaryExists('bun'))) { - resolve(false) + resolve({success: false, message: "Bun doesn't exist"}) + return } - resolve(true) - }) - -} - -/** - * Check if command line tools are installed, install if not - */ -export async function installCommandTool() { - return new Promise(async (resolve, reject) => { - const ensureInstalled = async (toolName: 'uv' | 'bun', scriptName: string): Promise => { - if (await isBinaryExists(toolName)) { - return true; - } - - console.log(`start install ${toolName}`); - const isSuccess = await runInstallScript(scriptName); - const mainWindow = getMainWindow(); - if (mainWindow && !mainWindow.isDestroyed() && !isSuccess) { - mainWindow.webContents.send('install-dependencies-complete', { - success: false, - code: 2, - error: `${toolName} installation failed`, - }); - return false - } - - const installed = await isBinaryExists(toolName); - - - if (mainWindow && !mainWindow.isDestroyed()) { - if (installed) { - mainWindow.webContents.send('install-dependencies-log', { - type: 'stdout', - data: `${toolName} installed successfully`, - }); - } else { - mainWindow.webContents.send('install-dependencies-complete', { - success: false, - code: 2, - error: `${toolName} installation failed`, - }); - } - } - - return installed; - }; - - if (!(await ensureInstalled('uv', 'install-uv.js'))) { - return reject("uv install failed"); - } - if (!(await ensureInstalled('bun', 'install-bun.js'))) { - return reject("bun install failed"); - } - - return resolve(true); + resolve({success: true, message: "Tools exist already"}) }) } @@ -167,124 +115,6 @@ export async function installCommandTool() { // }) // }) // } -export async function installDependencies() { - return new Promise(async (resolve, reject) => { - console.log('start install dependencies') - - const mainWindow = getMainWindow(); - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('install-dependencies-start'); - } - - const isInstalCommandTool = await installCommandTool() - if (!isInstalCommandTool) { - resolve(false) - return - } - - const uv_path = await getBinaryPath('uv') - const backendPath = getBackendPath() - - if (!fs.existsSync(backendPath)) { - fs.mkdirSync(backendPath, { recursive: true }) - } - - const installingLockPath = path.join(backendPath, 'uv_installing.lock') - fs.writeFileSync(installingLockPath, '') - - const installedLockPath = path.join(backendPath, 'uv_installed.lock') - // const proxyArgs = ['--default-index', 'https://pypi.tuna.tsinghua.edu.cn/simple'] - const proxyArgs = ['--default-index', 'https://mirrors.aliyun.com/pypi/simple/'] - const runInstall = (extraArgs: string[]) => { - return new Promise((resolveInner, rejectInner) => { - try { - const node_process = spawn(uv_path, [ - 'sync', - '--no-dev', - '--cache-dir', getCachePath('uv_cache'), - ...extraArgs], { - cwd: backendPath, - env: { - ...process.env, - UV_TOOL_DIR: getCachePath('uv_tool'), - UV_PYTHON_INSTALL_DIR: getCachePath('uv_python'), - } - }) - console.log('start install dependencies', extraArgs) - node_process.stdout.on('data', (data) => { - - log.info(`Script output: ${data}`) - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('install-dependencies-log', { type: 'stdout', data: data.toString() }); - } - }) - - node_process.stderr.on('data', (data) => { - log.error(`Script error: ${data}`) - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('install-dependencies-log', { type: 'stderr', data: data.toString() }); - } - }) - - node_process.on('close', (code) => { - console.log('install dependencies end', code === 0) - resolveInner(code === 0) - }) - } catch (err) { - log.error('run install failed', err) - // Clean up uv_installing.lock file if installation fails - if (fs.existsSync(installingLockPath)) { - fs.unlinkSync(installingLockPath); - } - rejectInner(err) - } - - }) - } - - // try default install - const installSuccess = await runInstall([]) - - if (installSuccess) { - fs.unlinkSync(installingLockPath) - fs.writeFileSync(installedLockPath, '') - log.info('Script completed successfully') - console.log('end install dependencies') - spawn(uv_path, ['run', 'task', 'babel'], { cwd: backendPath }) - resolve(true) - return - } - - // try mirror install - const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone - let mirrorInstallSuccess = false - - if (timezone === 'Asia/Shanghai') { - mirrorInstallSuccess = await runInstall(proxyArgs) - } else { - mirrorInstallSuccess = await runInstall([]) - } - - - fs.existsSync(installingLockPath) && fs.unlinkSync(installingLockPath) - - if (mirrorInstallSuccess) { - fs.writeFileSync(installedLockPath, '') - log.info('Mirror script completed successfully') - console.log('end install dependencies (mirror)') - spawn(uv_path, ['run', 'task', 'babel'], { cwd: backendPath }) - resolve(true) - } else { - log.error('Both default and mirror install failed') - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('install-dependencies-complete', { success: false, error: 'Both default and mirror install failed' }); - } - resolve(false) - } - }) -} - - export async function startBackend(setPort?: (port: number) => void): Promise { console.log('start fastapi') @@ -330,6 +160,9 @@ export async function startBackend(setPort?: (port: number) => void): Promise { if (!data) return; const msg = data.toString().trimEnd(); + //Detect if uv sync is run + detectInstallationLogs(msg); + if (msg.toLowerCase().includes("error") || msg.toLowerCase().includes("traceback")) { log.error(`BACKEND: ${msg}`); } else if (msg.toLowerCase().includes("warn")) { @@ -343,6 +176,7 @@ export async function startBackend(setPort?: (port: number) => void): Promise { + //Implicitly runs uv sync const node_process = spawn( uv_path, ["run", "uvicorn", "main:api", "--port", port.toString(), "--loop", "asyncio"], diff --git a/electron/main/install-deps.ts b/electron/main/install-deps.ts new file mode 100644 index 000000000..1fbb4dde2 --- /dev/null +++ b/electron/main/install-deps.ts @@ -0,0 +1,424 @@ +import { app, BrowserWindow } from 'electron' +import path from 'node:path' +import log from 'electron-log' +import { getMainWindow } from './init' +import fs from 'node:fs' +import { getBackendPath, getBinaryPath, getCachePath, isBinaryExists, runInstallScript } from './utils/process' +import { spawn } from 'child_process' +import { safeMainWindowSend } from './utils/safeWebContentsSend' + +const userData = app.getPath('userData'); +const versionFile = path.join(userData, 'version.txt'); + +export type PromiseReturnType = { + message: string; + success: boolean; +} + +interface checkInstallProps { + win:BrowserWindow|null; + forceInstall?:boolean +} +// Read last run version and install dependencies on update +export const checkAndInstallDepsOnUpdate = async ({win, forceInstall=false}:checkInstallProps): +Promise => { + const currentVersion = app.getVersion(); + let savedVersion = ''; + const checkInstallOperations = { + getSavedVersion: ():boolean => { + // Check if version file exists + const versionExists = fs.existsSync(versionFile); + if (versionExists) { + log.info('[DEPS INSTALL] start check version', { currentVersion }); + savedVersion = fs.readFileSync(versionFile, 'utf-8').trim(); + log.info('[DEPS INSTALL] read saved version', { savedVersion }); + } else { + log.info('[DEPS INSTALL] version file not exist, will create new file'); + } + return versionExists; + }, + handleUpdateNotification: (versionExists:boolean) => { + if (win && !win.isDestroyed()) { + win.webContents.send('update-notification', { + type: 'version-update', + currentVersion, + previousVersion: versionExists ? savedVersion : 'none', + reason: !versionExists ? 'version file not exist' : 'version not match' + }); + } else { + log.warn('[DEPS INSTALL] Cannot send update notification - window not available'); + } + }, + createVersionFile: () => { + fs.writeFileSync(versionFile, currentVersion); + log.info('[DEPS INSTALL] version file updated', { currentVersion }); + } + } + + return new Promise(async (resolve, reject) => { + try { + const versionExists:boolean = checkInstallOperations.getSavedVersion(); + + // If version file does not exist or version does not match, reinstall dependencies + if (forceInstall || !versionExists || savedVersion !== currentVersion) { + log.info('[DEPS INSTALL] version changed, prepare to reinstall uv dependencies...', { + currentVersion, + savedVersion: versionExists ? savedVersion : 'none', + reason: !versionExists ? 'version file not exist' : 'version not match' + }); + + // Notify frontend to update + checkInstallOperations.handleUpdateNotification(versionExists); + + // Update version file + checkInstallOperations.createVersionFile(); + + // Install dependencies + const result = await installDependencies(); + if (!result.success) { + log.error(' install dependencies failed'); + resolve({ message: `Install dependencies failed, msg ${result.message}`, success: false }); + return + } + resolve({ message: "Dependencies installed successfully after update", success: true }); + log.info('[DEPS INSTALL] install dependencies complete'); + return + } else { + log.info('[DEPS INSTALL] version not changed, skip install dependencies', { currentVersion }); + resolve({ message: "Version not changed, skipped installation", success: true }); + return + } + } catch (error) { + log.error(' check version and install dependencies error:', error); + resolve({ message: `Error checking version: ${error}`, success: false }); + return + } + }) +} + +/** + * Check if command line tools are installed, install if not + */ +export async function installCommandTool(): Promise { + try { + const ensureInstalled = async (toolName: 'uv' | 'bun', scriptName: string): Promise => { + if (await isBinaryExists(toolName)) { + return { message: `${toolName} already installed`, success: true }; + } + + console.log(`start install ${toolName}`); + await runInstallScript(scriptName); + const installed = await isBinaryExists(toolName); + + if (installed) { + safeMainWindowSend('install-dependencies-log', { + type: 'stdout', + data: `${toolName} installed successfully`, + }); + } else { + safeMainWindowSend('install-dependencies-complete', { + success: false, + code: 2, + error: `${toolName} installation failed (script exit code 2)`, + }); + } + + return { + message: installed ? `${toolName} installed successfully` : `${toolName} installation failed`, + success: installed + }; + }; + + const uvResult = await ensureInstalled('uv', 'install-uv.js'); + if (!uvResult.success) { + return { message: uvResult.message, success: false }; + } + + const bunResult = await ensureInstalled('bun', 'install-bun.js'); + if (!bunResult.success) { + return { message: bunResult.message, success: false }; + } + + return { message: "Command tools installed successfully", success: true }; + } catch (error) { + return { message: `Command tool installation failed: ${error}`, success: false }; + } +} + +let uv_path:string; +const mainWindow = getMainWindow(); +const backendPath = getBackendPath(); +const installingLockPath = path.join(backendPath, 'uv_installing.lock') +const installedLockPath = path.join(backendPath, 'uv_installed.lock') +// const proxyArgs = ['--default-index', 'https://pypi.tuna.tsinghua.edu.cn/simple'] +const proxyArgs = ['--default-index', 'https://mirrors.aliyun.com/pypi/simple/'] + +/** + * Get current installation status by checking lock files + * @returns Object with installation status information + */ +export async function getInstallationStatus(): Promise<{ + isInstalling: boolean; + hasLockFile: boolean; + installedExists: boolean; +}> { + try { + const installingExists = fs.existsSync(installingLockPath); + const installedExists = fs.existsSync(installedLockPath); + + // If installing lock exists, installation is in progress + // If installed lock exists, installation completed previously + return { + isInstalling: installingExists, + hasLockFile: installingExists || installedExists, + installedExists: installedExists + }; + } catch (error) { + console.error('[getInstallationStatus] Error checking installation status:', error); + return { + isInstalling: false, + hasLockFile: false, + installedExists: false + }; + } +} + +class InstallLogs { + private node_process; + + constructor(extraArgs:string[]) { + console.log('start install dependencies', extraArgs) + this.node_process = spawn(uv_path, [ + 'sync', + '--no-dev', + '--cache-dir', getCachePath('uv_cache'), + ...extraArgs], { + cwd: backendPath, + env: { + ...process.env, + UV_TOOL_DIR: getCachePath('uv_tool'), + UV_PYTHON_INSTALL_DIR: getCachePath('uv_python'), + } + }) + } + + /**Display filtered logs based on severity */ + displayFilteredLogs(data:String) { + if (!data) return; + const msg = data.toString().trimEnd(); + //Detect if uv sync is run + detectInstallationLogs(msg); + if (msg.toLowerCase().includes("error") || msg.toLowerCase().includes("traceback")) { + log.error(`BACKEND: [DEPS INSTALL] ${msg}`); + safeMainWindowSend('install-dependencies-log', { type: 'stderr', data: data.toString() }); + } else { + log.info(`BACKEND: [DEPS INSTALL] ${msg}`); + safeMainWindowSend('install-dependencies-log', { type: 'stdout', data: data.toString() }); + } + } + + /**Handle stdout data */ + onStdout() { + this.node_process.stdout.on('data', (data:any) => { + this.displayFilteredLogs(data); + }) + } + + /**Handle stderr data */ + onStderr() { + this.node_process.stderr.on('data', (data:any) => { + this.displayFilteredLogs(data); + }) + } + + /**Handle process close event */ + onClose(resolveInner:(code: number | null) => void) { + this.node_process.on('close', resolveInner); + } + + /** + * Set installing Lock Path + * Creates uv_installing.lock file to indicate installation in progress + * Creates backend directory if not exists + */ + static setLockPath() { + if (!fs.existsSync(backendPath)) { + fs.mkdirSync(backendPath, { recursive: true }) + } + fs.writeFileSync(installingLockPath, '') + } + + /**Clean installing Lock Path */ + static cleanLockPath() { + if (fs.existsSync(installingLockPath)) { + fs.unlinkSync(installingLockPath); + } + } +} + +const runInstall = (extraArgs: string[]) => { + const installLogs = new InstallLogs(extraArgs); + return new Promise((resolveInner, rejectInner) => { + try { + installLogs.onStdout(); + installLogs.onStderr(); + installLogs.onClose((code) => { + console.log('install dependencies end', code === 0) + InstallLogs.cleanLockPath() + resolveInner({ + message: code === 0 ? "Installation completed successfully" : `Installation failed with code ${code}`, + success: code === 0 + }) + }) + } catch (err) { + log.error('run install failed', err) + // Clean up uv_installing.lock file if installation fails + InstallLogs.cleanLockPath(); + rejectInner({ message: `Installation failed: ${err}`, success: false }) + } + }) +} + +export async function installDependencies(): Promise { + uv_path = await getBinaryPath('uv'); + const handleInstallOperations = { + spawnBabel: (message:"mirror"|"main"="main") => { + fs.writeFileSync(installedLockPath, '') + log.info('[DEPS INSTALL] Script completed successfully') + console.log(`Install Dependencies completed ${message}`) + spawn(uv_path, ['run', 'task', 'babel'], { cwd: backendPath }) + }, + notifyInstallDependenciesPage: ():boolean => { + const success = safeMainWindowSend('install-dependencies-start'); + if (!success) { + log.warn('[DEPS INSTALL] Main window not available, continuing installation without UI updates'); + } + return success; + } + } + + return new Promise(async (resolve, reject) => { + console.log('start install dependencies') + const mainWindowAvailable = handleInstallOperations.notifyInstallDependenciesPage(); + + if (!mainWindowAvailable) { + log.info('[DEPS INSTALL] Proceeding with installation without UI notifications'); + } + + const isInstalCommandTool = await installCommandTool() + if (!isInstalCommandTool.success) { + resolve({ message: "Command tool installation failed", success: false }) + return + } + + // Set Installing Lock Files + InstallLogs.setLockPath(); + + // try default install + const installSuccess = await runInstall([]) + if (installSuccess.success) { + handleInstallOperations.spawnBabel() + resolve({ message: "Dependencies installed successfully", success: true }) + return + } + + // try mirror install + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone + let mirrorInstallSuccess: PromiseReturnType = { message: "", success: false } + mirrorInstallSuccess = (timezone === 'Asia/Shanghai')? await runInstall(proxyArgs) :await runInstall([]) + + if (mirrorInstallSuccess.success) { + handleInstallOperations.spawnBabel("mirror") + resolve({ message: "Dependencies installed successfully with mirror", success: true }) + } else { + log.error('Both default and mirror install failed') + safeMainWindowSend('install-dependencies-complete', { + success: false, + error: 'Both default and mirror install failed' + }); + resolve({ message: "Both default and mirror install failed", success: false }) + } + }) +} + +let dependencyInstallationDetected = false; +let installationNotificationSent = false; +export function detectInstallationLogs(msg:string) { + // Check for UV dependency installation patterns + const installPatterns = [ + "Resolved", // UV resolving dependencies + "Downloaded", // UV downloading packages + "Installing", // UV installing packages + "Built", // UV building packages + "Prepared", // UV preparing virtual environment + "Syncing", // UV sync process + "Creating virtualenv", // Virtual environment creation + "Updating", // UV updating packages + "× No solution found when resolving dependencies", // Dependency resolution issues + "Audited" // UV auditing dependencies + ]; + + // Detect if UV is installing dependencies + if (!dependencyInstallationDetected && installPatterns.some(pattern => + msg.includes(pattern) && !msg.includes("Uvicorn running on") + )) { + dependencyInstallationDetected = true; + log.info('[BACKEND STARTUP] UV dependency installation detected during uvicorn startup'); + + // Create installing lock file to maintain consistency with install-deps.ts + InstallLogs.setLockPath(); + log.info('[BACKEND STARTUP] Created uv_installing.lock file'); + + // Notify frontend that installation has started (only once) + if (!installationNotificationSent) { + installationNotificationSent = true; + const notificationSent = safeMainWindowSend('install-dependencies-start'); + if (notificationSent) { + log.info('[BACKEND STARTUP] Notified frontend of dependency installation start'); + } else { + log.warn('[BACKEND STARTUP] Failed to notify frontend of dependency installation start'); + } + } + } + + // Send installation logs to frontend if installation was detected + if (dependencyInstallationDetected && !msg.includes("Uvicorn running on")) { + safeMainWindowSend('install-dependencies-log', { + type: msg.toLowerCase().includes("error") || msg.toLowerCase().includes("traceback") ? 'stderr' : 'stdout', + data: msg + }); + } + + // Check if installation is complete (uvicorn starts successfully) + if (dependencyInstallationDetected && msg.includes("Uvicorn running on")) { + log.info('[BACKEND STARTUP] UV dependency installation completed, uvicorn started successfully'); + + // Clean up installing lock and create installed lock + InstallLogs.cleanLockPath(); + fs.writeFileSync(installedLockPath, ''); + log.info('[BACKEND STARTUP] Created uv_installed.lock file'); + + safeMainWindowSend('install-dependencies-complete', { + success: true, + message: 'Dependencies installed successfully during backend startup' + }); + } + + // Handle installation failures + if (dependencyInstallationDetected && ( + msg.toLowerCase().includes("failed to resolve dependencies") || + msg.toLowerCase().includes("installation failed") || + msg.includes("× No solution found when resolving dependencies") + )) { + log.error('[BACKEND STARTUP] UV dependency installation failed'); + + // Clean up installing lock file + InstallLogs.cleanLockPath(); + log.info('[BACKEND STARTUP] Cleaned up uv_installing.lock file after failure'); + + safeMainWindowSend('install-dependencies-complete', { + success: false, + error: 'Dependency installation failed during backend startup' + }); + } +} \ No newline at end of file diff --git a/electron/main/utils/safeWebContentsSend.ts b/electron/main/utils/safeWebContentsSend.ts new file mode 100644 index 000000000..0e72da65d --- /dev/null +++ b/electron/main/utils/safeWebContentsSend.ts @@ -0,0 +1,20 @@ +import log from 'electron-log' +import { getMainWindow } from "../init"; + +/** + * Safely send message to main window if it exists and is not destroyed + * @param channel - The IPC channel to send message to + * @param data - The data to send + */ +function safeMainWindowSend(channel: string, data?: any) { + const mainWindow = getMainWindow(); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send(channel, data); + return true; + } else { + log.warn(`[WEBCONTENTS SEND] Cannot send message to main window: ${channel}`, data); + return false; + } +} + +export {safeMainWindowSend} \ No newline at end of file diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 0c89d17d2..d1c5e9e85 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -62,9 +62,9 @@ contextBridge.exposeInMainWorld('electronAPI', { deleteFolder: (email: string) => ipcRenderer.invoke('delete-folder', email), getMcpConfigPath: (email: string) => ipcRenderer.invoke('get-mcp-config-path', email), // install dependencies related API - installDependencies: () => ipcRenderer.invoke('install-dependencies'), - frontendReady: () => ipcRenderer.invoke('frontend-ready'), + checkAndInstallDepsOnUpdate: () => ipcRenderer.invoke('install-dependencies'), checkInstallBrowser: () => ipcRenderer.invoke('check-install-browser'), + getInstallationStatus: () => ipcRenderer.invoke('get-installation-status'), onInstallDependenciesStart: (callback: () => void) => { ipcRenderer.on('install-dependencies-start', callback); }, diff --git a/src/components/InstallStep/InstallDependencies.tsx b/src/components/InstallStep/InstallDependencies.tsx index 24b8212cf..5b913ba46 100644 --- a/src/components/InstallStep/InstallDependencies.tsx +++ b/src/components/InstallStep/InstallDependencies.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from "react"; +import React from "react"; import { useAuthStore } from "@/store/authStore"; import { ProgressInstall } from "@/components/ui/progress-install"; import { FileDown, RefreshCcw } from "lucide-react"; @@ -14,158 +14,21 @@ import { DialogFooter, } from "@/components/ui/dialog"; import { useTranslation } from "react-i18next"; +import { useInstallationUI } from "@/store/installationStore"; +import { TooltipSimple } from "../ui/tooltip"; -interface InstallLog { - type: "stdout" | "stderr"; - data: string; - timestamp: Date; -} - -export const InstallDependencies: React.FC<{ - isInstalling: boolean; - setIsInstalling: (isInstalling: boolean) => void; -}> = ({ isInstalling, setIsInstalling }) => { - const { initState, setInitState } = useAuthStore(); - const {t} = useTranslation() - const [logs, setLogs] = useState([]); - const [status, setStatus] = useState< - "idle" | "installing" | "success" | "error" - >("idle"); - const [showInstallScreen, setShowInstallScreen] = useState(true); - const [progress, setProgress] = useState(20); - useEffect(() => { - // listen to install start event - window.electronAPI.onInstallDependenciesStart(() => { - setIsInstalling(true); - setStatus("installing"); - setShowInstallScreen(true); - setLogs([]); - console.log("start installing dependencies..."); - setProgress(20); - }); - - // listen to install log - window.electronAPI.onInstallDependenciesLog( - (data: { type: string; data: string }) => { - console.log("data", data); - const newLog: InstallLog = { - type: data?.type as "stdout" | "stderr", - data: data?.data, - timestamp: new Date(), - }; - setProgress((prev) => { - const progress = prev + 5; - if (progress >= 90) { - return 90; - } - return progress; - }); - console.log(`install log [${data?.type}]:`, data?.data); - setLogs((prev) => [...prev, newLog]); - } - ); - - // listen to install complete event - window.electronAPI.onInstallDependenciesComplete( - (data: { success: boolean; code?: number; error?: string }) => { - setIsInstalling(false); - if (data?.success) { - setStatus("success"); - console.log("dependencies installed successfully!"); - setProgress(100); - setInitState("done"); - } else { - setStatus("error"); - console.error("dependencies installation failed:", data?.code); - console.error("dependencies installation failed:", data?.error); - } - } - ); - - // after component mounted, notify main process frontend is ready - const notifyFrontendReady = async () => { - try { - // check if there is frontend-ready API - if (window.electronAPI.frontendReady) { - await window.electronAPI.frontendReady(); - } - } catch (error) { - console.log( - "frontend ready notification failed, maybe manual install mode:", - error - ); - } - }; - - // delay notification, ensure component is fully initialized - setTimeout(notifyFrontendReady, 500); - - // clean up listeners - return () => { - window.electronAPI.removeAllListeners("install-dependencies-start"); - window.electronAPI.removeAllListeners("install-dependencies-log"); - window.electronAPI.removeAllListeners("install-dependencies-complete"); - }; - }, []); - - const handleInstall = async () => { - try { - setStatus("installing"); - setIsInstalling(true); - setLogs([]); - setShowInstallScreen(true); - - const result = await window.electronAPI.installDependencies(); - console.log("result", result); - if (!result.success) { - setStatus("error"); - setIsInstalling(false); - return; - } - setStatus("success"); - setProgress(100); - setIsInstalling(false); - setInitState("done"); - } catch (error) { - console.error("install start failed:", error); - setStatus("error"); - setIsInstalling(false); - } - }; - - const exportLog = async () => { - try { - const response = await window.electronAPI.exportLog(); - - if (!response.success) { - alert("Export cancelled:" + response.error); - return; - } - if (response.savedPath) { - window.location.href = - "https://github.com/eigent-ai/eigent/issues/new/choose"; - alert("log saved:" + response.savedPath); - } - } catch (e: any) { - alert("export error:" + e.message); - } - }; - - // if not show install interface, return null - if (initState === "done" && !isInstalling) { - return ( - - - - {t("layout.installation-failed")} - - - - - - - ); - } +export const InstallDependencies: React.FC = () => { + const { initState } = useAuthStore(); + const {t} = useTranslation(); + + const { + progress, + latestLog, + error, + isInstalling, + retryInstallation, + exportLog, + } = useInstallationUI(); return (
@@ -180,16 +43,18 @@ export const InstallDependencies: React.FC<{
{isInstalling ? "System Installing ..." : ""} - {logs.at(-1)?.data} + {latestLog?.data}
- +
@@ -214,7 +79,7 @@ export const InstallDependencies: React.FC<{ {t("layout.report-bug")} - diff --git a/src/components/InstallStep/InstallationErrorDialog/InstallationErrorDialog.tsx b/src/components/InstallStep/InstallationErrorDialog/InstallationErrorDialog.tsx new file mode 100644 index 000000000..bdfd2c1a0 --- /dev/null +++ b/src/components/InstallStep/InstallationErrorDialog/InstallationErrorDialog.tsx @@ -0,0 +1,50 @@ +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { error } from "electron-log"; +import { t } from "i18next"; +import React from "react"; + +interface InstallationErrorDialogProps { + error: string; + installationState: string; + latestLog: any; + retryInstallation: () => void; +} + +const InstallationErrorDialog = ({ + error, + installationState, + latestLog, + retryInstallation, +}:InstallationErrorDialogProps) => { + return ( + + + + {t("layout.installation-failed")} + +
+ { +
+ + Error: {error}
+ Log: {latestLog?.data} +
+
+ } +
+ + + +
+
+ ); +}; + +export default InstallationErrorDialog; diff --git a/src/components/Layout/index.tsx b/src/components/Layout/index.tsx index 525fcbc8b..e13379d19 100644 --- a/src/components/Layout/index.tsx +++ b/src/components/Layout/index.tsx @@ -8,13 +8,25 @@ import { AnimationJson } from "@/components/AnimationJson"; import animationData from "@/assets/animation/onboarding_success.json"; import CloseNoticeDialog from "../Dialog/CloseNotice"; import { useChatStore } from "@/store/chatStore"; +import { useInstallationUI } from "@/store/installationStore"; +import { useInstallationSetup } from "@/hooks/useInstallationSetup"; +import InstallationErrorDialog from "../InstallStep/InstallationErrorDialog/InstallationErrorDialog"; const Layout = () => { - const { initState, setInitState, isFirstLaunch, setIsFirstLaunch } = - useAuthStore(); - const [isInstalling, setIsInstalling] = useState(false); + const { initState, isFirstLaunch, setIsFirstLaunch } = useAuthStore(); const [noticeOpen, setNoticeOpen] = useState(false); const chatStore = useChatStore(); + const { + installationState, + latestLog, + error, + isInstalling, + shouldShowInstallScreen, + retryInstallation, + } = useInstallationUI(); + // Setup installation IPC listeners and state synchronization + useInstallationSetup(); + useEffect(() => { const handleBeforeClose = () => { const currentStatus = chatStore.tasks[chatStore.activeTaskId as string]?.status; @@ -32,43 +44,42 @@ const Layout = () => { }; }, [chatStore.tasks, chatStore.activeTaskId]); - useEffect(() => { - const checkToolInstalled = async () => { - // in render process - const result = await window.ipcRenderer.invoke("check-tool-installed"); - if (result.success) { - if (initState === "done" && !result.isInstalled) { - setInitState("carousel"); - } - console.log("tool is installed:"); - } else { - console.error("check failed:", result.error); - } - }; - checkToolInstalled(); - }, []); + // Determine what to show based on states + const shouldShowOnboarding = initState === "done" && isFirstLaunch && !isInstalling; + const shouldShowMainContent = !shouldShowInstallScreen; return (
- {initState === "done" && isFirstLaunch && !isInstalling && ( + {/* Onboarding animation */} + {shouldShowOnboarding && ( { - setIsFirstLaunch(false); - }} + onComplete={() => setIsFirstLaunch(false)} animationData={animationData} /> )} - {(initState !== "done" || isInstalling) && ( - + + {/* Installation screen */} + {shouldShowInstallScreen && } + + {/* Main app content */} + {shouldShowMainContent && ( + <> + + + )} - - + + {(error != "" && error !=undefined) && + + } + { + const { initState, setInitState } = useAuthStore(); + + // Extract only the functions we need to avoid dependency issues + const startInstallation = useInstallationStore(state => state.startInstallation); + const addLog = useInstallationStore(state => state.addLog); + const setSuccess = useInstallationStore(state => state.setSuccess); + const setError = useInstallationStore(state => state.setError); + + // Check tool installation status on mount + useEffect(() => { + const checkToolInstalled = async () => { + try { + console.log('[useInstallationSetup] Checking tool installation status...'); + const result = await window.ipcRenderer.invoke("check-tool-installed"); + + if (result.success && initState === "done" && !result.isInstalled) { + console.log('[useInstallationSetup] Tool not installed, setting initState to carousel'); + setInitState("carousel"); + } + } catch (error) { + console.error("[useInstallationSetup] Tool installation check failed:", error); + } + }; + + const checkBackendStatus = async() => { + // Also check if installation is currently in progress + const installationStatus = await window.electronAPI.getInstallationStatus(); + console.log('[useInstallationSetup] Installation status check:', installationStatus); + + if (installationStatus.success && installationStatus.isInstalling) { + console.log('[useInstallationSetup] Installation in progress, starting frontend state'); + startInstallation(); + } + } + + checkToolInstalled(); + checkBackendStatus(); + }, [initState, setInitState, startInstallation]); + + // Setup Electron IPC listeners (only once) + useEffect(() => { + // Electron IPC event handlers + const handleInstallStart = () => { + startInstallation(); + }; + + const handleInstallLog = (data: { type: string; data: string }) => { + addLog({ + type: data.type as 'stdout' | 'stderr', + data: data.data, + timestamp: new Date(), + }); + }; + + const handleInstallComplete = (data: { success: boolean; code?: number; error?: string }) => { + console.log('[useInstallationSetup] Install complete event received:', data); + + if (data.success) { + setSuccess(); + setInitState('done'); + } else { + setError(data.error || 'Installation failed'); + } + }; + + // Register Electron IPC listeners + window.electronAPI.onInstallDependenciesStart(handleInstallStart); + window.electronAPI.onInstallDependenciesLog(handleInstallLog); + window.electronAPI.onInstallDependenciesComplete(handleInstallComplete); + + console.log('[useInstallationSetup] Installation listeners registered'); + + // Cleanup listeners on unmount + return () => { + window.electronAPI.removeAllListeners('install-dependencies-start'); + window.electronAPI.removeAllListeners('install-dependencies-log'); + window.electronAPI.removeAllListeners('install-dependencies-complete'); + }; + }, [startInstallation, addLog, setSuccess, setError, setInitState]); +}; \ No newline at end of file diff --git a/src/store/installationStore.ts b/src/store/installationStore.ts new file mode 100644 index 000000000..374d88965 --- /dev/null +++ b/src/store/installationStore.ts @@ -0,0 +1,216 @@ +import { create } from 'zustand'; +import { subscribeWithSelector } from 'zustand/middleware'; + +// Define all possible installation states +export type InstallationState = + | 'idle' + | 'checking-permissions' + | 'showing-carousel' + | 'installing' + | 'error' + | 'completed'; + +// Installation log entry +export interface InstallationLog { + type: 'stdout' | 'stderr'; + data: string; + timestamp: Date; +} + +// Installation store state +interface InstallationStoreState { + // Core state + state: InstallationState; + progress: number; + logs: InstallationLog[]; + error?: string; + isVisible: boolean; + + // Actions + startInstallation: () => void; + addLog: (log: InstallationLog) => void; + setSuccess: () => void; + setError: (error: string) => void; + retryInstallation: () => void; + completeSetup: () => void; + updateProgress: (progress: number) => void; + setVisible: (visible: boolean) => void; + reset: () => void; + + // Async actions + performInstallation: () => Promise; + exportLog: () => Promise; +} + +// Initial state +const initialState = { + state: 'idle' as InstallationState, + progress: 20, + logs: [] as InstallationLog[], + error: undefined, + isVisible: false, +}; + +// Create the installation store +export const useInstallationStore = create()( + subscribeWithSelector( + (set, get) => ({ + // Initial state + ...initialState, + + // Basic actions + startInstallation: () => + set({ + state: 'installing', + progress: 20, + logs: [], + error: undefined, + isVisible: true, + }), + + addLog: (log: InstallationLog) => + set((state) => { + const newProgress = Math.min(state.progress + 5, 90); + return { + logs: [...state.logs, log], + progress: newProgress, + }; + }), + + setSuccess: () => + set({ + state: 'completed', + progress: 100, + }), + + setError: (error: string) => + set((state) => ({ + state: 'error', + error, + logs: [ + ...state.logs, + { + type: 'stderr', + data: error, + timestamp: new Date(), + }, + ], + })), + + retryInstallation: () => { + set({ + ...initialState, + isVisible: true, + state: 'installing', + }); + get().performInstallation(); + }, + + completeSetup: () => + set({ + state: 'completed', + isVisible: false, + }), + + updateProgress: (progress: number) => + set({ progress }), + + setVisible: (visible: boolean) => + set({ isVisible: visible }), + + reset: () => + set(initialState), + + // Async actions + performInstallation: async () => { + const { startInstallation, setSuccess, setError } = get(); + + try { + startInstallation(); + const result = await window.electronAPI.checkAndInstallDepsOnUpdate(); + + if (result.success) { + setSuccess(); + // Update auth store + const { useAuthStore } = await import('./authStore'); + useAuthStore.getState().setInitState('done'); + } else { + setError('Installation failed'); + } + } catch (error) { + setError(error instanceof Error ? error.message : 'Unknown error'); + } + }, + + exportLog: async () => { + try { + const response = await window.electronAPI.exportLog(); + + if (!response.success) { + alert('Export cancelled: ' + response.error); + return; + } + + if (response.savedPath) { + window.location.href = 'https://github.com/eigent-ai/eigent/issues/new/choose'; + alert('Log saved: ' + response.savedPath); + } + } catch (e: any) { + alert('Export error: ' + e.message); + } + }, + }) + ) +); + +// Computed selectors +export const useLatestLog = () => useInstallationStore(state => + state.logs[state.logs.length - 1] +); + +export const useInstallationActions = () => useInstallationStore(state => ({ + startInstallation: state.startInstallation, + retryInstallation: state.retryInstallation, + completeSetup: state.completeSetup, + performInstallation: state.performInstallation, + exportLog: state.exportLog, +})); + +// Combined hook for components that need multiple pieces of state +export const useInstallationStatus = () => { + const state = useInstallationStore(state => state.state); + const isVisible = useInstallationStore(state => state.isVisible); + + return { + isInstalling: state === 'installing', + installationState: state, + shouldShowInstallScreen: isVisible && state !== 'completed', + isInstallationComplete: state === 'completed', + canRetry: state === 'error', + }; +}; + +// Hook for the main installation UI component +export const useInstallationUI = () => { + const state = useInstallationStore(state => state.state); + const progress = useInstallationStore(state => state.progress); + const logs = useInstallationStore(state => state.logs); + const error = useInstallationStore(state => state.error); + const isVisible = useInstallationStore(state => state.isVisible); + const performInstallation = useInstallationStore(state => state.performInstallation); + const retryInstallation = useInstallationStore(state => state.retryInstallation); + const exportLog = useInstallationStore(state => state.exportLog); + + return { + installationState: state, + progress, + latestLog: logs[logs.length - 1], + error, + isInstalling: state === 'installing', + shouldShowInstallScreen: isVisible && state !== 'completed', + canRetry: state === 'error', + performInstallation, + retryInstallation, + exportLog, + }; +}; \ No newline at end of file diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index e1319bac5..470529c9e 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -45,9 +45,16 @@ interface ElectronAPI { envRemove: (email: string, key: string) => Promise; getEnvPath: (email: string) => Promise; executeCommand: (command: string,email:string) => Promise<{ success: boolean; stdout?: string; stderr?: string; error?: string }>; - installDependencies: () => Promise<{ success: boolean; error?: string }>; - frontendReady: () => Promise<{ success: boolean; error?: string }>; + checkAndInstallDepsOnUpdate: () => Promise<{ success: boolean; error?: string }>; checkInstallBrowser: () => Promise<{ data:any[] }>; + getInstallationStatus: () => Promise<{ + success: boolean; + isInstalling?: boolean; + hasLockFile?: boolean; + installedExists?: boolean; + timestamp?: number; + error?: string + }>; onInstallDependenciesStart: (callback: () => void) => void; onInstallDependenciesLog: (callback: (data: { type: string; data: string }) => void) => void; onInstallDependenciesComplete: (callback: (data: { success: boolean; code?: number; error?: string }) => void) => void;