- 在 ElectronAPI 中新增对话框相关方法:showQuestionDialog、dialogResponse、resizeDialogWindow - 实现独立的对话框窗口 dialog.html,支持自定义标题、消息和选项- 添加对话框窗口的键盘导航和焦点管理功能 - 在主进程中实现对话框窗口的创建、显示和响应处理 - 更新 WebSocket 消息监听器组件,使用系统级对话框替代应用内弹窗- 优化进程清理逻辑,增加多种清理方法并行执行 - 重构日志工具类,改进 Electron API 调用方式 - 调整 Git 更新检查逻辑,避免直接访问 GitHub - 移除冗余的类型定义文件,统一 Electron API 接口定义
1309 lines
35 KiB
TypeScript
1309 lines
35 KiB
TypeScript
import {
|
||
app,
|
||
BrowserWindow,
|
||
dialog,
|
||
ipcMain,
|
||
Menu,
|
||
nativeImage,
|
||
screen,
|
||
shell,
|
||
Tray,
|
||
} from 'electron'
|
||
import * as path from 'path'
|
||
import * as fs from 'fs'
|
||
import { exec, spawn } from 'child_process'
|
||
import { checkEnvironment, getAppRoot } from './services/environmentService'
|
||
import { setMainWindow as setDownloadMainWindow } from './services/downloadService'
|
||
import {
|
||
downloadPython,
|
||
installDependencies,
|
||
installPipPackage,
|
||
setMainWindow as setPythonMainWindow,
|
||
startBackend,
|
||
} from './services/pythonService'
|
||
import { cloneBackend, downloadGit, setMainWindow as setGitMainWindow } from './services/gitService'
|
||
import { cleanOldLogs, getLogFiles, getLogPath, log, setupLogger } from './services/logService'
|
||
|
||
// 强制清理相关进程的函数
|
||
async function forceKillRelatedProcesses(): Promise<void> {
|
||
try {
|
||
const { killAllRelatedProcesses } = await import('./utils/processManager')
|
||
await killAllRelatedProcesses()
|
||
log.info('所有相关进程已清理')
|
||
} catch (error) {
|
||
log.error('清理进程时出错:', error)
|
||
|
||
// 备用清理方法
|
||
if (process.platform === 'win32') {
|
||
const appRoot = getAppRoot()
|
||
const pythonExePath = path.join(appRoot, 'environment', 'python', 'python.exe')
|
||
|
||
return new Promise(resolve => {
|
||
// 使用更简单的命令强制结束相关进程
|
||
exec(`taskkill /f /im python.exe`, error => {
|
||
if (error) {
|
||
log.warn('备用清理方法失败:', error.message)
|
||
} else {
|
||
log.info('备用清理方法执行成功')
|
||
}
|
||
resolve()
|
||
})
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
// 检查是否以管理员权限运行
|
||
function isRunningAsAdmin(): boolean {
|
||
try {
|
||
// 在Windows上,尝试写入系统目录来检查管理员权限
|
||
if (process.platform === 'win32') {
|
||
const testPath = path.join(process.env.WINDIR || 'C:\\Windows', 'temp', 'admin-test.tmp')
|
||
try {
|
||
fs.writeFileSync(testPath, 'test')
|
||
fs.unlinkSync(testPath)
|
||
return true
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
return true // 非Windows系统暂时返回true
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
// 重新以管理员权限启动应用
|
||
function restartAsAdmin(): void {
|
||
if (process.platform === 'win32') {
|
||
const exePath = process.execPath
|
||
const args = process.argv.slice(1)
|
||
|
||
// 使用PowerShell以管理员权限启动
|
||
spawn(
|
||
'powershell',
|
||
[
|
||
'-Command',
|
||
`Start-Process -FilePath "${exePath}" -ArgumentList "${args.join(' ')}" -Verb RunAs`,
|
||
],
|
||
{
|
||
detached: true,
|
||
stdio: 'ignore',
|
||
}
|
||
)
|
||
|
||
app.quit()
|
||
}
|
||
}
|
||
|
||
let tray: Tray | null = null
|
||
let isQuitting = false
|
||
let saveWindowStateTimeout: NodeJS.Timeout | null = null
|
||
|
||
// 配置接口
|
||
interface AppConfig {
|
||
UI: {
|
||
IfShowTray: boolean
|
||
IfToTray: boolean
|
||
location: string
|
||
maximized: boolean
|
||
size: string
|
||
}
|
||
Start: {
|
||
IfMinimizeDirectly: boolean
|
||
IfSelfStart: boolean
|
||
}
|
||
|
||
[key: string]: any
|
||
}
|
||
|
||
// 默认配置
|
||
const defaultConfig: AppConfig = {
|
||
UI: {
|
||
IfShowTray: false,
|
||
IfToTray: false,
|
||
location: '100,100',
|
||
maximized: false,
|
||
size: '1600,1000',
|
||
},
|
||
Start: {
|
||
IfMinimizeDirectly: false,
|
||
IfSelfStart: false,
|
||
},
|
||
}
|
||
|
||
// 加载配置
|
||
function loadConfig(): AppConfig {
|
||
try {
|
||
const appRoot = getAppRoot()
|
||
const configPath = path.join(appRoot, 'config', 'frontend_config.json')
|
||
|
||
if (fs.existsSync(configPath)) {
|
||
const configData = fs.readFileSync(configPath, 'utf8')
|
||
const config = JSON.parse(configData)
|
||
return { ...defaultConfig, ...config }
|
||
}
|
||
} catch (error) {
|
||
log.error('加载配置失败:', error)
|
||
}
|
||
return defaultConfig
|
||
}
|
||
|
||
// 保存配置
|
||
function saveConfig(config: AppConfig) {
|
||
try {
|
||
const appRoot = getAppRoot()
|
||
const configDir = path.join(appRoot, 'config')
|
||
const configPath = path.join(configDir, 'frontend_config.json')
|
||
|
||
if (!fs.existsSync(configDir)) {
|
||
fs.mkdirSync(configDir, { recursive: true })
|
||
}
|
||
|
||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8')
|
||
} catch (error) {
|
||
log.error('保存配置失败:', error)
|
||
}
|
||
}
|
||
|
||
// 创建托盘
|
||
function createTray() {
|
||
if (tray) return
|
||
|
||
// 尝试多个可能的图标路径
|
||
const iconPaths = [
|
||
path.join(__dirname, '../public/AUTO-MAS.ico'),
|
||
path.join(process.resourcesPath, 'assets/AUTO-MAS.ico'),
|
||
path.join(app.getAppPath(), 'public/AUTO-MAS.ico'),
|
||
path.join(app.getAppPath(), 'dist/AUTO-MAS.ico'),
|
||
]
|
||
|
||
let trayIcon
|
||
|
||
try {
|
||
// 尝试加载图标
|
||
for (const iconPath of iconPaths) {
|
||
if (fs.existsSync(iconPath)) {
|
||
trayIcon = nativeImage.createFromPath(iconPath)
|
||
if (!trayIcon.isEmpty()) {
|
||
log.info(`成功加载托盘图标: ${iconPath}`)
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
// 如果所有路径都失败,创建一个默认图标
|
||
if (!trayIcon || trayIcon.isEmpty()) {
|
||
log.warn('无法加载托盘图标,使用默认图标')
|
||
trayIcon = nativeImage.createEmpty()
|
||
}
|
||
} catch (error) {
|
||
log.error('加载托盘图标失败:', error)
|
||
trayIcon = nativeImage.createEmpty()
|
||
}
|
||
|
||
tray = new Tray(trayIcon)
|
||
|
||
const contextMenu = Menu.buildFromTemplate([
|
||
{
|
||
label: '显示窗口',
|
||
click: () => {
|
||
if (mainWindow) {
|
||
if (mainWindow.isMinimized()) {
|
||
mainWindow.restore()
|
||
}
|
||
mainWindow.setSkipTaskbar(false) // 恢复任务栏图标
|
||
mainWindow.show()
|
||
mainWindow.focus()
|
||
}
|
||
},
|
||
},
|
||
{
|
||
label: '隐藏窗口',
|
||
click: () => {
|
||
if (mainWindow) {
|
||
const currentConfig = loadConfig()
|
||
if (currentConfig.UI.IfToTray) {
|
||
mainWindow.setSkipTaskbar(true) // 隐藏任务栏图标
|
||
}
|
||
mainWindow.hide()
|
||
}
|
||
},
|
||
},
|
||
{ type: 'separator' },
|
||
{
|
||
label: '退出',
|
||
click: () => {
|
||
isQuitting = true
|
||
app.quit()
|
||
},
|
||
},
|
||
])
|
||
|
||
tray.setContextMenu(contextMenu)
|
||
tray.setToolTip('AUTO-MAS')
|
||
|
||
// 双击托盘图标显示/隐藏窗口
|
||
tray.on('double-click', () => {
|
||
if (mainWindow) {
|
||
const currentConfig = loadConfig()
|
||
if (mainWindow.isVisible()) {
|
||
if (currentConfig.UI.IfToTray) {
|
||
mainWindow.setSkipTaskbar(true) // 隐藏任务栏图标
|
||
}
|
||
mainWindow.hide()
|
||
} else {
|
||
if (mainWindow.isMinimized()) {
|
||
mainWindow.restore()
|
||
}
|
||
mainWindow.setSkipTaskbar(false) // 恢复任务栏图标
|
||
mainWindow.show()
|
||
mainWindow.focus()
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
// 销毁托盘
|
||
function destroyTray() {
|
||
if (tray) {
|
||
tray.destroy()
|
||
tray = null
|
||
}
|
||
}
|
||
|
||
// 更新托盘状态
|
||
function updateTrayVisibility(config: AppConfig) {
|
||
// 根据需求逻辑判断是否应该显示托盘
|
||
let shouldShowTray = false
|
||
|
||
if (config.UI.IfShowTray && config.UI.IfToTray) {
|
||
// 勾选常驻显示托盘和最小化到托盘,就一直展示托盘
|
||
shouldShowTray = true
|
||
} else if (config.UI.IfShowTray && !config.UI.IfToTray) {
|
||
// 勾选常驻显示托盘但没有最小化到托盘,就一直展示托盘
|
||
shouldShowTray = true
|
||
} else if (!config.UI.IfShowTray && config.UI.IfToTray) {
|
||
// 没有常驻显示托盘但勾选最小化到托盘,有窗口时就只有窗口,最小化后任务栏消失,只有托盘
|
||
shouldShowTray = !mainWindow || !mainWindow.isVisible()
|
||
} else {
|
||
// 没有常驻显示托盘也没有最小化到托盘,托盘一直不展示
|
||
shouldShowTray = false
|
||
}
|
||
|
||
// 特殊情况:如果没有窗口显示且没有托盘,强制显示托盘避免程序成为幽灵
|
||
if (!shouldShowTray && (!mainWindow || !mainWindow.isVisible()) && !tray) {
|
||
shouldShowTray = true
|
||
log.warn('防幽灵机制:强制显示托盘图标')
|
||
}
|
||
|
||
if (shouldShowTray && !tray) {
|
||
createTray()
|
||
log.info('托盘图标已创建')
|
||
} else if (!shouldShowTray && tray) {
|
||
destroyTray()
|
||
log.info('托盘图标已销毁')
|
||
}
|
||
}
|
||
|
||
let mainWindow: Electron.BrowserWindow | null = null
|
||
|
||
function createWindow() {
|
||
log.info('开始创建主窗口')
|
||
|
||
const config = loadConfig()
|
||
|
||
// 解析配置
|
||
const [cfgW, cfgH] = config.UI.size.split(',').map((s: string) => parseInt(s.trim(), 10) || 1600)
|
||
const [cfgX, cfgY] = config.UI.location
|
||
.split(',')
|
||
.map((s: string) => parseInt(s.trim(), 10) || 100)
|
||
|
||
// 以目标位置选最近显示器
|
||
const targetDisplay = screen.getDisplayNearestPoint({ x: cfgX, y: cfgY })
|
||
const sf = targetDisplay.scaleFactor
|
||
|
||
// 逻辑最小尺寸(DIP)
|
||
const minDipW = Math.floor(1600 / sf)
|
||
const minDipH = Math.floor(900 / sf)
|
||
|
||
// 初始窗口逻辑尺寸(DIP)
|
||
let initW = Math.max(cfgW, minDipW)
|
||
let initH = Math.max(cfgH, minDipH)
|
||
|
||
// 不超过工作区
|
||
const { width: waW, height: waH } = targetDisplay.workAreaSize
|
||
initW = Math.min(initW, waW)
|
||
initH = Math.min(initH, waH)
|
||
|
||
// 关键:用局部常量 win,全程用它,类型不为 null
|
||
const win = new BrowserWindow({
|
||
x: cfgX,
|
||
y: cfgY,
|
||
width: initW,
|
||
height: initH,
|
||
minWidth: minDipW,
|
||
minHeight: minDipH,
|
||
useContentSize: true,
|
||
frame: false,
|
||
titleBarStyle: 'hidden',
|
||
icon: path.join(__dirname, '../public/AUTO-MAS.ico'),
|
||
autoHideMenuBar: true,
|
||
show: !config.Start.IfMinimizeDirectly,
|
||
webPreferences: {
|
||
preload: path.join(__dirname, 'preload.js'),
|
||
nodeIntegration: false,
|
||
contextIsolation: true,
|
||
},
|
||
})
|
||
|
||
// 把局部的 win 赋值给模块级(供其他模块/函数用)
|
||
mainWindow = win
|
||
|
||
// 根据显示器动态更新最小尺寸/边界
|
||
const recomputeMinSize = () => {
|
||
// 这里用 win,不会是 null
|
||
const bounds = win.getBounds()
|
||
const disp = screen.getDisplayMatching(bounds)
|
||
const s = disp.scaleFactor
|
||
const w = Math.floor(1600 / s)
|
||
const h = Math.floor(900 / s)
|
||
|
||
const [curMinW, curMinH] = win.getMinimumSize()
|
||
if (w !== curMinW || h !== curMinH) {
|
||
win.setMinimumSize(w, h)
|
||
|
||
if (win.isMaximized()) return
|
||
|
||
const { width: wW, height: wH } = disp.workAreaSize
|
||
const newBounds = { ...bounds }
|
||
if (newBounds.width > wW) newBounds.width = wW
|
||
if (newBounds.height > wH) newBounds.height = wH
|
||
if (newBounds.width < w) newBounds.width = w
|
||
if (newBounds.height < h) newBounds.height = h
|
||
win.setBounds(newBounds)
|
||
}
|
||
}
|
||
|
||
// 监听显示器变化/窗口移动
|
||
win.on('moved', recomputeMinSize)
|
||
win.on('resized', recomputeMinSize)
|
||
screen.on('display-metrics-changed', recomputeMinSize)
|
||
|
||
// 最大化配置
|
||
if (config.UI.maximized) {
|
||
win.maximize()
|
||
}
|
||
|
||
win.setMenuBarVisibility(false)
|
||
const devServer = process.env.VITE_DEV_SERVER_URL
|
||
if (devServer) {
|
||
log.info(`加载开发服务器: ${devServer}`)
|
||
win.loadURL(devServer)
|
||
} else {
|
||
const indexHtmlPath = path.join(app.getAppPath(), 'dist', 'index.html')
|
||
log.info(`加载生产环境页面: ${indexHtmlPath}`)
|
||
win.loadFile(indexHtmlPath)
|
||
}
|
||
|
||
// 窗口事件处理
|
||
win.on('close', (event: Electron.Event) => {
|
||
const currentConfig = loadConfig()
|
||
|
||
if (!isQuitting && currentConfig.UI.IfToTray) {
|
||
event.preventDefault()
|
||
win.hide()
|
||
win.setSkipTaskbar(true)
|
||
updateTrayVisibility(currentConfig)
|
||
log.info('窗口已最小化到托盘,任务栏图标已隐藏')
|
||
} else {
|
||
// 立即保存窗口状态,不使用防抖
|
||
if (!win.isDestroyed()) {
|
||
try {
|
||
const config = loadConfig()
|
||
const bounds = win.getBounds()
|
||
const isMaximized = win.isMaximized()
|
||
|
||
if (!isMaximized) {
|
||
config.UI.size = `${bounds.width},${bounds.height}`
|
||
config.UI.location = `${bounds.x},${bounds.y}`
|
||
}
|
||
config.UI.maximized = isMaximized
|
||
|
||
saveConfig(config)
|
||
log.info('窗口状态已保存')
|
||
} catch (error) {
|
||
log.error('保存窗口状态失败:', error)
|
||
}
|
||
}
|
||
}
|
||
})
|
||
|
||
win.on('closed', () => {
|
||
log.info('主窗口已关闭')
|
||
// 清理监听(可选)
|
||
screen.removeListener('display-metrics-changed', recomputeMinSize)
|
||
// 置空模块级引用
|
||
mainWindow = null
|
||
|
||
// 如果是正在退出,立即执行进程清理
|
||
if (isQuitting) {
|
||
log.info('窗口关闭,执行最终清理')
|
||
setTimeout(async () => {
|
||
try {
|
||
await forceKillRelatedProcesses()
|
||
} catch (e) {
|
||
log.error('最终清理失败:', e)
|
||
}
|
||
process.exit(0)
|
||
}, 100)
|
||
}
|
||
})
|
||
|
||
win.on('minimize', () => {
|
||
const currentConfig = loadConfig()
|
||
if (currentConfig.UI.IfToTray) {
|
||
win.hide()
|
||
win.setSkipTaskbar(true)
|
||
updateTrayVisibility(currentConfig)
|
||
log.info('窗口已最小化到托盘,任务栏图标已隐藏')
|
||
}
|
||
})
|
||
|
||
win.on('show', () => {
|
||
const currentConfig = loadConfig()
|
||
win.setSkipTaskbar(false)
|
||
updateTrayVisibility(currentConfig)
|
||
log.info('窗口已显示,任务栏图标已恢复')
|
||
})
|
||
|
||
win.on('hide', () => {
|
||
const currentConfig = loadConfig()
|
||
if (currentConfig.UI.IfToTray) {
|
||
win.setSkipTaskbar(true)
|
||
log.info('窗口已隐藏,任务栏图标已隐藏')
|
||
}
|
||
updateTrayVisibility(currentConfig)
|
||
})
|
||
|
||
// 移动/调整大小/最大化状态变化时保存
|
||
win.on('moved', saveWindowState)
|
||
win.on('resized', saveWindowState)
|
||
win.on('maximize', saveWindowState)
|
||
win.on('unmaximize', saveWindowState)
|
||
|
||
// 设置各个服务的主窗口引用(此处 win 一定存在,可直接传)
|
||
setDownloadMainWindow(win)
|
||
setPythonMainWindow(win)
|
||
setGitMainWindow(win)
|
||
log.info('主窗口创建完成,服务引用已设置')
|
||
|
||
// 根据配置初始化托盘
|
||
updateTrayVisibility(config)
|
||
}
|
||
|
||
// 保存窗口状态(带防抖)
|
||
function saveWindowState() {
|
||
if (!mainWindow || mainWindow.isDestroyed()) return
|
||
|
||
// 清除之前的定时器
|
||
if (saveWindowStateTimeout) {
|
||
clearTimeout(saveWindowStateTimeout)
|
||
}
|
||
|
||
// 设置新的定时器,500ms后保存
|
||
saveWindowStateTimeout = setTimeout(() => {
|
||
try {
|
||
// 再次检查窗口是否存在且未销毁
|
||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||
log.warn('窗口已销毁,跳过保存状态')
|
||
return
|
||
}
|
||
|
||
const config = loadConfig()
|
||
const bounds = mainWindow.getBounds()
|
||
const isMaximized = mainWindow.isMaximized()
|
||
|
||
// 只有在窗口不是最大化状态时才保存位置和大小
|
||
if (!isMaximized) {
|
||
config.UI.size = `${bounds.width},${bounds.height}`
|
||
config.UI.location = `${bounds.x},${bounds.y}`
|
||
}
|
||
config.UI.maximized = isMaximized
|
||
|
||
saveConfig(config)
|
||
log.info('窗口状态已保存')
|
||
} catch (error) {
|
||
log.error('保存窗口状态失败:', error)
|
||
}
|
||
}, 500)
|
||
}
|
||
|
||
// IPC处理函数
|
||
ipcMain.handle('open-dev-tools', () => {
|
||
if (mainWindow) {
|
||
mainWindow.webContents.openDevTools({ mode: 'undocked' })
|
||
}
|
||
})
|
||
|
||
// 窗口控制
|
||
ipcMain.handle('window-minimize', () => {
|
||
if (mainWindow) {
|
||
mainWindow.minimize()
|
||
}
|
||
})
|
||
|
||
ipcMain.handle('window-maximize', () => {
|
||
if (mainWindow) {
|
||
if (mainWindow.isMaximized()) {
|
||
mainWindow.unmaximize()
|
||
} else {
|
||
mainWindow.maximize()
|
||
}
|
||
}
|
||
})
|
||
|
||
ipcMain.handle('window-close', () => {
|
||
if (mainWindow) {
|
||
isQuitting = true
|
||
mainWindow.close()
|
||
}
|
||
})
|
||
|
||
// 添加强制退出处理器
|
||
ipcMain.handle('app-quit', () => {
|
||
isQuitting = true
|
||
app.quit()
|
||
})
|
||
|
||
// 添加进程管理相关的 IPC 处理器
|
||
ipcMain.handle('get-related-processes', async () => {
|
||
try {
|
||
const { getRelatedProcesses } = await import('./utils/processManager')
|
||
return await getRelatedProcesses()
|
||
} catch (error) {
|
||
log.error('获取进程信息失败:', error)
|
||
return []
|
||
}
|
||
})
|
||
|
||
ipcMain.handle('kill-all-processes', async () => {
|
||
try {
|
||
await forceKillRelatedProcesses()
|
||
return { success: true }
|
||
} catch (error) {
|
||
log.error('强制清理进程失败:', error)
|
||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||
}
|
||
})
|
||
|
||
// 添加一个测试用的强制退出命令
|
||
ipcMain.handle('force-exit', async () => {
|
||
log.info('收到强制退出命令')
|
||
isQuitting = true
|
||
|
||
// 立即清理进程
|
||
try {
|
||
await forceKillRelatedProcesses()
|
||
} catch (e) {
|
||
log.error('强制清理失败:', e)
|
||
}
|
||
|
||
// 强制退出
|
||
setTimeout(() => {
|
||
process.exit(0)
|
||
}, 500)
|
||
|
||
return { success: true }
|
||
})
|
||
|
||
ipcMain.handle('window-is-maximized', () => {
|
||
return mainWindow ? mainWindow.isMaximized() : false
|
||
})
|
||
|
||
ipcMain.handle('select-folder', async () => {
|
||
if (!mainWindow) return null
|
||
const result = await dialog.showOpenDialog(mainWindow, {
|
||
properties: ['openDirectory'],
|
||
title: '选择文件夹',
|
||
})
|
||
return result.canceled ? null : result.filePaths[0]
|
||
})
|
||
|
||
ipcMain.handle('select-file', async (event, filters = []) => {
|
||
if (!mainWindow) return []
|
||
const result = await dialog.showOpenDialog(mainWindow, {
|
||
properties: ['openFile'],
|
||
title: '选择文件',
|
||
filters: filters.length > 0 ? filters : [{ name: '所有文件', extensions: ['*'] }],
|
||
})
|
||
return result.canceled ? [] : result.filePaths
|
||
})
|
||
|
||
// 在系统默认浏览器中打开URL
|
||
ipcMain.handle('open-url', async (_event, url: string) => {
|
||
try {
|
||
await shell.openExternal(url)
|
||
return { success: true }
|
||
} catch (error) {
|
||
if (error instanceof Error) {
|
||
console.error('打开链接失败:', error.message)
|
||
return { success: false, error: error.message }
|
||
} else {
|
||
console.error('未知错误:', error)
|
||
return { success: false, error: String(error) }
|
||
}
|
||
}
|
||
})
|
||
|
||
// 打开文件
|
||
ipcMain.handle('open-file', async (_event, filePath: string) => {
|
||
try {
|
||
await shell.openPath(filePath)
|
||
} catch (error) {
|
||
console.error('打开文件失败:', error)
|
||
throw error
|
||
}
|
||
})
|
||
|
||
// 显示文件所在目录并选中文件
|
||
ipcMain.handle('show-item-in-folder', async (_event, filePath: string) => {
|
||
try {
|
||
shell.showItemInFolder(filePath)
|
||
} catch (error) {
|
||
console.error('显示文件所在目录失败:', error)
|
||
throw error
|
||
}
|
||
})
|
||
|
||
// 环境检查
|
||
ipcMain.handle('check-environment', async () => {
|
||
const appRoot = getAppRoot()
|
||
return checkEnvironment(appRoot)
|
||
})
|
||
|
||
// 关键文件检查 - 每次都重新检查exe文件是否存在
|
||
ipcMain.handle('check-critical-files', async () => {
|
||
try {
|
||
const appRoot = getAppRoot()
|
||
|
||
// 检查Python可执行文件
|
||
const pythonPath = path.join(appRoot, 'environment', 'python', 'python.exe')
|
||
const pythonExists = fs.existsSync(pythonPath)
|
||
|
||
// 检查pip(通常与Python一起安装)
|
||
const pipPath = path.join(appRoot, 'environment', 'python', 'Scripts', 'pip.exe')
|
||
const pipExists = fs.existsSync(pipPath)
|
||
|
||
// 检查Git可执行文件
|
||
const gitPath = path.join(appRoot, 'environment', 'git', 'bin', 'git.exe')
|
||
const gitExists = fs.existsSync(gitPath)
|
||
|
||
// 检查后端主文件
|
||
const mainPyPath = path.join(appRoot, 'main.py')
|
||
const mainPyExists = fs.existsSync(mainPyPath)
|
||
|
||
const result = {
|
||
pythonExists,
|
||
pipExists,
|
||
gitExists,
|
||
mainPyExists,
|
||
}
|
||
|
||
log.info('关键文件检查结果:', result)
|
||
return result
|
||
} catch (error) {
|
||
log.error('检查关键文件失败:', error)
|
||
return {
|
||
pythonExists: false,
|
||
pipExists: false,
|
||
gitExists: false,
|
||
mainPyExists: false,
|
||
}
|
||
}
|
||
})
|
||
|
||
// Python相关
|
||
ipcMain.handle('download-python', async (_event, mirror = 'tsinghua') => {
|
||
const appRoot = getAppRoot()
|
||
return downloadPython(appRoot, mirror)
|
||
})
|
||
|
||
ipcMain.handle('install-pip', async () => {
|
||
const appRoot = getAppRoot()
|
||
return installPipPackage(appRoot)
|
||
})
|
||
|
||
ipcMain.handle('install-dependencies', async (_event, mirror = 'tsinghua') => {
|
||
const appRoot = getAppRoot()
|
||
return installDependencies(appRoot, mirror)
|
||
})
|
||
|
||
ipcMain.handle('start-backend', async () => {
|
||
const appRoot = getAppRoot()
|
||
return startBackend(appRoot)
|
||
})
|
||
|
||
ipcMain.handle('stop-backend', async () => {
|
||
const { stopBackend } = await import('./services/pythonService')
|
||
return stopBackend()
|
||
})
|
||
|
||
// 全局存储对话框窗口引用和回调
|
||
let dialogWindows = new Map<string, BrowserWindow>()
|
||
let dialogCallbacks = new Map<string, (result: boolean) => void>()
|
||
|
||
// 创建对话框窗口
|
||
function createQuestionDialog(questionData: any): Promise<boolean> {
|
||
return new Promise((resolve) => {
|
||
const messageId = questionData.messageId || 'dialog_' + Date.now()
|
||
|
||
// 存储回调函数
|
||
dialogCallbacks.set(messageId, resolve)
|
||
|
||
// 准备对话框数据
|
||
const dialogData = {
|
||
title: questionData.title || '操作确认',
|
||
message: questionData.message || '是否要执行此操作?',
|
||
options: questionData.options || ['确定', '取消'],
|
||
messageId: messageId
|
||
}
|
||
|
||
// 创建对话框窗口
|
||
const dialogWindow = new BrowserWindow({
|
||
width: 450,
|
||
height: 200,
|
||
minWidth: 350,
|
||
minHeight: 150,
|
||
maxWidth: 600,
|
||
maxHeight: 400,
|
||
resizable: true,
|
||
minimizable: false,
|
||
maximizable: false,
|
||
alwaysOnTop: true,
|
||
show: false,
|
||
frame: false,
|
||
modal: mainWindow ? true : false,
|
||
parent: mainWindow || undefined,
|
||
icon: path.join(__dirname, '../public/AUTO-MAS.ico'),
|
||
webPreferences: {
|
||
nodeIntegration: false,
|
||
contextIsolation: true,
|
||
preload: path.join(__dirname, 'preload.js'),
|
||
},
|
||
})
|
||
|
||
// 存储窗口引用
|
||
dialogWindows.set(messageId, dialogWindow)
|
||
|
||
// 编码对话框数据
|
||
const encodedData = encodeURIComponent(JSON.stringify(dialogData))
|
||
|
||
// 加载对话框页面
|
||
const dialogUrl = `file://${path.join(__dirname, '../public/dialog.html')}?data=${encodedData}`
|
||
dialogWindow.loadURL(dialogUrl)
|
||
|
||
// 窗口准备好后显示并居中
|
||
dialogWindow.once('ready-to-show', () => {
|
||
// 计算居中位置
|
||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||
const mainBounds = mainWindow.getBounds()
|
||
const dialogBounds = dialogWindow.getBounds()
|
||
const x = Math.round(mainBounds.x + (mainBounds.width - dialogBounds.width) / 2)
|
||
const y = Math.round(mainBounds.y + (mainBounds.height - dialogBounds.height) / 2)
|
||
dialogWindow.setPosition(x, y)
|
||
} else {
|
||
dialogWindow.center()
|
||
}
|
||
|
||
dialogWindow.show()
|
||
dialogWindow.focus()
|
||
})
|
||
|
||
// 窗口关闭时清理
|
||
dialogWindow.on('closed', () => {
|
||
dialogWindows.delete(messageId)
|
||
const callback = dialogCallbacks.get(messageId)
|
||
if (callback) {
|
||
dialogCallbacks.delete(messageId)
|
||
callback(false) // 默认返回 false (取消)
|
||
}
|
||
})
|
||
|
||
log.info(`对话框窗口已创建: ${messageId}`)
|
||
})
|
||
}
|
||
|
||
// 显示问题对话框
|
||
ipcMain.handle('show-question-dialog', async (_event, questionData) => {
|
||
log.info('收到显示对话框请求:', questionData)
|
||
try {
|
||
const result = await createQuestionDialog(questionData)
|
||
log.info(`对话框结果: ${result}`)
|
||
return result
|
||
} catch (error) {
|
||
log.error('创建对话框失败:', error)
|
||
return false
|
||
}
|
||
})
|
||
|
||
// 处理对话框响应
|
||
ipcMain.handle('dialog-response', async (_event, messageId: string, choice: boolean) => {
|
||
log.info(`收到对话框响应: ${messageId} = ${choice}`)
|
||
|
||
const callback = dialogCallbacks.get(messageId)
|
||
if (callback) {
|
||
dialogCallbacks.delete(messageId)
|
||
callback(choice)
|
||
}
|
||
|
||
// 关闭对话框窗口
|
||
const dialogWindow = dialogWindows.get(messageId)
|
||
if (dialogWindow && !dialogWindow.isDestroyed()) {
|
||
dialogWindow.close()
|
||
}
|
||
dialogWindows.delete(messageId)
|
||
|
||
return true
|
||
})
|
||
|
||
// 调整对话框窗口大小
|
||
ipcMain.handle('resize-dialog-window', async (_event, height: number) => {
|
||
// 获取当前活动的对话框窗口(最后创建的)
|
||
const dialogWindow = Array.from(dialogWindows.values()).pop()
|
||
if (dialogWindow && !dialogWindow.isDestroyed()) {
|
||
const bounds = dialogWindow.getBounds()
|
||
dialogWindow.setBounds({
|
||
...bounds,
|
||
height: Math.max(150, Math.min(400, height))
|
||
})
|
||
}
|
||
})
|
||
|
||
// Git相关
|
||
ipcMain.handle('download-git', async () => {
|
||
const appRoot = getAppRoot()
|
||
return downloadGit(appRoot)
|
||
})
|
||
|
||
ipcMain.handle('check-git-update', async () => {
|
||
try {
|
||
const appRoot = getAppRoot()
|
||
|
||
// 检查是否为Git仓库
|
||
const gitDir = path.join(appRoot, '.git')
|
||
if (!fs.existsSync(gitDir)) {
|
||
log.info('不是Git仓库,跳过更新检查')
|
||
return { hasUpdate: false }
|
||
}
|
||
|
||
// 检查Git可执行文件是否存在
|
||
const gitPath = path.join(appRoot, 'environment', 'git', 'bin', 'git.exe')
|
||
if (!fs.existsSync(gitPath)) {
|
||
log.warn('Git可执行文件不存在,无法检查更新')
|
||
return { hasUpdate: false, error: 'Git可执行文件不存在' }
|
||
}
|
||
|
||
// 获取Git环境变量
|
||
const gitEnv = {
|
||
...process.env,
|
||
PATH: `${path.join(appRoot, 'environment', 'git', 'bin')};${path.join(appRoot, 'environment', 'git', 'mingw64', 'bin')};${process.env.PATH}`,
|
||
GIT_EXEC_PATH: path.join(appRoot, 'environment', 'git', 'mingw64', 'libexec', 'git-core'),
|
||
HOME: process.env.USERPROFILE || process.env.HOME,
|
||
GIT_CONFIG_NOSYSTEM: '1',
|
||
GIT_TERMINAL_PROMPT: '0',
|
||
GIT_ASKPASS: '',
|
||
}
|
||
|
||
log.info('开始检查Git仓库更新(跳过fetch,避免直接访问GitHub)...')
|
||
|
||
// 不执行fetch,直接检查本地状态
|
||
// 这样避免了直接访问GitHub,而是在后续的pull操作中使用镜像站
|
||
|
||
// 获取当前HEAD的commit hash
|
||
const currentCommit = await new Promise<string>((resolve, reject) => {
|
||
const revParseProc = spawn(gitPath, ['rev-parse', 'HEAD'], {
|
||
stdio: 'pipe',
|
||
env: gitEnv,
|
||
cwd: appRoot,
|
||
})
|
||
|
||
let output = ''
|
||
revParseProc.stdout?.on('data', data => {
|
||
output += data.toString()
|
||
})
|
||
|
||
revParseProc.on('close', code => {
|
||
if (code === 0) {
|
||
resolve(output.trim())
|
||
} else {
|
||
reject(new Error(`git rev-parse失败,退出码: ${code}`))
|
||
}
|
||
})
|
||
|
||
revParseProc.on('error', reject)
|
||
})
|
||
|
||
log.info(`当前本地commit: ${currentCommit}`)
|
||
|
||
// 由于我们跳过了fetch步骤(避免直接访问GitHub),
|
||
// 我们无法准确知道远程是否有更新
|
||
// 因此返回true,让后续的pull操作通过镜像站来检查和获取更新
|
||
// 如果没有更新,pull操作会很快完成且不会有实际变化
|
||
log.info('跳过远程检查,返回hasUpdate=true以触发镜像站更新流程')
|
||
return { hasUpdate: true, skipReason: 'avoided_github_access' }
|
||
} catch (error) {
|
||
log.error('检查Git更新失败:', error)
|
||
// 如果检查失败,返回true以触发更新流程,确保代码是最新的
|
||
return { hasUpdate: true, error: error instanceof Error ? error.message : String(error) }
|
||
}
|
||
})
|
||
|
||
ipcMain.handle(
|
||
'clone-backend',
|
||
async (_event, repoUrl = 'https://github.com/AUTO-MAS-Project/AUTO-MAS.git') => {
|
||
const appRoot = getAppRoot()
|
||
return cloneBackend(appRoot, repoUrl)
|
||
}
|
||
)
|
||
|
||
ipcMain.handle(
|
||
'update-backend',
|
||
async (_event, repoUrl = 'https://github.com/AUTO-MAS-Project/AUTO-MAS.git') => {
|
||
const appRoot = getAppRoot()
|
||
return cloneBackend(appRoot, repoUrl) // 使用相同的逻辑,会自动判断是pull还是clone
|
||
}
|
||
)
|
||
|
||
// 配置文件操作
|
||
ipcMain.handle('save-config', async (_event, config) => {
|
||
try {
|
||
const appRoot = getAppRoot()
|
||
const configDir = path.join(appRoot, 'config')
|
||
const configPath = path.join(configDir, 'frontend_config.json')
|
||
|
||
// 确保config目录存在
|
||
if (!fs.existsSync(configDir)) {
|
||
fs.mkdirSync(configDir, { recursive: true })
|
||
}
|
||
|
||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8')
|
||
console.log(`配置已保存到: ${configPath}`)
|
||
|
||
// 如果是UI配置更新,需要更新托盘状态
|
||
if (config.UI) {
|
||
updateTrayVisibility(config)
|
||
}
|
||
} catch (error) {
|
||
console.error('保存配置文件失败:', error)
|
||
throw error
|
||
}
|
||
})
|
||
|
||
// 新增:实时更新托盘状态的IPC处理器
|
||
ipcMain.handle('update-tray-settings', async (_event, uiSettings) => {
|
||
try {
|
||
// 先更新配置文件
|
||
const currentConfig = loadConfig()
|
||
currentConfig.UI = { ...currentConfig.UI, ...uiSettings }
|
||
saveConfig(currentConfig)
|
||
|
||
// 立即更新托盘状态
|
||
updateTrayVisibility(currentConfig)
|
||
|
||
log.info('托盘设置已更新:', uiSettings)
|
||
return true
|
||
} catch (error) {
|
||
log.error('更新托盘设置失败:', error)
|
||
throw error
|
||
}
|
||
})
|
||
|
||
ipcMain.handle('load-config', async () => {
|
||
try {
|
||
const appRoot = getAppRoot()
|
||
const configPath = path.join(appRoot, 'config', 'frontend_config.json')
|
||
|
||
if (fs.existsSync(configPath)) {
|
||
const config = fs.readFileSync(configPath, 'utf8')
|
||
console.log(`从文件加载配置: ${configPath}`)
|
||
return JSON.parse(config)
|
||
}
|
||
|
||
return null
|
||
} catch (error) {
|
||
console.error('加载配置文件失败:', error)
|
||
return null
|
||
}
|
||
})
|
||
|
||
ipcMain.handle('reset-config', async () => {
|
||
try {
|
||
const appRoot = getAppRoot()
|
||
const configPath = path.join(appRoot, 'config', 'frontend_config.json')
|
||
|
||
if (fs.existsSync(configPath)) {
|
||
fs.unlinkSync(configPath)
|
||
console.log(`配置文件已删除: ${configPath}`)
|
||
}
|
||
} catch (error) {
|
||
console.error('重置配置文件失败:', error)
|
||
throw error
|
||
}
|
||
})
|
||
|
||
// 日志文件操作
|
||
ipcMain.handle('get-log-path', async () => {
|
||
try {
|
||
return getLogPath()
|
||
} catch (error) {
|
||
log.error('获取日志路径失败:', error)
|
||
throw error
|
||
}
|
||
})
|
||
|
||
ipcMain.handle('get-log-files', async _event => {
|
||
try {
|
||
return getLogFiles()
|
||
} catch (error) {
|
||
log.error('获取日志文件列表失败:', error)
|
||
throw error
|
||
}
|
||
})
|
||
|
||
ipcMain.handle('get-logs', async (_event, lines?: number, fileName?: string) => {
|
||
try {
|
||
let logFilePath: string
|
||
|
||
if (fileName) {
|
||
// 如果指定了文件名,使用指定的文件
|
||
const appRoot = getAppRoot()
|
||
logFilePath = path.join(appRoot, 'logs', fileName)
|
||
} else {
|
||
// 否则使用当前日志文件
|
||
logFilePath = getLogPath()
|
||
}
|
||
|
||
if (!fs.existsSync(logFilePath)) {
|
||
return ''
|
||
}
|
||
|
||
const logs = fs.readFileSync(logFilePath, 'utf8')
|
||
|
||
if (lines && lines > 0) {
|
||
const logLines = logs.split('\n')
|
||
return logLines.slice(-lines).join('\n')
|
||
}
|
||
|
||
return logs
|
||
} catch (error) {
|
||
log.error('读取日志文件失败:', error)
|
||
throw error
|
||
}
|
||
})
|
||
|
||
ipcMain.handle('clear-logs', async (_event, fileName?: string) => {
|
||
try {
|
||
let logFilePath: string
|
||
|
||
if (fileName) {
|
||
// 如果指定了文件名,清空指定的文件
|
||
const appRoot = getAppRoot()
|
||
logFilePath = path.join(appRoot, 'logs', fileName)
|
||
} else {
|
||
// 否则清空当前日志文件
|
||
logFilePath = getLogPath()
|
||
}
|
||
|
||
if (fs.existsSync(logFilePath)) {
|
||
fs.writeFileSync(logFilePath, '', 'utf8')
|
||
log.info(`日志文件已清空: ${fileName || '当前文件'}`)
|
||
}
|
||
} catch (error) {
|
||
log.error('清空日志文件失败:', error)
|
||
throw error
|
||
}
|
||
})
|
||
|
||
ipcMain.handle('clean-old-logs', async (_event, daysToKeep = 7) => {
|
||
try {
|
||
cleanOldLogs(daysToKeep)
|
||
log.info(`已清理${daysToKeep}天前的旧日志文件`)
|
||
} catch (error) {
|
||
log.error('清理旧日志文件失败:', error)
|
||
throw error
|
||
}
|
||
})
|
||
|
||
// 保留原有的日志操作方法以兼容现有代码
|
||
ipcMain.handle('save-logs-to-file', async (_event, logs: string) => {
|
||
try {
|
||
const appRoot = getAppRoot()
|
||
const logsDir = path.join(appRoot, 'logs')
|
||
|
||
// 确保logs目录存在
|
||
if (!fs.existsSync(logsDir)) {
|
||
fs.mkdirSync(logsDir, { recursive: true })
|
||
}
|
||
|
||
const logFilePath = path.join(logsDir, 'app.log')
|
||
fs.writeFileSync(logFilePath, logs, 'utf8')
|
||
log.info(`日志已保存到: ${logFilePath}`)
|
||
} catch (error) {
|
||
log.error('保存日志文件失败:', error)
|
||
throw error
|
||
}
|
||
})
|
||
|
||
ipcMain.handle('load-logs-from-file', async () => {
|
||
try {
|
||
const appRoot = getAppRoot()
|
||
const logFilePath = path.join(appRoot, 'logs', 'app.log')
|
||
|
||
if (fs.existsSync(logFilePath)) {
|
||
const logs = fs.readFileSync(logFilePath, 'utf8')
|
||
log.info(`从文件加载日志: ${logFilePath}`)
|
||
return logs
|
||
}
|
||
|
||
return null
|
||
} catch (error) {
|
||
log.error('加载日志文件失败:', error)
|
||
return null
|
||
}
|
||
})
|
||
|
||
// 管理员权限相关
|
||
ipcMain.handle('check-admin', () => {
|
||
return isRunningAsAdmin()
|
||
})
|
||
|
||
ipcMain.handle('restart-as-admin', () => {
|
||
restartAsAdmin()
|
||
})
|
||
|
||
// 应用生命周期
|
||
// 保证应用单例运行
|
||
const gotTheLock = app.requestSingleInstanceLock()
|
||
|
||
if (!gotTheLock) {
|
||
app.quit()
|
||
process.exit(0)
|
||
}
|
||
|
||
app.on('second-instance', () => {
|
||
if (mainWindow) {
|
||
if (mainWindow.isMinimized()) mainWindow.restore()
|
||
mainWindow.focus()
|
||
}
|
||
})
|
||
|
||
app.on('before-quit', async event => {
|
||
// 只处理一次,避免多重触发
|
||
if (!isQuitting) {
|
||
event.preventDefault()
|
||
isQuitting = true
|
||
|
||
log.info('应用准备退出')
|
||
|
||
// 清理定时器
|
||
if (saveWindowStateTimeout) {
|
||
clearTimeout(saveWindowStateTimeout)
|
||
saveWindowStateTimeout = null
|
||
}
|
||
|
||
// 清理托盘
|
||
destroyTray()
|
||
|
||
// 立即开始强制清理,不等待优雅关闭
|
||
log.info('开始强制清理所有相关进程')
|
||
|
||
try {
|
||
// 并行执行多种清理方法
|
||
const cleanupPromises = [
|
||
// 方法1: 使用我们的进程管理器
|
||
forceKillRelatedProcesses(),
|
||
|
||
// 方法2: 直接使用 taskkill 命令
|
||
new Promise<void>(resolve => {
|
||
if (process.platform === 'win32') {
|
||
const appRoot = getAppRoot()
|
||
const commands = [
|
||
`taskkill /f /im python.exe`,
|
||
`wmic process where "CommandLine like '%main.py%'" delete`,
|
||
`wmic process where "CommandLine like '%${appRoot.replace(/\\/g, '\\\\')}%'" delete`,
|
||
]
|
||
|
||
let completed = 0
|
||
commands.forEach(cmd => {
|
||
exec(cmd, () => {
|
||
completed++
|
||
if (completed === commands.length) {
|
||
resolve()
|
||
}
|
||
})
|
||
})
|
||
|
||
// 2秒超时
|
||
setTimeout(resolve, 2000)
|
||
} else {
|
||
resolve()
|
||
}
|
||
}),
|
||
]
|
||
|
||
// 最多等待3秒
|
||
const timeoutPromise = new Promise(resolve => setTimeout(resolve, 3000))
|
||
await Promise.race([Promise.all(cleanupPromises), timeoutPromise])
|
||
|
||
log.info('进程清理完成')
|
||
} catch (e) {
|
||
log.error('进程清理时出错:', e)
|
||
}
|
||
|
||
log.info('应用强制退出')
|
||
|
||
// 使用 process.exit 而不是 app.exit,更加强制
|
||
setTimeout(() => {
|
||
process.exit(0)
|
||
}, 500)
|
||
}
|
||
})
|
||
|
||
app.whenReady().then(() => {
|
||
// 初始化日志系统
|
||
setupLogger()
|
||
|
||
// 清理7天前的旧日志
|
||
cleanOldLogs(7)
|
||
|
||
log.info('应用启动')
|
||
log.info(`应用版本: ${app.getVersion()}`)
|
||
log.info(`Electron版本: ${process.versions.electron}`)
|
||
log.info(`Node版本: ${process.versions.node}`)
|
||
log.info(`平台: ${process.platform}`)
|
||
|
||
// 检查管理员权限
|
||
if (!isRunningAsAdmin()) {
|
||
log.warn('应用未以管理员权限运行')
|
||
console.log('应用未以管理员权限运行')
|
||
// 在生产环境中,可以选择是否强制要求管理员权限
|
||
// 这里先创建窗口,让用户选择是否重新启动
|
||
} else {
|
||
log.info('应用以管理员权限运行')
|
||
}
|
||
|
||
createWindow()
|
||
})
|
||
|
||
app.on('window-all-closed', () => {
|
||
if (process.platform !== 'darwin') {
|
||
isQuitting = true
|
||
app.quit()
|
||
}
|
||
})
|
||
|
||
app.on('activate', () => {
|
||
if (mainWindow === null) createWindow()
|
||
})
|