Files
Scoreboard/console/main/index.js
T
AskaEth 0c9a6b6a27 fix(console): 开机自启改为「注册但不启用」,开关切换 enabled
上一版把「关闭」实现成不写登录项。实际在 Windows 上实测 Electron:
  setLoginItemSettings({ openAtLogin: true, enabled: false })
  → openAtLogin=true、launchItems[0].enabled=false、executableWillLaunchAtLogin=false
即登录项已注册(任务管理器「启动」里可见、显示为已禁用)但不会随系统拉起。
所以改成始终注册登录项、只用 enabled 控制是否真正启动,语义与用户要求一致:
  - applyOpenAtLogin / setOpenAtLogin 都走 loginItemSettings(wanted)
  - macOS 无 enabled 参数,仍按注册/注销处理
  - 状态快照的 openAtLogin 以系统实际状态为准(打包后读 executableWillLaunchAtLogin)
  - 文档同步:默认「已注册但未启用」
2026-09-10 00:09:49 +08:00

616 lines
21 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, updateConfig, resolveExecutable } = require('./config')
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
// 大屏实例看板只在管理窗口可见时轮询,窗口隐藏即停表
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
})
}
// 渲染层要随时知道最大化状态,才能在「最大化/还原」之间换图标
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'
}
// 账号重置走同目录的 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 && displays.enabled) displays.relayout()
return statusSnapshot()
})
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}`)
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 && 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()
})
}