Convert web app to Electron desktop application

- Add Electron main process with developer tools support
- Create preload script for secure context bridge
- Configure electron-builder for packaging
- Update Vite config for Electron compatibility (base: './')
- Add environment variable support for API host configuration
- Fix i18n to use relative paths for Electron file protocol
- Restore multi-port backend architecture (8081-8085)
- Add enhanced backend startup script with port checking
- Update package.json with Electron dependencies and build scripts

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
ZacharyZcR
2025-09-04 17:57:12 +08:00
parent 983cf7383e
commit 591309530d
19 changed files with 4919 additions and 31 deletions

467
electron/main.cjs Normal file
View File

@@ -0,0 +1,467 @@
const { app, BrowserWindow, Menu, Tray, shell, ipcMain, dialog } = require('electron');
const path = require('path');
const { spawn } = require('child_process');
const fs = require('fs');
// 动态导入可能有 ESM 问题的模块
let portfinder;
let Store;
let autoUpdater;
try {
portfinder = require('portfinder');
Store = require('electron-store');
const updaterModule = require('electron-updater');
autoUpdater = updaterModule.autoUpdater;
} catch (error) {
console.error('Error loading modules:', error);
// 提供后备方案
portfinder = {
getPortPromise: async () => 18080 + Math.floor(Math.random() * 100)
};
Store = class {
constructor() { this.data = {}; }
get(key, defaultValue) { return this.data[key] || defaultValue; }
set(key, value) { this.data[key] = value; }
};
}
// 初始化配置存储
const store = new Store();
// 全局变量
let mainWindow = null;
let backendProcess = null;
let tray = null;
let backendPort = null;
let isQuitting = false;
// 开发环境检测
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged;
// 防止多开
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
} else {
app.on('second-instance', () => {
// 如果用户试图运行第二个实例,我们应该聚焦我们的窗口
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
});
}
// 后端进程管理类
class BackendManager {
constructor() {
this.process = null;
this.port = null;
this.retryCount = 0;
this.maxRetries = 3;
this.isStarting = false;
this.healthCheckInterval = null;
}
async findAvailablePort() {
portfinder.basePort = store.get('backend.port', 18080);
try {
const port = await portfinder.getPortPromise();
this.port = port;
return port;
} catch (error) {
console.error('Error finding available port:', error);
throw error;
}
}
async start() {
if (this.isStarting || this.process) {
console.log('Backend already starting or running');
return;
}
this.isStarting = true;
try {
// 查找可用端口
await this.findAvailablePort();
console.log(`Starting backend on port ${this.port}`);
// 确定后端可执行文件路径
let backendPath;
if (isDev) {
// 开发环境:使用 node 运行构建后的 JS
backendPath = path.join(__dirname, '..', 'dist', 'backend', 'starter.js');
} else {
// 生产环境:使用打包后的后端
backendPath = path.join(process.resourcesPath, 'backend', 'starter.js');
}
// 确保后端文件存在
if (!fs.existsSync(backendPath)) {
throw new Error(`Backend file not found at ${backendPath}`);
}
// 设置环境变量
const env = {
...process.env,
PORT: this.port.toString(),
NODE_ENV: isDev ? 'development' : 'production',
DATA_PATH: app.getPath('userData'),
DB_PATH: path.join(app.getPath('userData'), 'database.db'),
};
// 启动后端进程
if (isDev) {
this.process = spawn('node', [backendPath], {
env,
cwd: path.join(__dirname, '..'),
stdio: ['ignore', 'pipe', 'pipe']
});
} else {
this.process = spawn('node', [backendPath], {
env,
cwd: process.resourcesPath,
stdio: ['ignore', 'pipe', 'pipe']
});
}
// 监听后端输出
this.process.stdout.on('data', (data) => {
console.log(`Backend stdout: ${data}`);
// 向渲染进程发送日志
if (mainWindow) {
mainWindow.webContents.send('backend-log', data.toString());
}
});
this.process.stderr.on('data', (data) => {
console.error(`Backend stderr: ${data}`);
if (mainWindow) {
mainWindow.webContents.send('backend-error', data.toString());
}
});
// 监听后端进程退出
this.process.on('exit', (code) => {
console.log(`Backend process exited with code ${code}`);
this.process = null;
this.isStarting = false;
// 如果不是正在退出且退出码不为0尝试重启
if (!isQuitting && code !== 0 && this.retryCount < this.maxRetries) {
this.retryCount++;
console.log(`Attempting to restart backend (retry ${this.retryCount}/${this.maxRetries})`);
setTimeout(() => this.start(), 2000);
}
});
// 等待后端启动
await this.waitForBackend();
// 启动健康检查
this.startHealthCheck();
// 更新全局端口变量
backendPort = this.port;
// 通知渲染进程
if (mainWindow) {
mainWindow.webContents.send('backend-started', { port: this.port });
}
this.isStarting = false;
this.retryCount = 0;
return this.port;
} catch (error) {
console.error('Failed to start backend:', error);
this.isStarting = false;
throw error;
}
}
async waitForBackend() {
const maxWaitTime = 30000; // 30秒
const checkInterval = 500; // 每500ms检查一次
const startTime = Date.now();
while (Date.now() - startTime < maxWaitTime) {
try {
// 尝试连接后端健康检查端点
const response = await fetch(`http://127.0.0.1:${this.port}/health`);
if (response.ok) {
console.log('Backend is ready');
return;
}
} catch (error) {
// 继续等待
}
await new Promise(resolve => setTimeout(resolve, checkInterval));
}
throw new Error('Backend failed to start within timeout period');
}
startHealthCheck() {
if (this.healthCheckInterval) {
clearInterval(this.healthCheckInterval);
}
this.healthCheckInterval = setInterval(async () => {
if (!this.process) return;
try {
const response = await fetch(`http://127.0.0.1:${this.port}/health`);
if (!response.ok) {
console.error('Backend health check failed');
// 可以在这里触发重启逻辑
}
} catch (error) {
console.error('Backend health check error:', error);
}
}, 10000); // 每10秒检查一次
}
stop() {
return new Promise((resolve) => {
if (this.healthCheckInterval) {
clearInterval(this.healthCheckInterval);
this.healthCheckInterval = null;
}
if (!this.process) {
resolve();
return;
}
console.log('Stopping backend process...');
// 设置超时强制杀死
const killTimeout = setTimeout(() => {
if (this.process) {
console.log('Force killing backend process');
this.process.kill('SIGKILL');
}
}, 5000);
this.process.on('exit', () => {
clearTimeout(killTimeout);
this.process = null;
console.log('Backend process stopped');
resolve();
});
// 优雅关闭
this.process.kill('SIGTERM');
});
}
}
// 创建后端管理器实例
const backendManager = new BackendManager();
// 创建主窗口
function createWindow() {
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 800,
minHeight: 600,
title: 'Termix',
icon: path.join(__dirname, '..', 'public', 'icon.png'),
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
preload: path.join(__dirname, 'preload.cjs'),
webSecurity: !isDev
},
show: false, // 先不显示,等加载完成
});
// 移除默认菜单栏Windows/Linux
if (process.platform !== 'darwin') {
mainWindow.setMenuBarVisibility(false);
}
// 加载应用
if (isDev) {
// 开发环境:连接到 Vite 开发服务器
mainWindow.loadURL('http://localhost:5173');
mainWindow.webContents.openDevTools();
} else {
// 生产环境:加载构建后的文件
mainWindow.loadFile(path.join(__dirname, '..', 'dist', 'index.html'));
}
// 窗口准备好后显示
mainWindow.once('ready-to-show', () => {
mainWindow.show();
});
// 处理窗口关闭事件
mainWindow.on('close', (event) => {
if (!isQuitting && process.platform === 'darwin') {
// macOS隐藏窗口而不是退出
event.preventDefault();
mainWindow.hide();
} else if (!isQuitting && store.get('minimizeToTray', true)) {
// Windows/Linux最小化到托盘
event.preventDefault();
mainWindow.hide();
}
});
mainWindow.on('closed', () => {
mainWindow = null;
});
// 处理外部链接
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: 'deny' };
});
}
// 创建系统托盘
function createTray() {
if (process.platform === 'darwin') return; // macOS 不需要托盘
const iconPath = path.join(__dirname, '..', 'public', 'icon.png');
tray = new Tray(iconPath);
const contextMenu = Menu.buildFromTemplate([
{
label: '显示',
click: () => {
if (mainWindow) {
mainWindow.show();
mainWindow.focus();
}
}
},
{ type: 'separator' },
{
label: '退出',
click: () => {
isQuitting = true;
app.quit();
}
}
]);
tray.setToolTip('Termix');
tray.setContextMenu(contextMenu);
// 双击托盘图标显示窗口
tray.on('double-click', () => {
if (mainWindow) {
mainWindow.show();
mainWindow.focus();
}
});
}
// IPC 通信处理
ipcMain.handle('get-backend-port', () => {
return backendPort;
});
ipcMain.handle('get-app-version', () => {
return app.getVersion();
});
ipcMain.handle('get-platform', () => {
return process.platform;
});
ipcMain.handle('restart-backend', async () => {
try {
await backendManager.stop();
await backendManager.start();
return { success: true, port: backendManager.port };
} catch (error) {
return { success: false, error: error.message };
}
});
ipcMain.handle('show-save-dialog', async (event, options) => {
const result = await dialog.showSaveDialog(mainWindow, options);
return result;
});
ipcMain.handle('show-open-dialog', async (event, options) => {
const result = await dialog.showOpenDialog(mainWindow, options);
return result;
});
// 自动更新
if (!isDev && autoUpdater) {
try {
autoUpdater.checkForUpdatesAndNotify();
autoUpdater.on('update-available', () => {
if (mainWindow) {
mainWindow.webContents.send('update-available');
}
});
autoUpdater.on('update-downloaded', () => {
if (mainWindow) {
mainWindow.webContents.send('update-downloaded');
}
});
} catch (error) {
console.log('Auto-updater not available:', error);
}
}
// 应用事件处理
app.whenReady().then(async () => {
try {
// 启动后端
await backendManager.start();
// 创建窗口
createWindow();
// 创建托盘
createTray();
} catch (error) {
console.error('Failed to initialize application:', error);
dialog.showErrorBox('启动失败', `无法启动应用: ${error.message}`);
app.quit();
}
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
} else if (mainWindow) {
mainWindow.show();
}
});
app.on('before-quit', async () => {
isQuitting = true;
await backendManager.stop();
});
// 处理未捕获的异常
process.on('uncaughtException', (error) => {
console.error('Uncaught Exception:', error);
dialog.showErrorBox('未捕获的异常', error.message);
});
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
});