import { app, BrowserWindow, Menu, ipcMain, safeStorage, dialog, shell } from 'electron' import path from 'path' import fs from 'fs' import https from 'https' import http from 'http' import { Client } from 'ssh2' // 隐藏 Electron 默认菜单(File / Edit 等) Menu.setApplicationMenu(null) let mainWindow = null let activeConnection = null // 概览/文件/终端 用的 SSH 连接 let activeSession = null // { host, username },登录成功后记录 let activeSftp = null // 缓存的 SFTP 会话,复用同一个 channel let dockerConnection = null // Docker 模块专用 SSH 连接(与 activeConnection 隔离) let dockerSession = null // docker 连接的会话信息 let deployConnection = null // 部署任务专用 SSH 连接(第 3 条连接) let deploySession = null // 部署连接的凭据 { host, port, username, password } // ---------- 后台部署任务管理 ---------- // 每个 task: { id, appName, status, steps[], logs[], startTime, error, containerId } // status: pending | running | done | error const deployTasks = [] let taskCounter = 0 function sendTaskUpdate(task) { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('deploy:taskUpdate', { id: task.id, appName: task.appName, status: task.status, steps: task.steps, logs: task.logs, startTime: task.startTime, error: task.error, containerId: task.containerId, }) } } function addTaskLog(task, message) { task.logs.push({ time: new Date().toLocaleTimeString(), message, }) sendTaskUpdate(task) } function setTaskStep(task, step, message) { // 更新步骤状态 const stepMap = { pull: '拉取镜像', mkdir: '创建目录', writefile: '写入配置', run: '创建容器', done: '完成', } const stepLabel = stepMap[step.replace('-done', '').replace('-error', '')] || step const status = step.includes('error') ? 'error' : step.includes('done') || step === 'done' ? 'done' : 'running' // 找到或创建步骤 let stepObj = task.steps.find((s) => s.name === stepLabel) if (!stepObj) { stepObj = { name: stepLabel, status: 'pending' } task.steps.push(stepObj) } stepObj.status = status if (message) addTaskLog(task, message) else sendTaskUpdate(task) } // ---------- 凭据存储 ---------- const credFile = () => path.join(app.getPath('userData'), 'credentials.json') function readCredentials() { try { return JSON.parse(fs.readFileSync(credFile(), 'utf8')) } catch { return [] } } function writeCredentials(list) { fs.mkdirSync(path.dirname(credFile()), { recursive: true }) fs.writeFileSync(credFile(), JSON.stringify(list, null, 2), 'utf8') } // 密码优先用系统安全存储加密(Windows DPAPI),不可用时降级为 base64 function encryptPassword(password) { if (safeStorage.isEncryptionAvailable()) { return { data: safeStorage.encryptString(password).toString('base64'), encrypted: true } } return { data: Buffer.from(password, 'utf8').toString('base64'), encrypted: false } } function decryptPassword(entry) { if (entry.encrypted) { return safeStorage.decryptString(Buffer.from(entry.data, 'base64')) } return Buffer.from(entry.data, 'base64').toString('utf8') } // ---------- 窗口 ---------- function createWindow() { mainWindow = new BrowserWindow({ width: 1280, height: 800, backgroundColor: '#eef1f6', webPreferences: { preload: path.join(__dirname, '../preload/index.js'), sandbox: false, contextIsolation: true, nodeIntegration: false, }, }) if (process.env['ELECTRON_RENDERER_URL']) { // 开发模式:加载 Vite 开发服务器(支持热更新) mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) // 菜单已隐藏,用 F12 开关开发者工具 mainWindow.webContents.on('before-input-event', (event, input) => { if (input.key === 'F12') mainWindow.webContents.toggleDevTools() }) } else { mainWindow.loadFile(path.join(__dirname, '../renderer/index.html')) } mainWindow.on('closed', () => { mainWindow = null if (activeShell) { activeShell.end() activeShell = null } activeSftp = null if (activeConnection) { activeConnection.end() activeConnection = null } if (dockerConnection) { dockerConnection.end() dockerConnection = null } if (deployConnection) { deployConnection.end() deployConnection = null } }) } // ---------- IPC:SSH 连接 ---------- // 内部函数:建立 Docker 专用 SSH 连接 function connectDocker(host, port, username, password) { if (dockerConnection) { dockerConnection.end() dockerConnection = null } const client = new Client() client.on('ready', () => { dockerConnection = client dockerSession = { host, username } if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('docker:ready', { ok: true }) } }) client.on('error', () => { if (dockerConnection === client) { dockerConnection = null dockerSession = null if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('docker:disconnected', { message: 'Docker SSH 连接异常' }) } } }) client.on('close', () => { if (dockerConnection === client) { dockerConnection = null dockerSession = null if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('docker:disconnected', { message: 'Docker SSH 连接已断开' }) } } }) client.connect({ host, port: Number(port), username, password, readyTimeout: 10000, }) } // 内部函数:获取或创建部署专用 SSH 连接 function getDeployConnection() { return new Promise((resolve, reject) => { // 已有连接且仍然存活 if (deployConnection) { resolve(deployConnection) return } if (!deploySession) { reject(new Error('部署连接凭据不存在')) return } const { host, port, username, password } = deploySession const client = new Client() let settled = false client.on('ready', () => { if (settled) return settled = true deployConnection = client resolve(client) }) client.on('error', (err) => { if (!settled) { settled = true reject(err) } if (deployConnection === client) { deployConnection = null } }) client.on('close', () => { if (!settled) { settled = true reject(new Error('部署连接被关闭')) } if (deployConnection === client) { deployConnection = null } }) client.connect({ host, port: Number(port), username, password, readyTimeout: 10000, }) }) } // 在部署连接上执行命令(直接并发,不加串行队列) // 每个部署任务内部是 await 顺序执行的,多个任务之间可以并行 // SSH 单连接支持多个并发 channel,2-3 个部署任务完全够用 function execDeploy(cmd, timeoutMs = 30000) { return new Promise((resolve) => { getDeployConnection().then((conn) => { let settled = false let stdout = '' let stderr = '' const timer = setTimeout(() => { if (!settled) { settled = true resolve({ ok: false, message: '命令执行超时' }) } }, timeoutMs) conn.exec(cmd, (err, stream) => { if (err) { clearTimeout(timer) if (!settled) { settled = true resolve({ ok: false, message: err.message }) } return } stream.on('data', (chunk) => (stdout += chunk.toString())) stream.stderr.on('data', (chunk) => (stderr += chunk.toString())) stream.on('close', (code) => { clearTimeout(timer) if (!settled) { settled = true resolve({ ok: true, code, stdout, stderr }) } }) }) }).catch((err) => { resolve({ ok: false, message: err.message }) }) }) } ipcMain.handle('ssh:connect', (event, { host, port, username, password }) => { return new Promise((resolve) => { // 已有连接时先断开旧连接 if (activeShell) { activeShell.end() activeShell = null } if (activeSftp) { activeSftp = null } if (activeConnection) { activeConnection.end() activeConnection = null } const client = new Client() let settled = false client.on('ready', () => { if (settled) return settled = true activeConnection = client activeSession = { host, username } // 保存部署连接凭据(懒创建,首次部署时才建立连接) deploySession = { host, port: Number(port), username, password } // 自动建立 Docker 专用连接(复用相同凭据) connectDocker(host, port, username, password) resolve({ ok: true }) }) // 连接阶段的错误 → resolve 给前端 // 连接成功后的运行期错误 → 仅清理连接状态,不 crash client.on('error', (err) => { if (!settled) { settled = true resolve({ ok: false, message: err.message }) } // 运行期 error:清理连接,让 UI 提示断开重连 if (activeConnection === client) { activeConnection = null activeSftp = null activeSession = null if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('ssh:disconnected', { message: err.message }) } } }) client.on('close', () => { if (!settled) { settled = true resolve({ ok: false, message: '连接被关闭' }) } if (activeConnection === client) { activeConnection = null activeSftp = null activeSession = null if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('ssh:disconnected', { message: '连接已断开' }) } } }) client.connect({ host, port: Number(port), username, password, readyTimeout: 10000, }) }) }) ipcMain.handle('ssh:disconnect', () => { if (activeShell) { activeShell.end() activeShell = null } activeSftp = null if (activeConnection) { activeConnection.end() activeConnection = null } activeSession = null // 同时断开 Docker 专用连接 if (dockerConnection) { dockerConnection.end() dockerConnection = null } dockerSession = null // 断开部署专用连接 if (deployConnection) { deployConnection.end() deployConnection = null } deploySession = null return { ok: true } }) ipcMain.handle('ssh:session', () => activeSession) // 在活动的 SSH 连接上执行命令 // 使用队列串行化所有 exec 请求,避免并发打开过多 SSH channel 导致 // "Channel open failure: open failed" 错误 const execQueue = [] let execRunning = false function execSsh(cmd, timeoutMs = 10000) { return new Promise((resolve) => { execQueue.push({ cmd, timeoutMs, resolve }) drainExecQueue() }) } function drainExecQueue() { if (execRunning || execQueue.length === 0) return execRunning = true const item = execQueue.shift() if (item.task) { // SFTP 任务 item .task() .then((result) => item.resolve(result)) .catch((err) => item.resolve({ ok: false, message: err.message })) .finally(() => { execRunning = false drainExecQueue() }) } else { // exec 任务 runExec(item.cmd, item.timeoutMs, (result) => { item.resolve(result) execRunning = false // 继续处理队列中下一个请求 drainExecQueue() }) } } function runExec(cmd, timeoutMs, done) { if (!activeConnection) { done({ ok: false, message: 'SSH 连接不存在' }) return } let settled = false let stdout = '' let stderr = '' const timer = setTimeout(() => { if (!settled) { settled = true done({ ok: false, message: '命令执行超时' }) } }, timeoutMs) activeConnection.exec(cmd, (err, stream) => { if (err) { clearTimeout(timer) if (!settled) { settled = true done({ ok: false, message: err.message }) } return } stream.on('data', (chunk) => (stdout += chunk.toString())) stream.stderr.on('data', (chunk) => (stderr += chunk.toString())) stream.on('close', (code) => { clearTimeout(timer) if (!settled) { settled = true done({ ok: true, code, stdout, stderr }) } }) }) } ipcMain.handle('ssh:exec', (event, { cmd, timeout }) => execSsh(cmd, timeout)) // ---------- IPC:凭据 ---------- ipcMain.handle('cred:save', (event, { host, port, username, password }) => { const list = readCredentials() const entry = { host, port: Number(port), username, password: encryptPassword(password) } // 同一 host + username 覆盖旧记录 const idx = list.findIndex((c) => c.host === host && c.username === username) if (idx >= 0) list[idx] = entry else list.push(entry) writeCredentials(list) return { ok: true } }) ipcMain.handle('cred:list', () => { return readCredentials() .map((c) => { try { return { host: c.host, port: c.port, username: c.username, password: decryptPassword(c.password), } } catch { return null // 本机无法解密(比如从别的机器拷贝来的),跳过 } }) .filter(Boolean) }) ipcMain.handle('cred:delete', (event, { host, username }) => { writeCredentials(readCredentials().filter((c) => !(c.host === host && c.username === username))) return { ok: true } }) // ---------- IPC:SFTP 文件管理 ---------- // SFTP 操作也走串行队列,和 exec 共用同一个队列, // 避免并发打开过多 SSH channel 导致 "Channel open failure" function runSftpTask(task) { return new Promise((resolve) => { execQueue.push({ resolve, task, // async function() => result }) drainExecQueue() }) } // 获取 SFTP 会话(复用 activeConnection,缓存同一个 SFTP channel) function getSftp() { return new Promise((resolve, reject) => { if (!activeConnection) { reject(new Error('SSH 连接不存在')) return } // 已有缓存的 sftp 会话且连接仍然有效,直接复用 if (activeSftp) { resolve(activeSftp) return } activeConnection.sftp((err, sftp) => { if (err) reject(err) else { activeSftp = sftp resolve(sftp) } }) }) } // 列目录 ipcMain.handle('sftp:list', async (event, { remotePath }) => { return runSftpTask(async () => { const sftp = await getSftp() const list = await new Promise((resolve, reject) => { sftp.readdir(remotePath, (err, items) => { if (err) reject(err) else resolve(items) }) }) // 规范化字段 const result = list.map((item) => ({ filename: item.filename, longname: item.longname || '', attrs: { size: item.attrs.size, mtime: item.attrs.mtime, atime: item.attrs.atime, permissions: item.attrs.permissions, uid: item.attrs.uid, gid: item.attrs.gid, }, isDirectory: item.attrs.isDirectory(), isFile: item.attrs.isFile(), isSymlink: item.attrs.isSymbolicLink(), })) // 目录排前,文件排后,同类按名称排序 result.sort((a, b) => { if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1 return a.filename.localeCompare(b.filename) }) return { ok: true, list: result } }) }) // 上传文件(本地 → 远程) ipcMain.handle('sftp:upload', async (event, { localPath, remotePath }) => { return runSftpTask(async () => { const sftp = await getSftp() const totalSize = fs.statSync(localPath).size let uploaded = 0 return await new Promise((resolve) => { const rs = fs.createReadStream(localPath) const ws = sftp.createWriteStream(remotePath) rs.on('data', (chunk) => { uploaded += chunk.length if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('sftp:progress', { action: 'upload', filename: path.basename(localPath), transferred: uploaded, total: totalSize, }) } }) ws.on('close', () => { resolve({ ok: true, transferred: uploaded, total: totalSize }) }) ws.on('error', (err) => { resolve({ ok: false, message: err.message }) }) rs.on('error', (err) => { resolve({ ok: false, message: err.message }) }) rs.pipe(ws) }) }) }) // 下载文件(远程 → 本地) ipcMain.handle('sftp:download', async (event, { remotePath, localPath }) => { return runSftpTask(async () => { const sftp = await getSftp() const stat = await new Promise((resolve, reject) => { sftp.stat(remotePath, (err, stats) => { if (err) reject(err) else resolve(stats) }) }) const totalSize = stat.size let downloaded = 0 return await new Promise((resolve) => { const rs = sftp.createReadStream(remotePath) const ws = fs.createWriteStream(localPath) rs.on('data', (chunk) => { downloaded += chunk.length if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('sftp:progress', { action: 'download', filename: path.basename(remotePath), transferred: downloaded, total: totalSize, }) } }) ws.on('close', () => { resolve({ ok: true, transferred: downloaded, total: totalSize }) }) ws.on('error', (err) => { resolve({ ok: false, message: err.message }) }) rs.on('error', (err) => { resolve({ ok: false, message: err.message }) }) rs.pipe(ws) }) }) }) // 打开本地文件选择对话框(用于上传,支持多选) ipcMain.handle('sftp:selectLocalFile', async () => { const result = await dialog.showOpenDialog(mainWindow, { title: '选择要上传的文件', properties: ['openFile', 'multiSelections'], }) if (result.canceled || result.filePaths.length === 0) return { ok: false } return { ok: true, paths: result.filePaths } }) // 打开本地保存对话框(用于下载单个文件) ipcMain.handle('sftp:selectSavePath', async (event, { defaultName }) => { const result = await dialog.showSaveDialog(mainWindow, { title: '保存文件', defaultPath: defaultName || 'download', }) if (result.canceled) return { ok: false } return { ok: true, path: result.filePath } }) // 打开本地目录选择对话框(用于批量下载到同一目录) ipcMain.handle('sftp:selectDownloadDir', async () => { const result = await dialog.showOpenDialog(mainWindow, { title: '选择下载保存目录', properties: ['openDirectory'], }) if (result.canceled || result.filePaths.length === 0) return { ok: false } return { ok: true, path: result.filePaths[0] } }) // 创建远程目录 ipcMain.handle('sftp:mkdir', async (event, { remotePath }) => { return runSftpTask(async () => { const sftp = await getSftp() await new Promise((resolve, reject) => { sftp.mkdir(remotePath, (err) => { if (err) reject(err) else resolve() }) }) return { ok: true } }) }) // 删除远程文件或目录 // 统一用 exec rm -rf 命令删除,不走 SFTP,避免 channel 问题 ipcMain.handle('sftp:delete', async (event, { remotePath, isDirectory, recursive }) => { // 路径用单引号包裹防止特殊字符注入 const safePath = "'" + remotePath.replace(/'/g, "'\\''") + "'" // rm -rf 对目录递归删除,对文件直接删除 const result = await execSsh('rm -rf ' + safePath, 30000) if (!result.ok) return { ok: false, message: result.message || result.stderr || '删除失败' } if (result.code !== 0) return { ok: false, message: result.stderr || '删除失败' } return { ok: true } }) // ---------- IPC:交互式终端 (Shell with PTY) ---------- let activeShell = null // 当前活动的 SSH Shell 流 // 启动交互式 Shell ipcMain.handle('shell:start', async (event, { cols, rows }) => { try { if (!activeConnection) { return { ok: false, message: 'SSH 连接不存在' } } // 如果已有 Shell 在运行,先关闭 if (activeShell) { activeShell.end() activeShell = null } const shell = await new Promise((resolve, reject) => { activeConnection.shell( { cols: cols || 80, rows: rows || 24, term: 'xterm-256color', }, (err, stream) => { if (err) reject(err) else resolve(stream) } ) }) activeShell = shell // 接收服务器输出 → 转发给渲染进程 shell.on('data', (data) => { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('shell:data', data) } }) shell.stderr.on('data', (data) => { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('shell:data', data) } }) shell.on('close', () => { activeShell = null if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('shell:close') } }) return { ok: true } } catch (err) { return { ok: false, message: err.message } } }) // 渲染进程向 Shell 写入数据(键盘输入) ipcMain.handle('shell:write', async (event, { data }) => { if (activeShell && !activeShell.destroyed) { activeShell.write(data) return { ok: true } } return { ok: false, message: 'Shell 未连接' } }) // 调整终端窗口大小 ipcMain.handle('shell:resize', async (event, { cols, rows }) => { if (activeShell && !activeShell.destroyed) { activeShell.setWindow(rows, cols, 0, 0) return { ok: true } } return { ok: false, message: 'Shell 未连接' } }) // 关闭 Shell ipcMain.handle('shell:close', async () => { if (activeShell) { activeShell.end() activeShell = null } return { ok: true } }) // ---------- IPC:Docker 专用 SSH 连接 ---------- // Docker 连接在 ssh:connect 成功后自动建立,前端无需手动连接 // 这里只提供 disconnect 和 exec ipcMain.handle('docker:disconnect', () => { if (dockerConnection) { dockerConnection.end() dockerConnection = null } dockerSession = null return { ok: true } }) // Docker 专用串行队列(与主 execQueue 隔离) const dockerQueue = [] let dockerRunning = false function execDocker(cmd, timeoutMs = 15000) { return new Promise((resolve) => { dockerQueue.push({ cmd, timeoutMs, resolve }) drainDockerQueue() }) } function drainDockerQueue() { if (dockerRunning || dockerQueue.length === 0) return dockerRunning = true const { cmd, timeoutMs, resolve } = dockerQueue.shift() runDockerExec(cmd, timeoutMs, (result) => { resolve(result) dockerRunning = false drainDockerQueue() }) } function runDockerExec(cmd, timeoutMs, done) { if (!dockerConnection) { done({ ok: false, message: 'Docker SSH 连接不存在' }) return } let settled = false let stdout = '' let stderr = '' let streamRef = null const timer = setTimeout(() => { if (!settled) { settled = true // 销毁 stream 防止 channel 泄漏 if (streamRef) { try { streamRef.destroy() } catch (_) {} } done({ ok: false, message: '命令执行超时' }) } }, timeoutMs) dockerConnection.exec(cmd, (err, stream) => { if (err) { clearTimeout(timer) if (!settled) { settled = true done({ ok: false, message: err.message }) } return } streamRef = stream // 如果已经超时,立即销毁新拿到的 stream if (settled) { try { stream.destroy() } catch (_) {} return } stream.on('data', (chunk) => (stdout += chunk.toString())) stream.stderr.on('data', (chunk) => (stderr += chunk.toString())) stream.on('close', (code) => { clearTimeout(timer) if (!settled) { settled = true done({ ok: true, code, stdout, stderr }) } }) }) } ipcMain.handle('docker:exec', (event, { cmd, timeout }) => execDocker(cmd, timeout)) // ---------- IPC:应用商店一键部署(后台任务模式) ---------- // 使用 deployConnection(第 3 条 SSH 连接)在后台执行部署 // 前端通过 docker:listTasks / deploy:taskUpdate 获取进度 // 部署步骤:1.拉取镜像 2.创建目录 3.写入配置 4.创建容器 ipcMain.handle('docker:deployApp', async (event, { deploy, paramValues }) => { // 创建任务对象 const taskId = ++taskCounter const task = { id: taskId, appName: deploy.containerName || 'unknown', status: 'pending', steps: [], logs: [], startTime: new Date().toISOString(), error: null, containerId: null, } deployTasks.push(task) // 后台执行部署(不 await,立即返回 taskId) runDeployTask(task, deploy, paramValues || {}) return { ok: true, taskId } }) // 后台部署任务执行函数 async function runDeployTask(task, deploy, paramValues) { // ---- 参数占位符替换 ---- function tpl(str) { if (!str || typeof str !== 'string') return str let result = str for (const [key, val] of Object.entries(paramValues)) { result = result.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), val) } return result } // 特殊占位符处理 function tplWithDerived(str) { if (!str || typeof str !== 'string') return str let result = tpl(str) // Redis 密码行 const redisPwd = paramValues['REDIS_PASSWORD'] || '' result = result.replace(/\{\{REDIS_PASSWORD_LINE\}\}/g, redisPwd ? `requirepass ${redisPwd}` : '# requirepass (未设置密码)') // Halo 数据库参数:H2 模式为空,MySQL 模式拼装环境变量 const dbType = paramValues['DB_TYPE'] || 'h2' if (dbType === 'mysql') { const dbHost = paramValues['DB_HOST'] || '127.0.0.1' const dbPort = paramValues['DB_PORT'] || '3306' const dbName = paramValues['DB_NAME'] || 'halodb' const dbUser = paramValues['DB_USER'] || 'root' const dbPwd = paramValues['DB_PASSWORD'] || '' const r2dbcUrl = `r2dbc:pool:mysql://${dbHost}:${dbPort}/${dbName}` const dbArgs = [ `-e spring.r2dbc.url=${r2dbcUrl}`, `-e spring.r2dbc.username=${dbUser}`, `-e spring.r2dbc.password=${dbPwd}`, `-e spring.sql.init.platform=mysql`, ].join(' ') result = result.replace(/\{\{HALO_DB_ARGS\}\}/g, dbArgs) } else { result = result.replace(/\{\{HALO_DB_ARGS\}\}/g, '') } return result } task.status = 'running' sendTaskUpdate(task) try { // ---- Step 1: 拉取镜像 ---- const imageName = tpl(deploy.image) setTaskStep(task, 'pull', `正在拉取镜像 ${imageName}...`) // 拉取镜像可能耗时较长,超时设为 5 分钟 const pullResult = await execDeploy(`docker pull ${imageName}`, 300000) if (!pullResult.ok || (pullResult.code !== 0 && !pullResult.stdout.includes('Status: Image is up to date') && !pullResult.stdout.includes('Already exists'))) { const errMsg = pullResult.stderr || pullResult.message || '未知错误' setTaskStep(task, 'pull-error', `镜像拉取失败: ${errMsg}`) task.status = 'error' task.error = `镜像拉取失败: ${errMsg}` sendTaskUpdate(task) return } setTaskStep(task, 'pull-done', `镜像 ${imageName} 准备就绪`) // ---- Step 2: 创建宿主机目录 ---- if (deploy.mkdirs && deploy.mkdirs.length > 0) { for (const dir of deploy.mkdirs) { const dirPath = tpl(dir) setTaskStep(task, 'mkdir', `创建目录 ${dirPath}`) const mkdirResult = await execDeploy(`mkdir -p '${dirPath}'`, 10000) if (!mkdirResult.ok || mkdirResult.code !== 0) { setTaskStep(task, 'mkdir-error', `创建目录失败: ${dirPath}`) task.status = 'error' task.error = `创建目录失败: ${dirPath}` sendTaskUpdate(task) return } } setTaskStep(task, 'mkdir-done', '目录创建完成') } // ---- Step 3: 写入配置文件 ---- if (deploy.writeFiles && deploy.writeFiles.length > 0) { for (const file of deploy.writeFiles) { const filePath = tpl(file.path) const content = tplWithDerived(file.content) setTaskStep(task, 'writefile', `写入配置文件 ${filePath}`) // 用 heredoc 方式写入文件,避免转义问题 const writeCmd = `cat > '${filePath}' << 'APPSTORE_EOF'\n${content}\nAPPSTORE_EOF` const writeResult = await execDeploy(writeCmd, 10000) if (!writeResult.ok || writeResult.code !== 0) { setTaskStep(task, 'writefile-error', `写入配置文件失败: ${filePath}`) task.status = 'error' task.error = `写入配置文件失败: ${filePath}` sendTaskUpdate(task) return } } setTaskStep(task, 'writefile-done', '配置文件写入完成') } // ---- Step 4: 构建 docker run 命令 ---- const parts = ['docker', 'run', '-d'] const containerName = tpl(deploy.containerName) if (containerName) parts.push('--name', containerName) if (deploy.restart) parts.push('--restart', deploy.restart) if (deploy.hostname) parts.push('--hostname', deploy.hostname) if (deploy.workdir) parts.push('-w', deploy.workdir) if (deploy.network) parts.push('--network', deploy.network) // 端口映射 for (const port of (deploy.ports || [])) { const hostPort = tpl(port.host) if (!port.container) continue let p = hostPort ? `${hostPort}:` : '' p += port.container if (port.protocol && port.protocol !== 'tcp') p += '/' + port.protocol parts.push('-p', p) } // 挂载卷 for (const vol of (deploy.volumes || [])) { if (!vol.container) continue const hostPath = tpl(vol.host) let v = hostPath ? `${hostPath}:` : '' v += vol.container if (vol.mode && vol.mode !== 'rw') v += ':' + vol.mode parts.push('-v', v) } // 环境变量(值含空格时用引号包裹,防止 docker 解析错误) for (const env of (deploy.env || [])) { if (!env.key) continue const envValue = tpl(env.value) const pair = `${env.key}=${envValue}` if (envValue.includes(' ')) { parts.push('-e', `'${pair}'`) } else { parts.push('-e', pair) } } // 额外参数(如 Halo 数据库配置,在镜像名之前插入) // extraArgs 中的值(DB URL、用户名、密码等)不含空格,可直接 split if (deploy.extraArgs) { const extraArgs = tplWithDerived(deploy.extraArgs) if (extraArgs && extraArgs.trim()) { parts.push(...extraArgs.trim().split(/\s+/).filter(Boolean)) } } // 启动命令 const command = tpl(deploy.command) parts.push(imageName) if (command) { parts.push(...command.split(/\s+/).filter(Boolean)) } const runCmd = parts.join(' ') setTaskStep(task, 'run', `正在创建容器...`) addTaskLog(task, `执行命令: ${runCmd}`) const runResult = await execDeploy(runCmd, 60000) if (!runResult.ok || runResult.code !== 0) { const errMsg = runResult.stderr || runResult.message || '创建容器失败' setTaskStep(task, 'run-error', `容器创建失败: ${errMsg}`) task.status = 'error' task.error = `容器创建失败: ${errMsg}` sendTaskUpdate(task) return } const containerId = (runResult.stdout || '').trim().split('\n')[0] task.containerId = containerId setTaskStep(task, 'run-done', `容器创建成功!ID: ${containerId.slice(0, 12)}`) setTaskStep(task, 'done', '部署完成') task.status = 'done' sendTaskUpdate(task) } catch (err) { setTaskStep(task, 'run-error', `部署异常: ${err.message}`) task.status = 'error' task.error = `部署异常: ${err.message}` sendTaskUpdate(task) } } // ---------- IPC:检查 SSH 连接数 ---------- // 检查服务器当前的 SSH 连接数,用于部署前警告 ipcMain.handle('docker:checkConnections', async () => { // 用 docker 连接执行命令检查当前 SSH 连接数 const r = await execDocker("ss -tn state established '( sport = :22 )' 2>/dev/null | tail -n +2 | wc -l || netstat -tn 2>/dev/null | grep ':22 ' | grep ESTABLISHED | wc -l", 10000) if (!r.ok) { return { ok: false, message: r.message || '检查失败' } } const count = parseInt((r.stdout || '').trim()) || 0 return { ok: true, count } }) // ---------- IPC:列出所有部署任务 ---------- ipcMain.handle('docker:listTasks', async () => { return { ok: true, tasks: deployTasks.map((t) => ({ id: t.id, appName: t.appName, status: t.status, steps: t.steps, logs: t.logs, startTime: t.startTime, error: t.error, containerId: t.containerId, }))} }) // ---------- 在线应用商店 ---------- // API 地址持久化在 Electron userData 目录 const appstoreConfigPath = path.join(app.getPath('userData'), 'appstore-config.json') // 在线模板缓存路径 const appstoreCachePath = path.join(app.getPath('userData'), 'appstore-cache.json') // 默认 API 地址(可被用户覆盖) let appstoreApiUrl = 'http://114.66.55.63:3001/api/apps' // API 鉴权 token let appstoreApiToken = 'akaxedx' // 从配置文件加载 API 地址和 token try { const configRaw = fs.readFileSync(appstoreConfigPath, 'utf-8') const config = JSON.parse(configRaw) if (config.apiUrl) appstoreApiUrl = config.apiUrl if (config.apiToken !== undefined) appstoreApiToken = config.apiToken } catch (_) { // 首次运行,使用默认值 } /** * 发起 HTTP GET 请求(支持 http / https),附带 Authorization token */ function httpGet(urlStr, timeoutMs = 10000) { return new Promise((resolve) => { const lib = urlStr.startsWith('https') ? https : http const headers = {} if (appstoreApiToken) { headers['Authorization'] = `Bearer ${appstoreApiToken}` } const req = lib.get(urlStr, { timeout: timeoutMs, headers }, (res) => { let data = '' res.on('data', (chunk) => { data += chunk }) res.on('end', () => { resolve({ ok: res.statusCode === 200, statusCode: res.statusCode, body: data }) }) }) req.on('error', (err) => { resolve({ ok: false, error: err.message }) }) req.on('timeout', () => { req.destroy() resolve({ ok: false, error: '请求超时' }) }) }) } // ---------- IPC:获取/设置 API 地址 ---------- ipcMain.handle('appstore:getApiUrl', async () => { return { ok: true, apiUrl: appstoreApiUrl } }) ipcMain.handle('appstore:setApiUrl', async (_e, url) => { appstoreApiUrl = url try { fs.writeFileSync(appstoreConfigPath, JSON.stringify({ apiUrl: url }), 'utf-8') return { ok: true } } catch (err) { return { ok: false, error: err.message } } }) // ---------- IPC:同步在线模板 ---------- // 从远程 API 获取模板列表,写入本地缓存文件 ipcMain.handle('appstore:sync', async () => { const res = await httpGet(appstoreApiUrl, 15000) if (!res.ok) { return { ok: false, error: res.error || `HTTP ${res.statusCode}` } } try { const parsed = JSON.parse(res.body) if (!parsed.ok || !Array.isArray(parsed.data)) { return { ok: false, error: parsed.error || '返回数据格式错误' } } // 转换数据库格式 → 前端 deploy 格式 const templates = parsed.data.map(mapDbTemplateToFront) // 写入缓存 fs.writeFileSync(appstoreCachePath, JSON.stringify({ templates, syncedAt: new Date().toISOString(), }), 'utf-8') return { ok: true, templates, syncedAt: new Date().toISOString() } } catch (err) { return { ok: false, error: 'JSON 解析失败: ' + err.message } } }) // ---------- IPC:获取本地缓存模板 ---------- ipcMain.handle('appstore:getLocal', async () => { try { const raw = fs.readFileSync(appstoreCachePath, 'utf-8') const cache = JSON.parse(raw) return { ok: true, templates: cache.templates || [], syncedAt: cache.syncedAt || null } } catch (_) { return { ok: true, templates: [], syncedAt: null } } }) // ---------- IPC:检查客户端版本更新 ---------- // 客户端当前版本号 const CLIENT_VERSION = 'v0.0.1' ipcMain.handle('app:checkUpdate', async () => { // 版本检查接口地址(固定,基于 appstore API 同一服务器) const versionApiUrl = appstoreApiUrl.replace('/api/apps', '/api/version') const res = await httpGet(versionApiUrl, 10000) if (!res.ok) { return { ok: false, error: res.error || `HTTP ${res.statusCode}` } } try { const parsed = JSON.parse(res.body) if (!parsed.ok || !parsed.data) { return { ok: false, error: parsed.error || '返回数据格式错误' } } const latest = parsed.data const hasUpdate = latest.version !== CLIENT_VERSION return { ok: true, currentVersion: CLIENT_VERSION, latestVersion: latest.version, downloadUrl: latest.downloadUrl, releaseNotes: latest.releaseNotes || '', hasUpdate, } } catch (err) { return { ok: false, error: 'JSON 解析失败: ' + err.message } } }) // ---------- IPC:获取当前版本号 ---------- ipcMain.handle('app:getVersion', async () => { return { ok: true, version: CLIENT_VERSION } }) // ---------- IPC:打开下载地址(系统默认浏览器) ---------- ipcMain.handle('app:openUrl', async (_e, url) => { try { shell.openExternal(url) return { ok: true } } catch (err) { return { ok: false, error: err.message } } }) // ---------- IPC:检查已安装应用 ---------- // 通过 docker 连接执行 docker ps -a 获取已有容器列表 // 返回 { ok, installed: ['redis', 'mysql', ...] } ipcMain.handle('appstore:checkInstalled', async () => { if (!dockerConnection) { return { ok: false, error: 'Docker 未连接' } } const r = await execDocker('docker ps -a --format "{{.Names}}" 2>/dev/null', 10000) if (!r.ok) { return { ok: false, error: r.message || '检查失败' } } const names = (r.stdout || '').trim().split('\n').map((s) => s.trim()).filter(Boolean) return { ok: true, installed: names } }) /** * 将数据库行转换为前端 deploy 格式 * 数据库字段名 → 前端字段名映射 */ function mapDbTemplateToFront(row) { const tpl = { id: row.app_key, name: row.name, icon: row.icon || '', tagline: row.tagline || '', image: row.image, description: row.description || '', category: row.category || '', deploy: { image: row.image, containerName: row.container_name, ports: (row.ports || []).map((p) => ({ host: p.host_port, container: p.container_port, protocol: p.protocol || 'tcp', })), volumes: (row.volumes || []).map((v) => ({ host: v.host_path, container: v.container_path, mode: v.mode || 'rw', })), env: (row.env || []).map((e) => ({ key: e.env_key, value: e.env_value || '', })), network: row.network || '', restart: row.restart || 'always', hostname: row.hostname || '', workdir: row.workdir || '', command: row.command || '', mkdirs: (row.mkdirs || []).map((m) => m.dir_path), writeFiles: (row.writeFiles || []).map((f) => ({ path: f.file_path, content: f.file_content, })), params: (row.params || []).map((p) => { const param = { key: p.param_key, label: p.label, type: p.type || 'text', placeholder: p.placeholder || '', default: p.default_value || '', required: !!p.required, } // select 类型:从 DB options 列读取下拉选项 (JSON 字符串) if (param.type === 'select' && p.options) { try { param.options = JSON.parse(p.options) } catch { param.options = [] } } // 条件渲染:从 DB show_when 列读取,格式 "param_key:value" if (p.show_when) { const [whenParam, whenValue] = p.show_when.split(':') if (whenParam && whenValue !== undefined) { param.showWhen = { param: whenParam, equals: whenValue } } } return param }), // 额外 docker run 参数(含 {{}} 占位符,由 tplWithDerived 动态解析) extraArgs: row.extra_args || undefined, }, } // 清理 undefined 字段 if (!tpl.deploy.extraArgs) delete tpl.deploy.extraArgs return tpl } // ---------- 应用生命周期 ---------- app.whenReady().then(() => { createWindow() app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow() }) }) app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit() })