'use strict' const path = require('path') const { app, BrowserWindow, Menu, Tray, ipcMain, powerSaveBlocker, shell } = 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') // 现场只允许一个实例:两个 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 tray = null let powerBlockerId = null let displayFallbackTimer = null let displaysEnabled = false let quitting = false let shutdownDone = 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: 'Scoreboard Console · 启动管理', backgroundColor: '#0f1420', show: false, webPreferences: { preload: path.join(__dirname, '..', 'renderer', 'preload.js'), contextIsolation: true, nodeIntegration: false, sandbox: true } }) 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 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: () => shell.openExternal(status.adminUrl || `http://127.0.0.1:${cfg.values.server.port}/admin`) }, { 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()) } function registerIpc() { ipcMain.handle('service:get-status', () => statusSnapshot()) 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-external', (_event, url) => { // 只放行 http/https,避免管理窗口被注入的链接拉去执行本地协议 if (typeof url === 'string' && /^https?:\/\//i.test(url)) { shell.openExternal(url) return true } return false }) ipcMain.handle('app:open-admin-window', () => { showAdmin() return true }) } app.on('second-instance', () => showAdmin()) 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) 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() if (!status.healthy) return // 服务端恢复后必须让大屏重新加载,否则页面还停在断线兜底画面上 if (displaysEnabled) displays.relayout() else ensureDisplays() }) createAdminWindow() createTray() registerIpc() // 防熄屏要在服务端起来之前就生效,否则开机后第一段时间屏幕仍会休眠 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() }) }