const { app, BrowserWindow, dialog, ipcMain, Menu } = require('electron') const { spawn } = require('child_process') const fs = require('fs') const http = require('http') const path = require('path') const BACKEND_HOST = 'localhost' const BACKEND_PORT = 9996 const BACKEND_URL = `http://${BACKEND_HOST}:${BACKEND_PORT}` const STARTUP_TIMEOUT_MS = 30000 let mainWindow = null let backendProcess = null let startedBackend = false const projectionWindows = new Set() function getSharedWebPreferences() { return { contextIsolation: true, nodeIntegration: false, sandbox: true, preload: path.join(__dirname, 'preload.cjs'), } } function isProjectionUrl(url) { try { return new URL(url).searchParams.get('screen') === 'counter' } catch { return false } } function createProjectionWindow(url) { const projectionWindow = new BrowserWindow({ width: 1280, height: 720, minWidth: 960, minHeight: 540, frame: false, fullscreen: false, movable: true, resizable: true, autoHideMenuBar: true, title: '投屏计数', backgroundColor: '#000000', thickFrame: false, webPreferences: getSharedWebPreferences(), }) projectionWindows.add(projectionWindow) hideMenuBar(projectionWindow) projectionWindow.loadURL(url) projectionWindow.on('closed', () => { projectionWindows.delete(projectionWindow) }) return projectionWindow } function hideMenuBar(win) { win.setMenuBarVisibility(false) win.removeMenu() } function attachWindowOpenHandler(win) { win.webContents.setWindowOpenHandler(({ url }) => { if (isProjectionUrl(url)) { createProjectionWindow(url) return { action: 'deny' } } return { action: 'allow' } }) win.webContents.on('did-create-window', (childWindow) => { hideMenuBar(childWindow) }) } function getBackendExecutablePath() { if (app.isPackaged) { return path.join( process.resourcesPath, 'backend', process.platform === 'win32' ? 'YD_TrainingCompetition_Api.exe' : 'YD_TrainingCompetition_Api', ) } return path.join( __dirname, '..', 'build', 'backend', process.platform === 'win32' ? 'YD_TrainingCompetition_Api.exe' : 'YD_TrainingCompetition_Api', ) } function getFrontendIndexPath() { return path.join(__dirname, '..', 'frontend', 'index.html') } function probeBackend() { return new Promise((resolve) => { const request = http.get(BACKEND_URL, (response) => { response.resume() resolve(true) }) request.on('error', () => resolve(false)) request.setTimeout(1000, () => { request.destroy() resolve(false) }) }) } async function waitForBackend(timeoutMs = STARTUP_TIMEOUT_MS) { const startedAt = Date.now() while (Date.now() - startedAt < timeoutMs) { if (await probeBackend()) { return } await new Promise((resolve) => setTimeout(resolve, 300)) } throw new Error(`后端服务启动超时:${BACKEND_URL}`) } async function startBackend() { if (await probeBackend()) { return } const backendPath = getBackendExecutablePath() if (!fs.existsSync(backendPath)) { throw new Error(`未找到后端程序:${backendPath}\n请先执行 npm run build:assets。`) } backendProcess = spawn(backendPath, [], { cwd: path.dirname(backendPath), env: { ...process.env, ASPNETCORE_ENVIRONMENT: 'Production', ASPNETCORE_URLS: BACKEND_URL, }, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, }) startedBackend = true backendProcess.stdout.on('data', (data) => { console.log(`[backend] ${data.toString().trimEnd()}`) }) backendProcess.stderr.on('data', (data) => { console.error(`[backend] ${data.toString().trimEnd()}`) }) backendProcess.on('exit', (code, signal) => { console.log(`[backend] exited code=${code} signal=${signal}`) backendProcess = null }) await waitForBackend() } function stopBackend() { if (!startedBackend || !backendProcess || backendProcess.killed) { return } if (process.platform === 'win32') { spawn('taskkill', ['/pid', String(backendProcess.pid), '/f', '/t'], { windowsHide: true, stdio: 'ignore', }) } else { backendProcess.kill('SIGTERM') } backendProcess = null } async function createWindow() { mainWindow = new BrowserWindow({ width: 1366, height: 900, minWidth: 1024, minHeight: 700, title: '赛事训练比赛系统', autoHideMenuBar: true, webPreferences: getSharedWebPreferences(), }) hideMenuBar(mainWindow) attachWindowOpenHandler(mainWindow) const devServerUrl = process.env.ELECTRON_RENDERER_URL if (!app.isPackaged && devServerUrl) { await mainWindow.loadURL(devServerUrl) mainWindow.webContents.openDevTools({ mode: 'detach' }) return } // 由后端通过 http 托管前端:绝对路径 /static、fetch、SignalR 与 API 均同源可用。 await mainWindow.loadURL(BACKEND_URL) } ipcMain.handle('dialog:pickFolder', async () => { const targetWindow = mainWindow ?? BrowserWindow.getFocusedWindow() const result = await dialog.showOpenDialog(targetWindow, { title: '选择赛事文件夹', properties: ['openDirectory'], }) if (result.canceled || result.filePaths.length === 0) { return null } return result.filePaths[0] }) app.whenReady().then(async () => { if (app.isPackaged) { Menu.setApplicationMenu(null) } try { await startBackend() await createWindow() } catch (error) { dialog.showErrorBox('应用启动失败', error instanceof Error ? error.message : String(error)) app.quit() } app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow() } }) }) app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit() } }) app.on('before-quit', stopBackend)