Files
Scoreboard/console/main/index.js
T
AskaEth 005b51b2ec fix: 数据目录移出安装目录,修掉「覆盖安装/卸载即丢数据」
根因:数据目录是 <安装目录>\data(config.yaml 的 dataDir 相对服务端工作目录),
而 electron-builder 的卸载器结尾执行 RMDir /r $INSTDIR,且覆盖安装会先跑旧卸载器
(installSection.nsh:52 的 uninstallOldVersion)→ 赛事库/规则/用户/审计/上传图片
连同安装目录被整个删除,再装新版。卸载一次同样如此。

- 服务端新增 -data <dir>(主命令、backup、passwd 三处),非空时覆盖 config.yaml 的
  dataDir;不传时行为完全不变(兼容便携/手工运行)
- Console 把数据指到当前用户目录 %LOCALAPPDATA%\ScoreboardConsole\data(卸载器不会碰
  这里;没有用 app.getPath('userData'),因为 %APPDATA%\<AppName> 正是被删的那个)
- 一次性迁移:目标为空且 <安装目录>\data 有数据时整体改名过去;两边都有数据时不动作
  只记警告;任何失败都保留原地数据、绝不删除
- passwd 也带 -data(否则重置的是安装目录下那个空库里的账号)
- deploy/backup.bat 指向同一数据目录
- 文档:部署指南新增「数据放在哪里(升级/卸载都不会删)」+ 备份/恢复/迁移路径改写,
  上线检查清单与使用手册同步

自检:服务端 12 包全绿;console 语法 14 文件 + datadir 单测 11/11;backup.bat 保持纯 ASCII。
2026-09-10 09:17:56 +08:00

710 lines
25 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 { 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
// 大屏实例看板只在管理窗口可见时轮询,窗口隐藏即停表
let instancesTimer = null
let instancesLastReason = 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')
}
// 开关显示以系统实际状态为准:打包后的 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/ 下的账号
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'
}
// 数据目录同理:不带上同一个 -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).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())
})
}
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}`)
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
})
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()
})
}