'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 serverAdminWindow = 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, // 标题栏由页面自绘;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('closed', () => { adminWindow = null }) } function adminPanelUrl() { // 复用服务端的地址推导:host 配成 0.0.0.0 时本机也要走回环 if (service) return service.status.adminUrl const host = (cfg.values.server.host || '127.0.0.1') === '0.0.0.0' ? '127.0.0.1' : cfg.values.server.host return `http://${host || '127.0.0.1'}:${cfg.values.server.port}/admin` } 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: 'Scoreboard · 服务端管理面板', 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 (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: () => 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()) } 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 }) ipcMain.handle('app:open-server-admin', () => { showServerAdmin() return true }) // 窗口控制统一从 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.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() // 服务停了,大屏已无数据可显示,同步关掉扩展屏窗口; // 放在状态回调里,IPC 按钮和托盘菜单两条停止路径都能覆盖。 if (status.state === 'stopped') { if (displayFallbackTimer) { clearTimeout(displayFallbackTimer) displayFallbackTimer = null } if (displays && displaysEnabled) { displays.closeAll() log.push('console', '服务已停止,大屏窗口已关闭') } displaysEnabled = false return } 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() }) }