90bc4bd398
- SSH 服务器连接管理 + 登录页自动预填 - 服务器概览监控(CPU/内存/硬盘/网络/进程,进程卡片支持排序和端口显示) - 远程文件管理(浏览/上传/下载/新建/删除,实时进度条) - 交互式终端(xterm.js + ssh2 PTY) - Docker 容器管理(三连接架构,概览/容器列表/镜像/应用商店/任务中心/配置/网络/卷) - 应用商店一键部署(Redis/MySQL/Halo,在线模板同步,数据驱动 options/showWhen/extraArgs) - Docker 镜像源配置(daemon.json 编辑 + 自动重启) - Docker 强制停止修复(三步强制停止 + 轮询确认) - 系统防火墙管理(firewalld/ufw/iptables 自动检测 + GUI 管理) - 客户端版本检查(/api/version 接口 + 更新弹窗) - 品牌名统一为 WFJ Panel - electron-builder 打包配置(NSIS 安装包,国内镜像) - .npmrc 配置 Electron 国内镜像
1378 lines
41 KiB
JavaScript
1378 lines
41 KiB
JavaScript
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()
|
||
})
|