'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()) }) } 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() }) }