Files
Scoreboard/console/main/index.js
T
AskaEth 7d5d94f815 feat: M0-M2 骨架落地(服务端/前端/宿主/数据库/积分引擎)
- server: Go 单二进制骨架,embed 内嵌前端,配置/迁移/HTTP 路由
- store: system.db + event.db 两套迁移,业务表 + 种子数据
- domain: 积分引擎(并列三口径/计分通道/流水/排名/破纪录)+ 68 个测试
- web: Vite + Vue 三入口骨架 + shared 层(类型/API/WebSocket 客户端)
- console: Electron 宿主骨架(选屏/防熄屏/进程守护/托盘)
2026-09-09 18:36:40 +08:00

300 lines
8.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict'
const path = require('path')
const {
app,
BrowserWindow,
Menu,
Tray,
ipcMain,
powerSaveBlocker,
shell
} = require('electron')
const { loadConfig, resolveExecutable } = require('./config')
const { LogBus } = require('./log-bus')
const { ServiceSupervisor } = require('./service')
const { DisplayManager } = require('./displays')
const { createTrayIcon } = require('./tray-icon')
// 现场只允许一个实例:两个 Console 会互相抢同一个服务端端口和同一块大屏
if (!app.requestSingleInstanceLock()) {
app.quit()
} else {
main()
}
function main() {
let cfg = null
let log = null
let service = null
let displays = null
let adminWindow = null
let tray = null
let powerBlockerId = null
let displayFallbackTimer = null
let displaysEnabled = false
let quitting = false
let shutdownDone = false
function resolveConfigPath() {
if (process.env.SCOREBOARD_CONSOLE_CONFIG) {
return path.resolve(process.env.SCOREBOARD_CONSOLE_CONFIG)
}
// 打包后 config.json 放在 exe 旁边,方便现场直接改,不藏在 asar 里
if (app.isPackaged) {
return path.join(path.dirname(app.getPath('exe')), 'config.json')
}
return path.join(__dirname, '..', 'config.json')
}
function statusSnapshot() {
const status = service ? service.status : { state: 'stopped', stateLabel: '已停止' }
return {
...status,
configPath: cfg ? cfg.path : null,
configWarning: cfg ? cfg.warning : null,
displays: displays ? displays.getInfo() : [],
powerSaveBlocked: powerBlockerId !== null ? powerSaveBlocker.isStarted(powerBlockerId) : false,
appVersion: app.getVersion()
}
}
function sendToAdmin(channel, payload) {
if (adminWindow && !adminWindow.isDestroyed()) {
adminWindow.webContents.send(channel, payload)
}
}
function broadcastStatus() {
sendToAdmin('service:status', statusSnapshot())
}
function showAdmin() {
if (!adminWindow || adminWindow.isDestroyed()) {
createAdminWindow()
return
}
if (adminWindow.isMinimized()) adminWindow.restore()
adminWindow.show()
adminWindow.focus()
}
function createAdminWindow() {
const { width, height } = cfg.values.admin
adminWindow = new BrowserWindow({
width,
height,
minWidth: 860,
minHeight: 600,
title: 'Scoreboard Console · 启动管理',
backgroundColor: '#0f1420',
show: false,
webPreferences: {
preload: path.join(__dirname, '..', 'renderer', 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true
}
})
adminWindow.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'))
adminWindow.once('ready-to-show', () => adminWindow.show())
// 关窗只隐藏,服务端和守护逻辑要继续在托盘里跑
adminWindow.on('close', (event) => {
if (quitting) return
event.preventDefault()
adminWindow.hide()
log.push('console', '管理窗口已最小化到托盘(服务端继续运行)')
})
adminWindow.on('closed', () => {
adminWindow = null
})
}
function ensureDisplays() {
if (displaysEnabled || !displays) return
displaysEnabled = true
if (displayFallbackTimer) {
clearTimeout(displayFallbackTimer)
displayFallbackTimer = null
}
displays.enable()
}
function buildTrayMenu() {
const status = statusSnapshot()
const busy = status.state === 'starting' || status.state === 'running' || status.state === 'stopping'
return Menu.buildFromTemplate([
{
label: `服务端:${status.stateLabel}${status.healthy ? ' · 就绪' : ''}`,
enabled: false
},
{ type: 'separator' },
{ label: '启动服务', enabled: !busy, click: () => service.start() },
{ label: '停止服务', enabled: busy, click: () => service.stop() },
{ label: '重启服务', enabled: busy, click: () => service.restart() },
{ type: 'separator' },
{ label: '打开管理端', click: () => showAdmin() },
{
label: '打开服务端管理面板',
click: () => shell.openExternal(status.adminUrl || `http://127.0.0.1:${cfg.values.server.port}/admin`)
},
{ type: 'separator' },
{
label: '退出',
click: () => {
quitting = true
app.quit()
}
}
])
}
function refreshTray() {
if (!tray || tray.isDestroyed()) return
const status = statusSnapshot()
tray.setToolTip(`Scoreboard Console · ${status.stateLabel}${status.healthy ? ' · 就绪' : ''}`)
tray.setContextMenu(buildTrayMenu())
}
function createTray() {
tray = new Tray(createTrayIcon())
tray.setToolTip('Scoreboard Console')
tray.setContextMenu(buildTrayMenu())
tray.on('double-click', () => showAdmin())
}
function registerIpc() {
ipcMain.handle('service:get-status', () => statusSnapshot())
ipcMain.handle('service:start', () => {
service.start()
return statusSnapshot()
})
ipcMain.handle('service:stop', async () => {
await service.stop()
return statusSnapshot()
})
ipcMain.handle('service:restart', async () => {
await service.restart()
return statusSnapshot()
})
ipcMain.handle('log:tail', () => (log ? log.tail() : []))
ipcMain.handle('log:clear', () => {
if (log) log.clear()
return true
})
ipcMain.handle('display:relayout', () => {
if (displays && displaysEnabled) displays.relayout()
return statusSnapshot()
})
ipcMain.handle('app:open-external', (_event, url) => {
// 只放行 http/https,避免管理窗口被注入的链接拉去执行本地协议
if (typeof url === 'string' && /^https?:\/\//i.test(url)) {
shell.openExternal(url)
return true
}
return false
})
ipcMain.handle('app:open-admin-window', () => {
showAdmin()
return true
})
}
app.on('second-instance', () => showAdmin())
app.whenReady().then(() => {
cfg = loadConfig(resolveConfigPath())
log = new LogBus({
maxLines: cfg.values.log.maxLines,
file: path.resolve(path.dirname(cfg.path), cfg.values.log.file || 'logs/console.log'),
maxFileBytes: cfg.values.log.maxFileBytes
})
log.push('console', `Scoreboard Console v${app.getVersion()} 启动(配置:${cfg.path})`)
if (cfg.warning) log.push('console', cfg.warning)
const resolved = resolveExecutable(cfg.values.server.exe, cfg.path)
log.push('console', `服务端程序:${resolved.path}`)
service = new ServiceSupervisor({
values: cfg.values,
exePath: resolved.path,
log
})
displays = new DisplayManager({
values: cfg.values,
server: cfg.values.server,
log
})
log.onLine((line) => sendToAdmin('log:line', line))
service.on('status', (status) => {
broadcastStatus()
refreshTray()
if (!status.healthy) return
// 服务端恢复后必须让大屏重新加载,否则页面还停在断线兜底画面上
if (displaysEnabled) displays.relayout()
else ensureDisplays()
})
createAdminWindow()
createTray()
registerIpc()
// 防熄屏要在服务端起来之前就生效,否则开机后第一段时间屏幕仍会休眠
powerBlockerId = powerSaveBlocker.start('prevent-display-sleep')
log.push('console', `防熄屏已启用(powerSaveBlocker=${powerBlockerId},生效=${powerSaveBlocker.isStarted(powerBlockerId)})`)
if (cfg.values.server.autoStart) {
service.start()
} else {
log.push('console', '配置为不自动启动,等待手动启动服务端')
}
// 服务端迟迟不健康(例如首次迁移很慢)也要把大屏开出来,别让现场只看到桌面
const wait = Number(cfg.values.display.waitForServerMs) || 0
if (wait > 0) {
displayFallbackTimer = setTimeout(() => {
displayFallbackTimer = null
if (!displaysEnabled) {
log.push('console', `等待服务端就绪超过 ${Math.round(wait / 1000)} 秒,先打开大屏窗口`)
ensureDisplays()
}
}, wait)
}
})
// 常驻托盘:窗口全关也不退出,服务端要继续守着
app.on('window-all-closed', () => {})
app.on('before-quit', (event) => {
quitting = true
if (shutdownDone) return
event.preventDefault()
const finish = () => {
if (displays) displays.closeAll()
if (powerBlockerId !== null) powerSaveBlocker.stop(powerBlockerId)
if (log) log.close()
shutdownDone = true
app.quit()
}
if (service) {
service.stop().then(finish).catch(finish)
} else {
finish()
}
})
app.on('quit', () => {
if (service) service.dispose()
})
}