Files
Scoreboard/console/main/index.js
T
AskaEth 7f9701c465 fix: 审查修复波次(P0x4 + P1x30+)
- domain/rules: 计分与排名解耦、破纪录按组别、tieBreak 生效、调整分不丢、移除 aggregate 假能力
- realtime: 新建 internal/dto,快照三项契约对齐,/ws 只认会话身份
- httpapi: 流水幂等、standings 整表载荷、广播接线、强制改密、XFF 防伪造、records 写入、审计、赛事包内容、发布前备份、result.points、实例列表
- web: FormData 上传、YAML 解析、快照归一化、大屏解析层、bootstrap 降级、访客判定、主题切换
- console: 优雅关闭握手、中文化、免验证改管理员账号、选屏与日志轮转修复
- docs: 用户文档三份 + README
- 主代理: 破纪录加分接线(levels 未加载导致恒 0)
2026-09-09 21:06:40 +08:00

497 lines
16 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 fs = require('fs')
const path = require('path')
const {
app,
BrowserWindow,
Menu,
Tray,
ipcMain,
powerSaveBlocker
} = 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')
const { validateAccountField, buildPasswdArgs, runPasswd } = require('./account')
// 现场只允许一个实例:两个 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 serverAdminWindow = null
let tray = null
let powerBlockerId = null
let displayFallbackTimer = null
let displaysEnabled = false
let quitting = false
let shutdownDone = false
let exePath = null
// passwd 子进程同一时刻只允许一个:并发写同一个 sqlite 库没有意义,也会让输出互相串
let accountBusy = 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: '赛事积分榜 · 服务管理',
backgroundColor: '#0f1420',
show: false,
// 标题栏由页面自绘;titleBarStyle 只是兜底,防止某些平台忽略 frame:false
frame: false,
titleBarStyle: 'hidden',
webPreferences: {
preload: path.join(__dirname, '..', 'renderer', 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true
}
})
bindWindowStateEvents(adminWindow)
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 adminPanelUrl() {
// 复用服务端的地址推导:host 配成 0.0.0.0 时本机也要走回环
if (service) return service.status.adminUrl
const host = (cfg.values.server.host || '127.0.0.1') === '0.0.0.0' ? '127.0.0.1' : cfg.values.server.host
return `http://${host || '127.0.0.1'}:${cfg.values.server.port}/admin`
}
function showServerAdmin() {
if (!serverAdminWindow || serverAdminWindow.isDestroyed()) {
createServerAdminWindow()
return
}
if (serverAdminWindow.isMinimized()) serverAdminWindow.restore()
serverAdminWindow.show()
serverAdminWindow.focus()
}
// 服务端管理面板是 Web 页面,用无边框 Electron 窗口承载,省得现场再开浏览器
function createServerAdminWindow() {
const { width, height } = cfg.values.admin
serverAdminWindow = new BrowserWindow({
width: Math.max(width, 1100),
height,
minWidth: 900,
minHeight: 620,
title: '赛事积分榜 · 服务端管理面板',
backgroundColor: '#161616',
show: false,
frame: false,
titleBarStyle: 'hidden',
webPreferences: {
preload: path.join(__dirname, '..', 'renderer', 'shell-preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true
}
})
bindWindowStateEvents(serverAdminWindow)
const url = adminPanelUrl()
log.push('console', `打开服务端管理面板:${url}`)
serverAdminWindow.loadURL(url).catch((err) => {
log.push('console', `服务端管理面板加载失败:${err.message}`)
})
// 加载失败时 ready-to-show 不会触发,兜底把窗口显示出来,否则用户看不到任何反馈
const reveal = () => {
if (serverAdminWindow && !serverAdminWindow.isDestroyed()) {
serverAdminWindow.show()
serverAdminWindow.focus()
}
}
serverAdminWindow.once('ready-to-show', reveal)
serverAdminWindow.webContents.on('did-fail-load', (_event, _code, _desc, _url, isMainFrame) => {
if (isMainFrame) reveal()
})
serverAdminWindow.on('closed', () => {
serverAdminWindow = null
})
}
// 渲染层要随时知道最大化状态,才能在「最大化/还原」之间换图标
function bindWindowStateEvents(win) {
const notify = () => {
if (win.isDestroyed()) return
win.webContents.send('window:maximize-changed', win.isMaximized())
}
win.on('maximize', notify)
win.on('unmaximize', notify)
}
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: () => showServerAdmin() },
{ 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())
}
// 开机自启:只对打包后的 Windows/macOS 程序有意义,开发态注册的是 electron 可执行文件
function applyOpenAtLogin() {
if (!app.isPackaged || (process.platform !== 'win32' && process.platform !== 'darwin')) return
const wanted = !cfg.values.startup || cfg.values.startup.openAtLogin !== false
try {
app.setLoginItemSettings({ openAtLogin: wanted })
log.push('console', wanted ? '开机自启已启用' : '开机自启已关闭')
} catch (err) {
log.push('console', `设置开机自启失败:${err.message}`)
}
}
// 服务端用什么配置文件,passwd 就得用同一份,否则改的是另一个 data/ 下的账号
function passwdConfigArg() {
const args = Array.isArray(cfg.values.server.args) ? cfg.values.server.args : []
const index = args.indexOf('-config')
if (index !== -1 && typeof args[index + 1] === 'string' && args[index + 1]) {
return args[index + 1]
}
return 'config.yaml'
}
// 账号重置走同目录的 scoreboard.exe passwd 子命令:本机进程、不经 HTTP,
// 所以不需要任何登录态,这就是面板能「免验证」的原因。
async function resetAdminAccount(event, payload) {
if (accountBusy) return { ok: false, error: '上一次重置还没结束,请稍候再试' }
// IPC 入参一律当不可信处理
if (payload === null || typeof payload !== 'object') payload = {}
const currentUsername = typeof payload.currentUsername === 'string' ? payload.currentUsername.trim() : ''
const newUsername = typeof payload.newUsername === 'string' ? payload.newUsername.trim() : ''
const password = typeof payload.password === 'string' ? payload.password : ''
const invalid =
validateAccountField(currentUsername, '当前用户名', true) ||
validateAccountField(newUsername, '新用户名', false) ||
validateAccountField(password, '新密码', false)
if (invalid) return { ok: false, error: invalid }
const exe = exePath || resolveExecutable(cfg.values.server.exe, cfg.path).path
if (!exe || !fs.existsSync(exe)) {
return { ok: false, error: `未找到服务端程序:${exe || cfg.values.server.exe}` }
}
const configArg = passwdConfigArg()
const args = buildPasswdArgs({ configArg, currentUsername, newUsername, password })
const sender = event.sender
accountBusy = true
// 日志里不能带明文密码
log.push(
'console',
`重置管理员账号:${exe} passwd -config ${configArg} -user ${currentUsername}` +
`${newUsername ? ` -new-username ${newUsername}` : ''}(密码已隐藏)`
)
try {
const result = await runPasswd({
exePath: exe,
args,
onOutput: (text) => {
if (!sender.isDestroyed()) sender.send('account:output', text)
}
})
const reason = result.error
? result.error
: `退出码 ${result.exitCode}${result.signal ? `,信号 ${result.signal}` : ''}`
log.push('console', `重置管理员账号${result.ok ? '完成' : '失败'}(${reason})`)
return result
} finally {
accountBusy = false
}
}
function registerIpc() {
ipcMain.handle('service:get-status', () => statusSnapshot())
ipcMain.handle('account:reset', (event, payload) => resetAdminAccount(event, payload))
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-admin-window', () => {
showAdmin()
return true
})
ipcMain.handle('app:open-server-admin', () => {
showServerAdmin()
return true
})
// 窗口控制统一从 event.sender 反查窗口,多个窗口共用同一套通道
ipcMain.handle('window:minimize', (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (win && !win.isDestroyed()) win.minimize()
return true
})
ipcMain.handle('window:maximize', (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win || win.isDestroyed()) return false
// 以「切换」语义实现,避免按钮在已最大化时点了没反应
const next = !win.isMaximized()
if (next) win.maximize()
else win.unmaximize()
return next
})
ipcMain.handle('window:close', (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (win && !win.isDestroyed()) win.close()
return true
})
ipcMain.handle('window:is-maximized', (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
return Boolean(win && !win.isDestroyed() && win.isMaximized())
})
}
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)
exePath = resolved.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()
// 服务停了,大屏已无数据可显示,同步关掉扩展屏窗口;
// 放在状态回调里,IPC 按钮和托盘菜单两条停止路径都能覆盖。
if (status.state === 'stopped') {
if (displayFallbackTimer) {
clearTimeout(displayFallbackTimer)
displayFallbackTimer = null
}
if (displays && displaysEnabled) {
displays.closeAll()
log.push('console', '服务已停止,大屏窗口已关闭')
}
displaysEnabled = false
return
}
if (!status.healthy) return
// 服务端恢复后必须让大屏重新加载,否则页面还停在断线兜底画面上
if (displaysEnabled) displays.relayout()
else ensureDisplays()
})
createAdminWindow()
createTray()
registerIpc()
// 单实例锁在 whenReady 之前就生效,但回调要用 cfg;注册放在这里,
// 避免第二个实例在配置加载完成前触发 showAdmin() 读到 null
app.on('second-instance', () => showAdmin())
// 现场无人值守,Console 需要随系统启动(doc 10 §4.3)
applyOpenAtLogin()
// 防熄屏要在服务端起来之前就生效,否则开机后第一段时间屏幕仍会休眠
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()
})
}