Files
Termix/electron/main.cjs
ZacharyZcR 591309530d 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>
2025-09-04 17:57:12 +08:00

467 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});