Files
Scoreboard/console/main/index.js
T
AskaEth fc96d17660 feat: 字体可配并随赛事包打包 + 大屏调试标识收敛 + 报名表 CSV 编码兼容
三块改动,均为现场反馈:

一、字体配置与随包打包(此前「没有入口可以配置字体」,导出包也不带字体)
- 服务端新增 httpapi/fonts.go 与 4 条路由:GET /api/fonts(recorder)、
  POST /api/fonts(admin,multipart file)、DELETE /api/fonts/{name}(admin)、
  GET /uploads/fonts/{name}(匿名——大屏 @font-face 不带会话)
- 上传限 ttf/otf/woff/woff2、单个 20 MiB;文件名经 safeFontName 收敛且需与
  fontContentType 往返一致,挡住路径穿越;重名 409、删除被引用字体 409
- 新增设置键 display.font_title / display.font_body(DTO fontTitle/fontBody,
  随快照下发给大屏),写入时校验取值合法
- 导出:接上一直没被赋值的 IncludeFonts/FontsDir;包内落点用 uploads/fonts/
  (运行时只读这里,落顶层 fonts/ 等于没人读)
- 大屏按配置动态注册字体并挂 --display-font-title/body,加载失败静默回落默认字体
- 管理端「赛事信息」新增字体区块:标题/正文字体下拉、上传、字体库、实时预览

二、大屏显示收敛
- 数据版本号角标 vN 彻底移除(原先只有窗口化窗口会显示,两处显隐口径不一致)
- 窗口化窗口按 F11 全屏后自绘标题栏不让位:根因是 Console 用 win.setFullScreen()
  做窗口级全屏,而页面用 document.fullscreenElement 判断,后者恒为 null;
  新增 display-preload.js 下发窗口级全屏状态,页面侧 shell.ts 把两级全屏取或,
  无 preload 时(浏览器直连 /display)回落到文档级监听

三、报名表 CSV 编码兼容(用户报「解析中文字符遇到编码问题」)
- 服务端不再把原始字节直接喂给 csv.Reader:先摘 UTF-8 BOM,再认 UTF-16 LE/BE
  BOM,其余非合法 UTF-8 按 GB18030 解(GBK 是其子集)
- 修复前症状:GBK 文件(Excel 另存为 CSV 的默认编码)中文变乱码;带 BOM 的文件
  (Excel「CSV UTF-8」,也正是我们自己模板的格式)把 BOM 拼进第一列表头,
  报「报名表缺少列 event_code」——连「下载模板→填→传回来」都走不通
- golang.org/x/text 由 indirect 提升为直接依赖,未新增外部依赖

测试:服务端 12 包全绿(新增 fonts_test.go / fonts_internal_test.go /
handlers_import_encoding_test.go);前端 vue-tsc 通过、pnpm build 通过、157 例全过;
Console 语法检查 17 个文件通过。docs/ 与 devdocs/ 同步更新。
2026-09-10 17:45:11 +08:00

745 lines
28 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,
screen
} = require('electron')
const { loadConfig, updateConfig, resolveExecutable } = require('./config')
const { resolveDataDir, migrateLegacyData, isInside } = require('./datadir')
const { resolveUserRoot, planUserPaths, prepareUserPaths } = require('./userpaths')
const { LogBus } = require('./log-bus')
const { ServiceSupervisor, healthHost } = require('./service')
const { DisplayManager } = require('./displays')
const { fetchDisplayInstances } = require('./instances')
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 quitting = false
let shutdownDone = false
let exePath = null
// 解析后的数据目录信息(见 datadir.js),拉起服务端和 passwd 都用它
let dataDirInfo = null
// 解析后的用户配置/规则路径(见 userpaths.js):console\config.json、config.yaml、rules\
let userPaths = null
// 大屏实例看板只在管理窗口可见时轮询,窗口隐藏即停表
let instancesTimer = null
let instancesLastReason = null
// passwd 子进程同一时刻只允许一个:并发写同一个 sqlite 库没有意义,也会让输出互相串
let accountBusy = false
// 部署包根目录:打包后 = exe 所在目录(安装目录),开发态 = 仓库的 console\。
// 注意别和下面数据目录用的 installDir 混用:那个取的是**服务端 exe** 所在目录,
// 开发态两者可能不是同一个地方。
function appDir() {
return app.isPackaged ? path.dirname(app.getPath('exe')) : path.join(__dirname, '..')
}
// Console 自己的 config.json:打包后在 <用户根>\console\config.json(配置/规则已经搬出安装
// 目录,覆盖安装不会丢),开发态仍是仓库里的 console\config.json。
// SCOREBOARD_CONSOLE_CONFIG 优先级最高,方便本机临时换一份配置跑。
function resolveConfigPath() {
if (process.env.SCOREBOARD_CONSOLE_CONFIG) {
return path.resolve(process.env.SCOREBOARD_CONSOLE_CONFIG)
}
return userPaths ? userPaths.consoleConfig : path.join(__dirname, '..', 'config.json')
}
// 开关显示以系统实际状态为准:打包后的 Windows 读登录项 enabled,其余回落配置值。
function openAtLoginEffective() {
const configured = cfg ? cfg.values.startup.openAtLogin === true : false
if (!app.isPackaged || process.platform !== 'win32') return configured
try {
return app.getLoginItemSettings().executableWillLaunchAtLogin === true
} catch {
return configured
}
}
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,
openAtLogin: openAtLoginEffective(),
openAtLoginSupported: app.isPackaged && (process.platform === 'win32' || process.platform === 'darwin'),
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('show', startInstancesPolling)
adminWindow.on('hide', stopInstancesPolling)
adminWindow.on('closed', () => {
stopInstancesPolling()
adminWindow = null
})
}
function adminPanelUrl() {
// 复用服务端的地址推导:host 配成 0.0.0.0/:: 时本机也要走回环
if (service) return service.status.adminUrl
return `http://${healthHost(cfg.values.server)}:${cfg.values.server.port}/admin`
}
function serverHost() {
if (service) return service.healthHost()
return healthHost(cfg.values.server)
}
// 大屏实例看板:读服务端 GET /api/display/instances 并推给管理窗口。
// 失败只按状态变化记一次日志,避免未登录/服务未启动时刷屏。
async function pollDisplayInstances() {
let result
try {
result = await fetchDisplayInstances({ host: serverHost(), port: cfg.values.server.port })
} catch (err) {
result = { ok: false, reason: 'error', message: `读取大屏实例失败:${err.message}` }
}
if (result.ok) {
if (instancesLastReason) {
instancesLastReason = null
log.push('console', `大屏实例列表恢复读取(${result.instances.length} 个实例)`)
}
} else if (result.reason !== instancesLastReason) {
instancesLastReason = result.reason
log.push('console', `读取大屏实例失败:${result.message}`)
}
sendToAdmin('display:instances', result)
return result
}
function startInstancesPolling() {
if (instancesTimer) return
pollDisplayInstances()
instancesTimer = setInterval(pollDisplayInstances, 5000)
}
function stopInstancesPolling() {
if (!instancesTimer) return
clearInterval(instancesTimer)
instancesTimer = null
}
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
})
}
// 服务端管理面板是大屏窗口的第二个入口:页名只收 ASCII slug,
// 其余(含空值)交给 DisplayManager 回落配置里的默认页
function safeDisplayPage(value) {
return typeof value === 'string' && /^[a-z0-9-]{1,32}$/.test(value) ? value : null
}
function findDisplay(displayId) {
return screen.getAllDisplays().find((item) => String(item.id) === String(displayId)) || null
}
// 全屏窗口只允许出现在「多屏 + 非主屏」上:单屏或主屏开全屏会把用户桌面整个盖住,
// 现场又没有键盘能退出来。所以放不放行由主进程判定,页面传什么都不作数。
function openDisplayWindow(displayId, options) {
if (!displays) return { ok: false, created: false, mode: null, message: '显示器管理尚未就绪' }
// IPC 入参一律当不可信处理
const payload = options && typeof options === 'object' ? options : {}
const target = findDisplay(displayId)
if (!target) return { ok: false, created: false, mode: null, message: '未找到该显示器,可能已被拔出' }
const all = screen.getAllDisplays()
const singleMachine = all.length <= 1
const allowFullscreen = !singleMachine && target.id !== screen.getPrimaryDisplay().id
const wantedFullscreen = payload.fullscreen === true
const downgraded = wantedFullscreen && !allowFullscreen
const result = displays.openOn(target, {
page: safeDisplayPage(payload.page),
single: !(wantedFullscreen && allowFullscreen)
})
log.push('console', `管理面板${result.created ? '创建' : '聚焦'}大屏窗口 display=${target.id}(${result.mode === 'kiosk' ? '全屏 kiosk' : '窗口化'})`)
if (downgraded) {
const reason = singleMachine ? '当前只有一个显示器' : '主显示器'
log.push('console', `管理面板请求在 display=${target.id} 上开全屏:${reason}只能窗口化,已降级`)
}
return {
ok: true,
created: result.created,
mode: result.mode,
message: downgraded ? `${singleMachine ? '当前只有一个显示器' : '主显示器'}只能窗口化` : undefined
}
}
function closeDisplayWindow(displayId) {
if (!displays) return { ok: false, closed: false, message: '显示器管理尚未就绪' }
const target = findDisplay(displayId)
// 显示器拔了但窗口还在时,也允许按原 id 直接收窗
const closed = displays.closeDisplay(target ? target.id : Number(displayId))
return { ok: true, closed }
}
// 渲染层要随时知道最大化状态,才能在「最大化/还原」之间换图标
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 (!displays || displays.enabled) return
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())
}
// 开机自启:只对打包后的程序有意义,开发态注册的是 electron 可执行文件。
// Windows 上始终注册登录项(任务管理器「启动」里能看到),用 enabled 控制是否真的拉起:
// 实测 {openAtLogin:true, enabled:false} → openAtLogin=true 且 executableWillLaunchAtLogin=false,
// 即「已注册但未启用」。macOS 没有 enabled 参数,只能注册/注销。
function applyOpenAtLogin() {
if (!app.isPackaged || (process.platform !== 'win32' && process.platform !== 'darwin')) return
const wanted = cfg.values.startup?.openAtLogin === true
try {
app.setLoginItemSettings(loginItemSettings(wanted))
log.push('console', wanted ? '开机自启已启用' : '开机自启已注册但未启用')
} catch (err) {
log.push('console', `设置开机自启失败:${err.message}`)
}
}
// 配置为准:把开关值落到 config.json(只动 startup.openAtLogin)
function writeOpenAtLogin(value) {
updateConfig(cfg.path, (raw) => {
raw.startup = { ...(raw.startup || {}), openAtLogin: value }
})
}
function loginItemSettings(enabled) {
return process.platform === 'win32'
? { openAtLogin: true, enabled }
: { openAtLogin: enabled }
}
// 服务管理窗口的「开机自启」开关:登录项一直保留,只切换是否真正随系统拉起。
function setOpenAtLogin(enabled) {
if (!cfg) return { ok: false, message: '配置尚未加载' }
const wanted = enabled === true
const previous = cfg.values.startup?.openAtLogin === true
cfg.values.startup = { ...(cfg.values.startup || {}), openAtLogin: wanted }
try {
writeOpenAtLogin(wanted)
} catch (err) {
cfg.values.startup.openAtLogin = previous
log.push('console', `写入开机自启配置失败:${err.message}`)
return { ok: false, message: `保存配置失败:${err.message}` }
}
if (app.isPackaged && (process.platform === 'win32' || process.platform === 'darwin')) {
try {
app.setLoginItemSettings(loginItemSettings(wanted))
} catch (err) {
// 登录项没写成功,配置也回滚,避免界面与系统状态不一致
cfg.values.startup.openAtLogin = previous
try {
writeOpenAtLogin(previous)
} catch (rollbackErr) {
log.push('console', `回滚开机自启配置失败:${rollbackErr.message}`)
}
log.push('console', `设置开机自启失败:${err.message}`)
return { ok: false, message: `写入登录项失败:${err.message}` }
}
}
log.push('console', wanted ? '开机自启已启用' : '开机自启已关闭(登录项保留,任务管理器里可见)')
return { ok: true, openAtLogin: wanted }
}
// 服务端用什么配置文件,passwd 就得用同一份,否则改的是另一个 data/ 下的账号;
// 打包态这份在用户数据目录里,Console 已经解析好了绝对路径(见 userpaths.js)
function passwdConfigArg() {
if (userPaths && userPaths.serverConfig) return userPaths.serverConfig
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'
}
// 数据目录同理:不带上同一个 -data,passwd 会改到另一个库里的账号上
function passwdDataArg() {
return dataDirInfo && dataDirInfo.passToServer ? dataDirInfo.dir : ''
}
// 账号重置走同目录的 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, appDir()).path
if (!exe || !fs.existsSync(exe)) {
return { ok: false, error: `未找到服务端程序:${exe || cfg.values.server.exe}` }
}
const configArg = passwdConfigArg()
const dataDirArg = passwdDataArg()
const args = buildPasswdArgs({ configArg, dataDir: dataDirArg, currentUsername, newUsername, password })
const sender = event.sender
accountBusy = true
// 日志里不能带明文密码
log.push(
'console',
`重置管理员账号:${exe} passwd -config ${configArg}` +
`${dataDirArg ? ` -data ${dataDirArg}` : ''} -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 && displays.enabled) displays.relayout()
return statusSnapshot()
})
ipcMain.handle('display:list', () => {
if (!displays) return { single: true, displays: [] }
return displays.listDisplays()
})
ipcMain.handle('display:open', (event, displayId, options) => openDisplayWindow(displayId, options))
ipcMain.handle('display:close', (event, displayId) => closeDisplayWindow(displayId))
ipcMain.handle('app:open-admin-window', () => {
showAdmin()
return true
})
ipcMain.handle('app:open-server-admin', () => {
showServerAdmin()
return true
})
ipcMain.handle('display:instances-get', () => pollDisplayInstances())
ipcMain.handle('startup:set-open-at-login', (event, enabled) => setOpenAtLogin(enabled))
// 窗口控制统一从 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())
})
ipcMain.handle('window:is-fullscreen', (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
return Boolean(win && !win.isDestroyed() && win.isFullScreen())
})
}
app.whenReady().then(() => {
const bundleDir = appDir()
// 配置与规则跟数据目录一样搬出安装目录(见 userpaths.js)。这一步在加载配置之前做:
// Console 自己的 config.json 也要先落到用户目录。失败只记日志,攒着等日志器建好再写。
const setupNotes = []
userPaths = prepareUserPaths({
plan: planUserPaths({
installDir: bundleDir,
userRoot: resolveUserRoot(process.env),
isPackaged: app.isPackaged
}),
log: (message) => setupNotes.push(message)
})
cfg = loadConfig(resolveConfigPath())
log = new LogBus({
maxLines: cfg.values.log.maxLines,
// 日志固定在部署包根目录的 logs\ 下(log.file 相对它解析):配置文件搬走了,
// 但日志是易失的排查材料,位置保持原样,现场习惯和文档都不用改
file: path.resolve(bundleDir, cfg.values.log.file || 'logs/console.log'),
maxFileBytes: cfg.values.log.maxFileBytes
})
log.push('console', `Scoreboard Console v${app.getVersion()} 启动(配置:${cfg.path})`)
for (const note of setupNotes) log.push('console', note)
if (userPaths.mode === 'user') {
log.push('console', `用户数据目录:${userPaths.root}(Console 配置、服务端 config.yaml、rules 都在这里,覆盖安装不会丢)`)
}
if (cfg.warning) log.push('console', cfg.warning)
const resolved = resolveExecutable(cfg.values.server.exe, cfg.path, bundleDir)
exePath = resolved.path
log.push('console', `服务端程序:${resolved.path}`)
const installDir = path.dirname(resolved.path)
dataDirInfo = resolveDataDir({
values: cfg.values,
installDir,
env: process.env,
isPackaged: app.isPackaged
})
// 老版本把数据放在安装目录里,而覆盖安装会先静默跑旧卸载器把安装目录整个删掉,
// 所以先做一次性迁移。只在打包态做,开发态不动仓库里的 data/。
if (app.isPackaged && dataDirInfo.passToServer) {
migrateLegacyData({
targetDir: dataDirInfo.dir,
legacyDir: path.join(installDir, 'data'),
log: (message) => log.push('console', message)
})
}
log.push(
'console',
`数据目录:${dataDirInfo.dir}${dataDirInfo.passToServer ? '' : '(沿用服务端默认)'}`
)
if (dataDirInfo.passToServer && isInside(installDir, dataDirInfo.dir)) {
log.push('console', `警告:数据目录仍在安装目录内(${dataDirInfo.dir}),覆盖安装时会被删除`)
}
service = new ServiceSupervisor({
values: cfg.values,
exePath: resolved.path,
log,
dataDir: dataDirInfo.passToServer ? dataDirInfo.dir : null,
configPath: userPaths.serverConfig
})
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 && displays.enabled) {
displays.closeAll()
log.push('console', '服务已停止,大屏窗口已关闭')
}
return
}
if (!status.healthy) return
// 服务端恢复后必须让大屏重新加载,否则页面还停在断线兜底画面上
if (displays.enabled) displays.relayout()
else ensureDisplays()
})
createAdminWindow()
createTray()
registerIpc()
// 单实例锁在 whenReady 之前就生效,但回调要用 cfg;注册放在这里,
// 避免第二个实例在配置加载完成前触发 showAdmin() 读到 null
app.on('second-instance', () => showAdmin())
// 开机自启默认关闭,按 config.json 的 startup.openAtLogin 决定是否注册
applyOpenAtLogin()
// 防熄屏要在服务端起来之前就生效,否则开机后第一段时间屏幕仍会休眠
try {
powerBlockerId = powerSaveBlocker.start('prevent-display-sleep')
log.push('console', `防熄屏已启用(powerSaveBlocker=${powerBlockerId},生效=${powerSaveBlocker.isStarted(powerBlockerId)})`)
} catch (err) {
powerBlockerId = null
log.push('console', `防熄屏启用失败:${err.message}`)
}
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 (displays && !displays.enabled) {
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 = () => {
stopInstancesPolling()
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', () => {
stopInstancesPolling()
if (service) service.dispose()
})
}