2026-07-02 10:52:09 +08:00

252 lines
5.8 KiB
JavaScript

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)