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:
84
electron-builder.json
Normal file
84
electron-builder.json
Normal file
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"appId": "com.termix.app",
|
||||
"productName": "Termix",
|
||||
"directories": {
|
||||
"output": "release",
|
||||
"buildResources": "build"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"electron/main-simple.cjs",
|
||||
"electron/preload-simple.cjs",
|
||||
"public/icon.*",
|
||||
"package.json"
|
||||
],
|
||||
"asar": true,
|
||||
"mac": {
|
||||
"category": "public.app-category.developer-tools",
|
||||
"icon": "public/icon.icns",
|
||||
"hardenedRuntime": true,
|
||||
"gatekeeperAssess": false,
|
||||
"entitlements": "build/entitlements.mac.plist",
|
||||
"entitlementsInherit": "build/entitlements.mac.plist",
|
||||
"target": [
|
||||
{
|
||||
"target": "dmg",
|
||||
"arch": ["x64", "arm64"]
|
||||
},
|
||||
{
|
||||
"target": "zip",
|
||||
"arch": ["x64", "arm64"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"win": {
|
||||
"icon": "public/icon.ico",
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": ["x64", "ia32"]
|
||||
},
|
||||
{
|
||||
"target": "portable",
|
||||
"arch": ["x64"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"linux": {
|
||||
"category": "Development",
|
||||
"target": [
|
||||
{
|
||||
"target": "AppImage",
|
||||
"arch": ["x64"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"perMachine": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"deleteAppDataOnUninstall": false,
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true
|
||||
},
|
||||
"dmg": {
|
||||
"contents": [
|
||||
{
|
||||
"x": 410,
|
||||
"y": 150,
|
||||
"type": "link",
|
||||
"path": "/Applications"
|
||||
},
|
||||
{
|
||||
"x": 130,
|
||||
"y": 150,
|
||||
"type": "file"
|
||||
}
|
||||
]
|
||||
},
|
||||
"publish": {
|
||||
"provider": "github",
|
||||
"owner": "LukeGus",
|
||||
"repo": "Termix"
|
||||
}
|
||||
}
|
||||
132
electron/main-simple.cjs
Normal file
132
electron/main-simple.cjs
Normal file
@@ -0,0 +1,132 @@
|
||||
const { app, BrowserWindow, Menu, shell, ipcMain } = require('electron');
|
||||
const path = require('path');
|
||||
|
||||
// 全局变量
|
||||
let mainWindow = null;
|
||||
|
||||
// 开发环境检测
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 创建主窗口
|
||||
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-simple.cjs'),
|
||||
webSecurity: !isDev
|
||||
},
|
||||
show: false,
|
||||
});
|
||||
|
||||
// 创建应用菜单(包含开发者工具快捷键)
|
||||
const template = [
|
||||
{
|
||||
label: 'View',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Toggle Developer Tools',
|
||||
accelerator: process.platform === 'darwin' ? 'Alt+Command+I' : 'Ctrl+Shift+I',
|
||||
click: () => {
|
||||
mainWindow.webContents.toggleDevTools();
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Reload',
|
||||
accelerator: 'CmdOrCtrl+R',
|
||||
click: () => {
|
||||
mainWindow.webContents.reload();
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const menu = Menu.buildFromTemplate(template);
|
||||
Menu.setApplicationMenu(menu);
|
||||
|
||||
// 加载应用
|
||||
if (isDev) {
|
||||
// 开发环境:连接到 Vite 开发服务器
|
||||
mainWindow.loadURL('http://localhost:5173');
|
||||
mainWindow.webContents.openDevTools();
|
||||
} else {
|
||||
// 生产环境:加载构建后的文件
|
||||
mainWindow.loadFile(path.join(__dirname, '..', 'dist', 'index.html'));
|
||||
// 生产环境也启用开发者工具以便调试
|
||||
mainWindow.webContents.openDevTools();
|
||||
}
|
||||
|
||||
// 窗口准备好后显示
|
||||
mainWindow.once('ready-to-show', () => {
|
||||
mainWindow.show();
|
||||
});
|
||||
|
||||
// 处理窗口关闭事件
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
|
||||
// 处理外部链接
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
shell.openExternal(url);
|
||||
return { action: 'deny' };
|
||||
});
|
||||
}
|
||||
|
||||
// IPC 通信处理
|
||||
ipcMain.handle('get-app-version', () => {
|
||||
return app.getVersion();
|
||||
});
|
||||
|
||||
ipcMain.handle('get-platform', () => {
|
||||
return process.platform;
|
||||
});
|
||||
|
||||
// 应用事件处理
|
||||
app.whenReady().then(() => {
|
||||
createWindow();
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
// 处理未捕获的异常
|
||||
process.on('uncaughtException', (error) => {
|
||||
console.error('Uncaught Exception:', error);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
|
||||
});
|
||||
467
electron/main.cjs
Normal file
467
electron/main.cjs
Normal 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);
|
||||
});
|
||||
17
electron/preload-simple.cjs
Normal file
17
electron/preload-simple.cjs
Normal file
@@ -0,0 +1,17 @@
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
// 暴露简化的 API 给渲染进程
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
// 获取应用版本
|
||||
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
|
||||
|
||||
// 获取平台信息
|
||||
getPlatform: () => ipcRenderer.invoke('get-platform'),
|
||||
|
||||
// 环境检测
|
||||
isElectron: true,
|
||||
isDev: process.env.NODE_ENV === 'development',
|
||||
});
|
||||
|
||||
// 添加一个标识,让渲染进程知道这是 Electron 环境
|
||||
window.IS_ELECTRON = true;
|
||||
54
electron/preload.cjs
Normal file
54
electron/preload.cjs
Normal file
@@ -0,0 +1,54 @@
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
// 暴露安全的 API 给渲染进程
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
// 获取后端端口
|
||||
getBackendPort: () => ipcRenderer.invoke('get-backend-port'),
|
||||
|
||||
// 获取应用版本
|
||||
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
|
||||
|
||||
// 获取平台信息
|
||||
getPlatform: () => ipcRenderer.invoke('get-platform'),
|
||||
|
||||
// 重启后端
|
||||
restartBackend: () => ipcRenderer.invoke('restart-backend'),
|
||||
|
||||
// 文件对话框
|
||||
showSaveDialog: (options) => ipcRenderer.invoke('show-save-dialog', options),
|
||||
showOpenDialog: (options) => ipcRenderer.invoke('show-open-dialog', options),
|
||||
|
||||
// 监听后端事件
|
||||
onBackendStarted: (callback) => {
|
||||
ipcRenderer.on('backend-started', (event, data) => callback(data));
|
||||
},
|
||||
|
||||
onBackendLog: (callback) => {
|
||||
ipcRenderer.on('backend-log', (event, data) => callback(data));
|
||||
},
|
||||
|
||||
onBackendError: (callback) => {
|
||||
ipcRenderer.on('backend-error', (event, data) => callback(data));
|
||||
},
|
||||
|
||||
// 监听更新事件
|
||||
onUpdateAvailable: (callback) => {
|
||||
ipcRenderer.on('update-available', () => callback());
|
||||
},
|
||||
|
||||
onUpdateDownloaded: (callback) => {
|
||||
ipcRenderer.on('update-downloaded', () => callback());
|
||||
},
|
||||
|
||||
// 移除事件监听器
|
||||
removeAllListeners: (channel) => {
|
||||
ipcRenderer.removeAllListeners(channel);
|
||||
},
|
||||
|
||||
// 环境检测
|
||||
isElectron: true,
|
||||
isDev: process.env.NODE_ENV === 'development',
|
||||
});
|
||||
|
||||
// 添加一个标识,让渲染进程知道这是 Electron 环境
|
||||
window.IS_ELECTRON = true;
|
||||
4010
package-lock.json
generated
4010
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
21
package.json
21
package.json
@@ -1,15 +1,29 @@
|
||||
{
|
||||
"name": "termix",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "1.6.0",
|
||||
"description": "Open-source server management platform with SSH terminal access, tunnel management, and file editing",
|
||||
"author": {
|
||||
"name": "LukeGus",
|
||||
"email": "support@termix.site"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "electron/main-simple.cjs",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build:backend": "tsc -p tsconfig.node.json",
|
||||
"dev:backend": "tsc -p tsconfig.node.json && node ./dist/backend/starter.js",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"electron": "electron .",
|
||||
"electron:dev": "npm run build:backend && NODE_ENV=development electron .",
|
||||
"electron:build": "npm run build && npm run build:backend && electron-builder",
|
||||
"dist": "npm run build && npm run build:backend && electron-builder --publish=never",
|
||||
"dist:win": "npm run dist -- --win",
|
||||
"dist:mac": "npm run dist -- --mac",
|
||||
"dist:linux": "npm run dist -- --linux",
|
||||
"postinstall": "electron-builder install-app-deps"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.1.1",
|
||||
@@ -85,6 +99,7 @@
|
||||
"zod": "^4.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron/rebuild": "^4.0.1",
|
||||
"@eslint/js": "^9.34.0",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/cors": "^2.8.19",
|
||||
@@ -97,6 +112,8 @@
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitejs/plugin-react-swc": "^3.10.2",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"electron": "^31.7.0",
|
||||
"electron-builder": "^24.13.3",
|
||||
"eslint": "^9.34.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
|
||||
BIN
public/icon.png
Normal file
BIN
public/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 105 B |
@@ -235,6 +235,16 @@ app.get('/releases/rss', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Health check endpoint for Electron backend manager
|
||||
app.get('/health', (req, res) => {
|
||||
res.status(200).json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'database-api',
|
||||
port: PORT
|
||||
});
|
||||
});
|
||||
|
||||
app.use('/users', userRoutes);
|
||||
app.use('/ssh', sshRoutes);
|
||||
app.use('/alerts', alertRoutes);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Children, ReactElement, cloneElement, isValidElement } from 'react';
|
||||
|
||||
import { ButtonProps } from '@/components/ui/button';
|
||||
import { type ButtonProps } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ButtonGroupProps {
|
||||
|
||||
@@ -35,16 +35,19 @@ const buttonVariants = cva(
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ComponentProps<"button">,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
}: ButtonProps) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
@@ -56,4 +59,4 @@ function Button({
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
export { Button, buttonVariants, type ButtonProps }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, VariantProps } from "class-variance-authority"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { PanelLeftIcon } from "lucide-react"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, ToasterProps } from "sonner"
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
@@ -25,7 +25,7 @@ i18n
|
||||
|
||||
// Backend options
|
||||
backend: {
|
||||
loadPath: '/locales/{{lng}}/translation.json',
|
||||
loadPath: './locales/{{lng}}/translation.json',
|
||||
},
|
||||
|
||||
interpolation: {
|
||||
|
||||
21
src/types/electron.d.ts
vendored
Normal file
21
src/types/electron.d.ts
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
interface ElectronAPI {
|
||||
getBackendPort: () => Promise<number>;
|
||||
getAppVersion: () => Promise<string>;
|
||||
getPlatform: () => Promise<string>;
|
||||
restartBackend: () => Promise<{ success: boolean; port?: number; error?: string }>;
|
||||
showSaveDialog: (options: any) => Promise<any>;
|
||||
showOpenDialog: (options: any) => Promise<any>;
|
||||
onBackendStarted: (callback: (data: { port: number }) => void) => void;
|
||||
onBackendLog: (callback: (data: string) => void) => void;
|
||||
onBackendError: (callback: (data: string) => void) => void;
|
||||
onUpdateAvailable: (callback: () => void) => void;
|
||||
onUpdateDownloaded: (callback: () => void) => void;
|
||||
removeAllListeners: (channel: string) => void;
|
||||
isElectron: boolean;
|
||||
isDev: boolean;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
electronAPI?: ElectronAPI;
|
||||
IS_ELECTRON?: boolean;
|
||||
}
|
||||
@@ -201,34 +201,72 @@ function createApiInstance(baseURL: string): AxiosInstance {
|
||||
// API INSTANCES
|
||||
// ============================================================================
|
||||
|
||||
// Check if running in Electron
|
||||
const isElectron = window.IS_ELECTRON === true;
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development' &&
|
||||
(window.location.port === '3000' || window.location.port === '5173' || window.location.port === '');
|
||||
|
||||
// Get API host and port configuration
|
||||
let apiHost = import.meta.env.VITE_API_HOST || 'localhost';
|
||||
let apiPort = 8081;
|
||||
|
||||
// In Electron, use fixed port since backend runs independently
|
||||
if (isElectron) {
|
||||
// Backend runs independently, use default port
|
||||
apiPort = 8081;
|
||||
}
|
||||
|
||||
// Function to get the correct API URL
|
||||
function getApiUrl(path: string, defaultPort: number): string {
|
||||
if (isElectron) {
|
||||
// In Electron, always use localhost with dynamic port
|
||||
return `http://127.0.0.1:${apiPort}${path}`;
|
||||
} else if (isDev) {
|
||||
// In dev mode, use configured host
|
||||
return `http://${apiHost}:${defaultPort}${path}`;
|
||||
} else {
|
||||
// In production web mode, use relative paths
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-port backend architecture (original design)
|
||||
// SSH Host Management API (port 8081)
|
||||
export const sshHostApi = createApiInstance(
|
||||
isDev ? 'http://localhost:8081/ssh' : '/ssh'
|
||||
export let sshHostApi = createApiInstance(
|
||||
getApiUrl('/ssh', 8081)
|
||||
);
|
||||
|
||||
// Tunnel Management API (port 8083)
|
||||
export const tunnelApi = createApiInstance(
|
||||
isDev ? 'http://localhost:8083/ssh' : '/ssh'
|
||||
export let tunnelApi = createApiInstance(
|
||||
getApiUrl('/ssh', 8083)
|
||||
);
|
||||
|
||||
// File Manager Operations API (port 8084) - SSH file operations
|
||||
export const fileManagerApi = createApiInstance(
|
||||
isDev ? 'http://localhost:8084/ssh/file_manager' : '/ssh/file_manager'
|
||||
export let fileManagerApi = createApiInstance(
|
||||
getApiUrl('/ssh/file_manager', 8084)
|
||||
);
|
||||
|
||||
// Server Statistics API (port 8085)
|
||||
export const statsApi = createApiInstance(
|
||||
isDev ? 'http://localhost:8085' : ''
|
||||
export let statsApi = createApiInstance(
|
||||
getApiUrl('', 8085)
|
||||
);
|
||||
|
||||
// Authentication API (port 8081) - includes users, alerts, version, releases
|
||||
export const authApi = createApiInstance(
|
||||
isDev ? 'http://localhost:8081' : ''
|
||||
export let authApi = createApiInstance(
|
||||
getApiUrl('', 8081)
|
||||
);
|
||||
|
||||
// Function to update API instances with new port (for Electron)
|
||||
function updateApiPorts(port: number) {
|
||||
apiPort = port;
|
||||
sshHostApi = createApiInstance(`http://127.0.0.1:${port}/ssh`);
|
||||
tunnelApi = createApiInstance(`http://127.0.0.1:${port}/ssh`);
|
||||
fileManagerApi = createApiInstance(`http://127.0.0.1:${port}/ssh/file_manager`);
|
||||
statsApi = createApiInstance(`http://127.0.0.1:${port}`);
|
||||
authApi = createApiInstance(`http://127.0.0.1:${port}`);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ERROR HANDLING
|
||||
// ============================================================================
|
||||
@@ -945,7 +983,7 @@ export async function generateBackupCodes(password?: string, totp_code?: string)
|
||||
|
||||
export async function getUserAlerts(userId: string): Promise<{ alerts: any[] }> {
|
||||
try {
|
||||
const apiInstance = createApiInstance(isDev ? 'http://localhost:8081' : '');
|
||||
const apiInstance = createApiInstance(isDev ? `http://${apiHost}:8081` : '');
|
||||
const response = await apiInstance.get(`/alerts/user/${userId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
@@ -956,7 +994,7 @@ export async function getUserAlerts(userId: string): Promise<{ alerts: any[] }>
|
||||
export async function dismissAlert(userId: string, alertId: string): Promise<any> {
|
||||
try {
|
||||
// Use the general API instance since alerts endpoint is at root level
|
||||
const apiInstance = createApiInstance(isDev ? 'http://localhost:8081' : '');
|
||||
const apiInstance = createApiInstance(isDev ? `http://${apiHost}:8081` : '');
|
||||
const response = await apiInstance.post('/alerts/dismiss', { userId, alertId });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
@@ -971,7 +1009,7 @@ export async function dismissAlert(userId: string, alertId: string): Promise<any
|
||||
export async function getReleasesRSS(perPage: number = 100): Promise<any> {
|
||||
try {
|
||||
// Use the general API instance since releases endpoint is at root level
|
||||
const apiInstance = createApiInstance(isDev ? 'http://localhost:8081' : '');
|
||||
const apiInstance = createApiInstance(isDev ? `http://${apiHost}:8081` : '');
|
||||
const response = await apiInstance.get(`/releases/rss?per_page=${perPage}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
@@ -982,7 +1020,7 @@ export async function getReleasesRSS(perPage: number = 100): Promise<any> {
|
||||
export async function getVersionInfo(): Promise<any> {
|
||||
try {
|
||||
// Use the general API instance since version endpoint is at root level
|
||||
const apiInstance = createApiInstance(isDev ? 'http://localhost:8081' : '');
|
||||
const apiInstance = createApiInstance(isDev ? `http://${apiHost}:8081` : '');
|
||||
const response = await apiInstance.get('/version/');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
|
||||
43
start-backend.sh
Executable file
43
start-backend.sh
Executable file
@@ -0,0 +1,43 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "Starting Termix Backend Services..."
|
||||
|
||||
# 设置环境变量
|
||||
export NODE_ENV=${NODE_ENV:-production}
|
||||
export DATA_PATH=${DATA_PATH:-./data}
|
||||
|
||||
# 检查是否已经构建
|
||||
if [ ! -d "dist/backend" ]; then
|
||||
echo "Building backend..."
|
||||
npm run build:backend
|
||||
fi
|
||||
|
||||
# 检查端口是否被占用的函数
|
||||
check_port() {
|
||||
local port=$1
|
||||
if lsof -Pi :$port -sTCP:LISTEN -t >/dev/null 2>&1; then
|
||||
echo "Port $port is already in use"
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# 启动所有后端服务
|
||||
echo "Starting backend services..."
|
||||
echo "- Database API (8081)"
|
||||
echo "- WebSocket Terminal (8082)"
|
||||
echo "- Tunnel Management (8083)"
|
||||
echo "- File Manager (8084)"
|
||||
echo "- Server Statistics (8085)"
|
||||
|
||||
# 检查关键端口
|
||||
for port in 8081 8082 8083 8084 8085; do
|
||||
if ! check_port $port; then
|
||||
echo "Error: Port $port is in use. Please stop the conflicting service."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# 启动主服务(会自动启动所有其他服务)
|
||||
echo "Starting all services via starter.js..."
|
||||
node dist/backend/starter.js
|
||||
@@ -11,4 +11,5 @@ export default defineConfig({
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
base: './', // 使用相对路径,适配 Electron
|
||||
})
|
||||
Reference in New Issue
Block a user