import {app, BrowserWindow, ipcMain, Menu, screen, dialog} from 'electron'; import {existsSync, createWriteStream, promises as fs, mkdirSync, copyFileSync, readdirSync, writeFileSync} from 'fs'; import {join, dirname, basename} from 'path'; import {spawn, ChildProcess} from 'child_process'; import * as https from 'https'; import * as http from 'http'; import { createTray, destroyTray } from './tray'; const isDev = process.env.NODE_ENV === 'development'; let springProcess: ChildProcess | null = null; let mainWindow: BrowserWindow | null = null; let splashWindow: BrowserWindow | null = null; let appOpened = false; let downloadProgress = {percentage: 0, current: '0 MB', total: '0 MB'}; let isDownloading = false; let downloadedFilePath: string | null = null; let downloadedAsarPath: string | null = null; let downloadedJarPath: string | null = null; let isQuitting = false; let currentDownloadAbortController: AbortController | null = null; function openAppIfNotOpened() { if (appOpened) return; appOpened = true; if (mainWindow && !mainWindow.isDestroyed()) { isDev ? mainWindow.loadURL(`http://localhost:${process.argv[2] || 8083}`) : mainWindow.loadFile(join(__dirname, '../renderer/index.html')); mainWindow.webContents.once('did-finish-load', () => { setTimeout(() => { if (mainWindow && !mainWindow.isDestroyed()) { const config = loadConfig(); const shouldMinimize = config.launchMinimized || false; if (!shouldMinimize) { mainWindow.show(); mainWindow.focus(); } if (isDev) mainWindow.webContents.openDevTools(); } if (splashWindow && !splashWindow.isDestroyed()) { splashWindow.close(); splashWindow = null; } }, 500); }); } } // 通用资源路径获取函数 function getResourcePath(devPath: string, prodPath: string, fallbackPath?: string): string { if (isDev) return join(__dirname, devPath); const bundledPath = join(process.resourcesPath, 'app.asar.unpacked', prodPath); if (existsSync(bundledPath)) return bundledPath; return fallbackPath ? join(__dirname, fallbackPath) : join(__dirname, prodPath); } function getJavaExecutablePath(): string { if (isDev) return 'java'; const javaPath = getResourcePath('', 'public/jre/bin/java.exe'); return existsSync(javaPath) ? javaPath : 'java'; } function findJarFile(directory: string): string { if (!existsSync(directory)) return ''; const jarFile = readdirSync(directory).find((f: string) => f.startsWith('erp_client_sb-') && f.endsWith('.jar')); return jarFile ? join(directory, jarFile) : ''; } function getJarFilePath(): string { if (isDev) return findJarFile(join(__dirname, '../../public')); return findJarFile(process.resourcesPath); } const getSplashPath = () => getResourcePath('../../public/splash.html', 'public/splash.html'); const getIconPath = () => getResourcePath('../../public/icon/icon.png', 'public/icon/icon.png', '../renderer/icon/icon.png'); const getLogbackConfigPath = () => getResourcePath('../../public/config/logback.xml', 'public/config/logback.xml'); function getDataDirectoryPath(): string { const dataDir = join(app.getPath('userData'), 'data'); if (!existsSync(dataDir)) mkdirSync(dataDir, {recursive: true}); return dataDir; } function getUpdateDirectoryPath(): string { const updateDir = join(app.getPath('userData'), 'updates'); if (!existsSync(updateDir)) mkdirSync(updateDir, {recursive: true}); return updateDir; } function getLogDirectoryPath(): string { const logDir = join(app.getPath('userData'), 'logs'); if (!existsSync(logDir)) mkdirSync(logDir, {recursive: true}); return logDir; } interface AppConfig { closeAction?: 'quit' | 'minimize' | 'tray'; autoLaunch?: boolean; launchMinimized?: boolean; } function getConfigPath(): string { return join(app.getPath('userData'), 'config.json'); } function loadConfig(): AppConfig { try { const configPath = getConfigPath(); if (existsSync(configPath)) { return JSON.parse(require('fs').readFileSync(configPath, 'utf-8')); } } catch (error) { console.error('加载配置失败:', error); } return {}; } function saveConfig(config: AppConfig) { try { const configPath = getConfigPath(); require('fs').writeFileSync(configPath, JSON.stringify(config, null, 2)); } catch (error) { console.error('保存配置失败:', error); } } function migrateDataFromPublic(): void { if (!isDev) return; const oldDataPath = join(__dirname, '../../public/data'); if (!existsSync(oldDataPath)) return; const newDataPath = getDataDirectoryPath(); try { readdirSync(oldDataPath).forEach(file => { const destFile = join(newDataPath, file); if (!existsSync(destFile)) { copyFileSync(join(oldDataPath, file), destFile); } }); } catch (error) { console.log('数据迁移失败,使用默认配置'); } } function startSpringBoot() { migrateDataFromPublic(); const jarPath = getJarFilePath(); const javaPath = getJavaExecutablePath(); const dataDir = getDataDirectoryPath(); const logDir = getLogDirectoryPath(); const logbackConfigPath = getLogbackConfigPath(); if (!existsSync(jarPath)) { dialog.showErrorBox('启动失败', `JAR 文件不存在:\n${jarPath}`); app.quit(); return; } try { const springArgs = [ '-jar', jarPath, `--spring.datasource.url=jdbc:sqlite:${dataDir}/erp-cache.db`, `--server.port=8081`, `--logging.config=file:${logbackConfigPath}`, `--logging.file.path=${logDir}` ]; springProcess = spawn(javaPath, springArgs, { cwd: dataDir, detached: false, stdio: ['ignore', 'pipe', 'pipe'] }); let startupCompleted = false; springProcess.stdout?.on('data', (data) => { console.log('[Spring Boot]', data.toString().trim()); }); springProcess.stderr?.on('data', (data) => { console.log('[Spring Boot]', data.toString().trim()); }); springProcess.on('close', (code) => { mainWindow ? mainWindow.close() : app.quit(); }); springProcess.on('error', (error) => { let errorMessage = '启动 Java 应用失败'; if (error.message.includes('ENOENT')) { errorMessage = '找不到 Java 运行环境\n请确保应用包含 JRE 或系统已安装 Java'; } dialog.showErrorBox('启动失败', errorMessage); app.quit(); }); const checkHealth = () => { if (startupCompleted) return; http.get('http://127.0.0.1:8081', (res) => { if (!startupCompleted) { startupCompleted = true; console.log('[Spring Boot] 服务已就绪'); openAppIfNotOpened(); } }).on('error', () => { setTimeout(checkHealth, 200); }); }; setTimeout(checkHealth, 1000); setTimeout(() => { if (!startupCompleted) { console.log('[Spring Boot] 启动超时,强制打开窗口'); openAppIfNotOpened(); } }, 15000); } catch (error) { dialog.showErrorBox('启动异常', `无法启动应用: ${error}`); app.quit(); } } startSpringBoot(); function stopSpringBoot() { if (!springProcess) return; try { if (process.platform === 'win32') { const pid = springProcess.pid; if (pid) { spawn('taskkill', ['/pid', String(pid), '/f', '/t']); } else { springProcess.kill(); } } else { springProcess.kill('SIGTERM'); } } catch (e) { console.error('Failed to stop Spring Boot:', e); } finally { springProcess = null; } } function createWindow() { mainWindow = new BrowserWindow({ width: 1280, height: 800, show: false, // autoHideMenuBar: true, icon: getIconPath(), backgroundColor: '#f5f5f5', webPreferences: { preload: join(__dirname, 'preload.js'), nodeIntegration: false, contextIsolation: true, } }); Menu.setApplicationMenu(null); mainWindow.setMenuBarVisibility(false); // 阻止默认的文件拖拽导航行为,让渲染进程的 JavaScript 处理拖拽上传 mainWindow.webContents.on('will-navigate', (event, url) => { // 允许开发模式下的热重载导航 if (isDev && url.startsWith('http://localhost')) return; // 阻止所有其他导航(包括拖拽文件) event.preventDefault(); }); // 同样阻止新窗口的文件拖拽 mainWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' })); // 拦截关闭事件 mainWindow.on('close', (event) => { if (isQuitting) return; const config = loadConfig(); const closeAction = config.closeAction || 'quit'; if (closeAction === 'quit') { isQuitting = true; app.quit(); } else if (closeAction === 'tray' || closeAction === 'minimize') { event.preventDefault(); mainWindow?.hide(); } }); // 监听窗口关闭事件,确保正确清理引用 mainWindow.on('closed', () => { mainWindow = null; }); mainWindow.webContents.once('did-finish-load', () => { setTimeout(() => checkPendingUpdate(), 500); }); } // 单实例锁定 const gotTheLock = app.requestSingleInstanceLock(); if (!gotTheLock) { app.quit(); } else { app.on('second-instance', () => { if (mainWindow) { if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.show(); mainWindow.focus(); } }); } app.whenReady().then(() => { // 应用开机自启动配置 const config = loadConfig(); const shouldMinimize = config.launchMinimized || false; if (config.autoLaunch !== undefined) { app.setLoginItemSettings({ openAtLogin: config.autoLaunch, openAsHidden: shouldMinimize }); } createWindow(); createTray(mainWindow); // 只有在不需要最小化启动时才显示 splash 窗口 if (!shouldMinimize) { splashWindow = new BrowserWindow({ width: 1200, height: 675, frame: false, transparent: false, resizable: false, alwaysOnTop: false, show: true, center: true, icon: getIconPath(), webPreferences: { nodeIntegration: false, contextIsolation: true, } }); // 监听启动窗口关闭事件 splashWindow.on('closed', () => { splashWindow = null; }); const splashPath = getSplashPath(); if (existsSync(splashPath)) { splashWindow.loadFile(splashPath); } } // setTimeout(() => { // openAppIfNotOpened(); // }, 2000); app.on('activate', () => { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.show(); } else if (BrowserWindow.getAllWindows().length === 0) { createWindow(); createTray(mainWindow); } }); }); app.on('window-all-closed', () => { // 允许在后台运行,不自动退出 if (process.platform !== 'darwin' && isQuitting) { stopSpringBoot(); app.quit(); } }); app.on('before-quit', () => { isQuitting = true; stopSpringBoot(); destroyTray(); }); ipcMain.on('message', (event, message) => { console.log(message); }); ipcMain.handle('get-jar-version', () => { const jarPath = getJarFilePath(); const match = jarPath ? basename(jarPath).match(/erp_client_sb-(\d+\.\d+\.\d+)\.jar/) : null; return match?.[1] || ''; }); function checkPendingUpdate() { try { const updateDir = getUpdateDirectoryPath(); const asarUpdatePath = join(updateDir, 'app.asar.update'); const jarUpdatePath = readdirSync(updateDir).find(f => f.startsWith('erp_client_sb-') && f.endsWith('.jar.update')); if (!existsSync(asarUpdatePath) && !jarUpdatePath) return; const appDir = dirname(process.execPath); const helperPath = join(appDir, 'update-helper.bat'); if (!existsSync(helperPath)) return; const appAsarPath = join(process.resourcesPath, 'app.asar'); const vbsPath = join(app.getPath('temp'), 'update-silent.vbs'); const vbsContent = `Set WshShell = CreateObject("WScript.Shell") WshShell.Run Chr(34) & "${helperPath.replace(/\\/g, '\\\\')}" & Chr(34) & " " & Chr(34) & "${appAsarPath.replace(/\\/g, '\\\\')}" & Chr(34) & " " & Chr(34) & "${asarUpdatePath.replace(/\\/g, '\\\\')}" & Chr(34) & " " & Chr(34) & "${jarUpdatePath ? join(updateDir, jarUpdatePath).replace(/\\/g, '\\\\') : ''}" & Chr(34) & " " & Chr(34) & "${process.execPath.replace(/\\/g, '\\\\')}" & Chr(34) & " " & Chr(34) & "${updateDir.replace(/\\/g, '\\\\')}" & Chr(34), 0, False`; writeFileSync(vbsPath, vbsContent); spawn('wscript.exe', [vbsPath], {detached: true, stdio: 'ignore', shell: false}); setTimeout(() => app.quit(), 1000); } catch (error: unknown) { console.error('[UPDATE] 更新失败:', error); } } ipcMain.handle('download-update', async (event, downloadUrls: {asarUrl?: string, jarUrl?: string, latestVersion?: string}) => { if (isDownloading) return {success: false, error: '正在下载中'}; if (downloadedFilePath === 'completed') return {success: true, filePath: 'already-completed'}; isDownloading = true; currentDownloadAbortController = new AbortController(); let totalDownloaded = 0; let combinedTotalSize = 0; try { // 预先获取文件大小,计算总下载大小 let asarSize = 0; let jarSize = 0; if (downloadUrls.asarUrl) { asarSize = await getFileSize(downloadUrls.asarUrl); } if (downloadUrls.jarUrl) { jarSize = await getFileSize(downloadUrls.jarUrl); } combinedTotalSize = asarSize + jarSize; if (downloadUrls.asarUrl && !currentDownloadAbortController.signal.aborted) { const updateDir = getUpdateDirectoryPath(); const asarUpdatePath = join(updateDir, 'app.asar.update'); await downloadFile(downloadUrls.asarUrl, asarUpdatePath, (progress) => { const combinedProgress = { percentage: combinedTotalSize > 0 ? Math.round(((totalDownloaded + progress.downloaded) / combinedTotalSize) * 100) : 0, current: `${((totalDownloaded + progress.downloaded) / 1024 / 1024).toFixed(1)} MB`, total: combinedTotalSize > 0 ? `${(combinedTotalSize / 1024 / 1024).toFixed(1)} MB` : '0 MB' }; downloadProgress = combinedProgress; if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('download-progress', combinedProgress); } }); if (currentDownloadAbortController.signal.aborted) throw new Error('下载已取消'); downloadedAsarPath = asarUpdatePath; totalDownloaded = asarSize; } if (downloadUrls.jarUrl && !currentDownloadAbortController.signal.aborted) { const jarFileName = downloadUrls.latestVersion ? `erp_client_sb-${downloadUrls.latestVersion}.jar` : basename(downloadUrls.jarUrl); const updateDir = getUpdateDirectoryPath(); const jarUpdatePath = join(updateDir, jarFileName + '.update'); await downloadFile(downloadUrls.jarUrl, jarUpdatePath, (progress) => { const combinedProgress = { percentage: combinedTotalSize > 0 ? Math.round(((totalDownloaded + progress.downloaded) / combinedTotalSize) * 100) : 0, current: `${((totalDownloaded + progress.downloaded) / 1024 / 1024).toFixed(1)} MB`, total: combinedTotalSize > 0 ? `${(combinedTotalSize / 1024 / 1024).toFixed(1)} MB` : '0 MB' }; downloadProgress = combinedProgress; if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('download-progress', combinedProgress); } }); if (currentDownloadAbortController.signal.aborted) throw new Error('下载已取消'); downloadedJarPath = jarUpdatePath; } downloadedFilePath = 'completed'; isDownloading = false; currentDownloadAbortController = null; return {success: true, asarPath: downloadedAsarPath, jarPath: downloadedJarPath}; } catch (error: unknown) { await cleanupDownloadFiles(); isDownloading = false; currentDownloadAbortController = null; downloadedFilePath = null; downloadedAsarPath = null; downloadedJarPath = null; return {success: false, error: error instanceof Error ? error.message : '下载失败'}; } }); ipcMain.handle('get-download-progress', () => { return {...downloadProgress, isDownloading}; }); ipcMain.handle('install-update', async () => { try { const updateDir = getUpdateDirectoryPath(); const asarUpdatePath = join(updateDir, 'app.asar.update'); const jarUpdateFile = readdirSync(updateDir).find(f => f.startsWith('erp_client_sb-') && f.endsWith('.jar.update')); const jarUpdatePath = jarUpdateFile ? join(updateDir, jarUpdateFile) : ''; if (!existsSync(asarUpdatePath) && !jarUpdatePath) { return {success: false, error: '更新文件不存在'}; } const appDir = dirname(process.execPath); const helperPath = join(appDir, 'update-helper.bat'); if (!existsSync(helperPath)) { return {success: false, error: '更新助手不存在'}; } const appAsarPath = join(process.resourcesPath, 'app.asar'); const vbsPath = join(app.getPath('temp'), 'update-install.vbs'); const vbsContent = `Set WshShell = CreateObject("WScript.Shell") WshShell.Run Chr(34) & "${helperPath.replace(/\\/g, '\\\\')}" & Chr(34) & " " & Chr(34) & "${appAsarPath.replace(/\\/g, '\\\\')}" & Chr(34) & " " & Chr(34) & "${asarUpdatePath.replace(/\\/g, '\\\\')}" & Chr(34) & " " & Chr(34) & "${jarUpdatePath.replace(/\\/g, '\\\\')}" & Chr(34) & " " & Chr(34) & "${process.execPath.replace(/\\/g, '\\\\')}" & Chr(34) & " " & Chr(34) & "${updateDir.replace(/\\/g, '\\\\')}" & Chr(34), 0, False`; writeFileSync(vbsPath, vbsContent); spawn('wscript.exe', [vbsPath], {detached: true, stdio: 'ignore', shell: false}); setTimeout(() => { downloadedFilePath = null; downloadedAsarPath = null; downloadedJarPath = null; app.quit(); }, 500); return {success: true}; } catch (error: unknown) { return {success: false, error: error instanceof Error ? error.message : '重启失败'}; } }); ipcMain.handle('cancel-download', async () => { if (currentDownloadAbortController) { currentDownloadAbortController.abort(); currentDownloadAbortController = null; } await new Promise(resolve => setTimeout(resolve, 500)); await cleanupDownloadFiles(); isDownloading = false; downloadProgress = {percentage: 0, current: '0 MB', total: '0 MB'}; downloadedFilePath = null; downloadedAsarPath = null; downloadedJarPath = null; return {success: true}; }); ipcMain.handle('get-update-status', () => { return {downloadedFilePath, isDownloading, downloadProgress, isPackaged: app.isPackaged}; }); ipcMain.handle('check-pending-update', () => { try { const updateDir = getUpdateDirectoryPath(); const asarUpdatePath = join(updateDir, 'app.asar.update'); const jarUpdateFile = readdirSync(updateDir).find(f => f.startsWith('erp_client_sb-') && f.endsWith('.jar.update')); const jarUpdatePath = jarUpdateFile ? join(updateDir, jarUpdateFile) : ''; return { hasPendingUpdate: existsSync(asarUpdatePath) || !!jarUpdatePath, asarUpdatePath: existsSync(asarUpdatePath) ? asarUpdatePath : null, jarUpdatePath: jarUpdatePath || null }; } catch (error) { return {hasPendingUpdate: false, asarUpdatePath: null, jarUpdatePath: null}; } }); ipcMain.handle('clear-update-files', async () => { try { await cleanupDownloadFiles(); // 重置下载状态 downloadedFilePath = null; downloadedAsarPath = null; downloadedJarPath = null; isDownloading = false; downloadProgress = {percentage: 0, current: '0 MB', total: '0 MB'}; return {success: true}; } catch (error: unknown) { return {success: false, error: error instanceof Error ? error.message : '清除失败'}; } }); async function cleanupDownloadFiles() { try { const updateDir = getUpdateDirectoryPath(); const files = readdirSync(updateDir); for (const file of files) { await fs.unlink(join(updateDir, file)).catch(() => {}); } } catch (error) {} } // 添加文件保存对话框处理器 ipcMain.handle('show-save-dialog', async (event, options) => { return await dialog.showSaveDialog(mainWindow!, options); }); // 添加文件夹选择对话框处理器 ipcMain.handle('show-open-dialog', async (event, options) => { return await dialog.showOpenDialog(mainWindow!, options); }); // 添加文件写入处理器 ipcMain.handle('write-file', async (event, filePath: string, data: Uint8Array) => { await fs.writeFile(filePath, Buffer.from(data)); return { success: true }; }); // 获取日志日期列表 ipcMain.handle('get-log-dates', async () => { try { const logDir = getLogDirectoryPath(); const files = await fs.readdir(logDir); const dates: string[] = []; const today = new Date().toISOString().split('T')[0]; files.forEach(file => { if (file === 'spring-boot.log') { dates.push(today); } else if (file.startsWith('spring-boot-') && file.endsWith('.log')) { const date = file.replace('spring-boot-', '').replace('.log', ''); dates.push(date); } }); dates.sort().reverse(); return { dates }; } catch (error) { return { dates: [] }; } }); // 读取指定日期的日志文件 ipcMain.handle('read-log-file', async (event, logDate: string) => { try { const logDir = getLogDirectoryPath(); const today = new Date().toISOString().split('T')[0]; const fileName = logDate === today ? 'spring-boot.log' : `spring-boot-${logDate}.log`; const logFilePath = join(logDir, fileName); if (!existsSync(logFilePath)) { return { success: false, error: '日志文件不存在' }; } const content = await fs.readFile(logFilePath, 'utf-8'); return { success: true, content }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : '读取失败' }; } }); // 获取关闭行为配置 ipcMain.handle('get-close-action', () => { const config = loadConfig(); return config.closeAction; }); // 保存关闭行为配置 ipcMain.handle('set-close-action', (event, action: 'quit' | 'minimize' | 'tray') => { const config = loadConfig(); config.closeAction = action; saveConfig(config); return { success: true }; }); // 清理缓存 ipcMain.handle('clear-cache', async () => { try { const response = await fetch('http://127.0.0.1:8081/api/system/cache/clear', { method: 'POST' }); const data = await response.json(); return data; } catch (error: any) { console.error('清理缓存失败:', error); return { success: false, error: error.message }; } }); // 获取启动配置 ipcMain.handle('get-launch-config', () => { const config = loadConfig(); const loginSettings = app.getLoginItemSettings(); return { autoLaunch: config.autoLaunch !== undefined ? config.autoLaunch : loginSettings.openAtLogin, launchMinimized: config.launchMinimized || false }; }); // 设置启动配置 ipcMain.handle('set-launch-config', (event, launchConfig: { autoLaunch: boolean; launchMinimized: boolean }) => { const config = loadConfig(); config.autoLaunch = launchConfig.autoLaunch; config.launchMinimized = launchConfig.launchMinimized; saveConfig(config); // 立即应用开机自启动设置 app.setLoginItemSettings({ openAtLogin: launchConfig.autoLaunch, openAsHidden: launchConfig.launchMinimized }); return { success: true }; }); // 刷新页面 ipcMain.handle('reload', () => mainWindow?.webContents.reload()); async function getFileSize(url: string): Promise { return new Promise((resolve) => { const protocol = url.startsWith('https') ? https : http; const request = protocol.get(url, {method: 'HEAD'}, (response) => { if (response.statusCode === 301 || response.statusCode === 302 || response.statusCode === 307) { const redirectUrl = response.headers.location; if (redirectUrl) { getFileSize(redirectUrl).then(resolve).catch(() => resolve(0)); return; } } const size = parseInt(response.headers['content-length'] || '0', 10); resolve(size); }).on('error', () => resolve(0)); request.setTimeout(10000, () => { request.destroy(); resolve(0); }); }); } async function downloadFile(url: string, filePath: string, onProgress: (progress: {downloaded: number, total: number}) => void): Promise { return new Promise((resolve, reject) => { const protocol = url.startsWith('https') ? https : http; const request = protocol.get(url, (response) => { if (response.statusCode === 301 || response.statusCode === 302 || response.statusCode === 307) { const redirectUrl = response.headers.location; if (redirectUrl) { downloadFile(redirectUrl, filePath, onProgress).then(resolve).catch(reject); return; } } if (response.statusCode !== 200) { reject(new Error(`HTTP ${response.statusCode}`)); return; } const totalSize = parseInt(response.headers['content-length'] || '0', 10); let downloadedBytes = 0; const fileStream = createWriteStream(filePath); if (currentDownloadAbortController) { currentDownloadAbortController.signal.addEventListener('abort', () => { request.destroy(); response.destroy(); fileStream.destroy(); reject(new Error('下载已取消')); }); } response.on('data', (chunk) => { downloadedBytes += chunk.length; onProgress({downloaded: downloadedBytes, total: totalSize}); }); response.pipe(fileStream); fileStream.on('finish', () => { fileStream.close(); resolve(); }); fileStream.on('error', (error) => { fs.unlink(filePath).catch(() => {}); reject(error); }); }).on('error', reject); }); }