Files
Scoreboard/console/main/index.js
T
AskaEth efa049c827 feat(platform): Console 支持 Linux(arm64),Windows 行为逐字不变;版本 dev_0.5.10
需求:让积分榜能在飞腾 D2000(ARM64)的 Linux 上跑起来,**同时不破坏 Windows**。

## 一、Go 服务端:交叉编译即可(**已实测**)
- SQLite 用的是 `modernc.org/sqlite`(纯 Go 驱动),全项目**没有一处 `import "C"`** → `CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build` 直接出;
- 产物是**静态链接**(`ELF 64-bit LSB executable, ARM aarch64, statically linked`)→ **不依赖 glibc 版本**,对国产系统友好;
- 本机(arm64)**实际启动验证**:数据目录、局域网地址探测、数据库迁移(0001/0002/0003)全部正常;
- 产物:`debug/服务端-arm64/scoreboard`(29 MB,`-s -w`)。

## 二、Console(Electron 宿主):平台差异收口到一个新文件
- **新增 `console/main/platform.js`**:平台差异集中在这 45 行里,其余代码不写平台判断。
  ⚠️ **`platform` 参数缺省按 Windows 解析** —— 漏传参数走的就是改动前那条老路(这比"改了再小心"更保守:**默认行为不变,新行为必须显式传参**)。
- `userpaths.js` / `datadir.js`:Windows 分支 `%LOCALAPPDATA%\ScoreboardConsole\` **逐字保留**;Linux 分支按 XDG(`$XDG_DATA_HOME`,缺省 `~/.local/share`)。两平台各自的"拿不到用户目录"提示文案分开写(免得现场去设一个本平台不存在的环境变量)。
- `config.js`:服务端文件名按平台取(Windows `scoreboard.exe` / Linux `scoreboard`)。
- **新增 `console/main/autostart.js`**:Linux 用 `~/.config/autostart/*.desktop`(开=写文件、关=删文件;Linux 没有"已注册但未启用"这个中间态)。
- 托盘与窗口行为:Linux 的通知区多数不派发 double-click,因此按平台分支成单击。

## 三、怎么保证没弄坏 Windows
1. **设计上**:平台差异集中 + 缺省值就是 Windows(见上);
2. **逐行核对 diff**:Windows 分支那几行原样未动;
3. **测试**:Console 由 **68 → 96 例**,新增全是平台分支用例,**既有断言一条没改**;
4. **真机验证**:构建机重打 Windows 包并跑(这一道才算收口)。

## 四、自检
全量自检两轮**全绿、`EXIT=0`**:服务端 14 包 / 前端 **944 例** / Console **96 例**。

## 版本
`dev_0.5.9` → **`dev_0.5.10`**;安装器素材同步重生成(照例只动 `brand.bmp` 两张)。
2026-09-15 00:53:16 +08:00

823 lines
32 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 { 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 { isAutostartEnabled, setAutostart } = require('./autostart')
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;Linux 读
// ~/.config/autostart 里那个 .desktop 在不在;其余回落配置值。
function openAtLoginEffective() {
const configured = cfg ? cfg.values.startup.openAtLogin === true : false
if (!app.isPackaged || (process.platform !== 'win32' && process.platform !== 'linux')) {
return configured
}
if (process.platform === 'linux') {
// Linux 不给「已注册但未启用」这种中间态:文件在就是开、不在就是关(见 autostart.js)
return isAutostartEnabled({ platform: 'linux', env: process.env })
}
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' || process.platform === 'linux'),
appVersion: app.getVersion(),
// 渲染层按同一个上限裁剪日志节点(见 renderer/app.js appendLog):
// 主进程只留最后 maxLines 行,页面若只增不减,长赛事跑一天会把窗口拖卡
logMaxLines: cfg ? cfg.values.log.maxLines : null
}
}
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())
// 关窗只隐藏,服务端和守护逻辑要继续在托盘里跑。
// 前提是托盘真的建起来了:托盘没建起来时(Linux 的通知区宿主可能缺失)
// 藏起来就再也叫不回来,那种情况按普通窗口语义退出。
adminWindow.on('close', (event) => {
if (quitting) return
if (!tray || tray.isDestroyed()) {
quitting = true
app.quit()
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 === 'restarting' ||
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())
// Linux 的通知区(appindicator / GtkStatusIcon)多数不派发 double-click,只有单击;
// 不补这一条,托盘就成了一个点不开的东西(Windows 上照旧走上面那条,行为不变)
if (process.platform === 'linux') tray.on('click', () => showAdmin())
}
// 开机自启:只对打包后的程序有意义,开发态注册的是 electron 可执行文件。
// Windows 上始终注册登录项(任务管理器「启动」里能看到),用 enabled 控制是否真的拉起:
// 实测 {openAtLogin:true, enabled:false} → openAtLogin=true 且 executableWillLaunchAtLogin=false,
// 即「已注册但未启用」。macOS 没有 enabled 参数,只能注册/注销。
// Linux 没有登录项 API(setLoginItemSettings 是空实现),靠 ~/.config/autostart 下的
// .desktop 文件,且没有「已注册但未启用」这个中间态:开=写文件、关=删文件(见 autostart.js)。
function applyOpenAtLogin() {
if (!app.isPackaged) return
const wanted = cfg.values.startup?.openAtLogin === true
if (process.platform === 'linux') {
try {
setAutostart({ platform: 'linux', env: process.env, exePath: app.getPath('exe'), enabled: wanted })
log.push('console', wanted ? '开机自启已启用' : '开机自启已关闭')
} catch (err) {
log.push('console', `设置开机自启失败:${err.message}`)
}
return
}
if (process.platform !== 'win32' && process.platform !== 'darwin') return
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 }
}
// 把开关落到系统上;写不成功就抛,由调用方回滚配置。Linux 与 Windows 走的是两套机制
function applySystemOpenAtLogin(wanted) {
if (process.platform === 'linux') {
setAutostart({ platform: 'linux', env: process.env, exePath: app.getPath('exe'), enabled: wanted })
return
}
app.setLoginItemSettings(loginItemSettings(wanted))
}
// 服务管理窗口的「开机自启」开关:Windows 上登录项一直保留,只切换是否真正随系统拉起;
// Linux 上直接对应 .desktop 文件的增删(见 autostart.js)。
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}` }
}
const supported = process.platform === 'win32' || process.platform === 'darwin' || process.platform === 'linux'
if (app.isPackaged && supported) {
try {
applySystemOpenAtLogin(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
? '开机自启已启用'
: process.platform === 'linux'
? '开机自启已关闭(已删除自启文件)'
: '开机自启已关闭(登录项保留,任务管理器里可见)'
)
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 : ''
}
// 账号重置走同目录的服务端程序(Windows scoreboard.exe / Linux scoreboard)的 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-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())
})
ipcMain.handle('window:is-fullscreen', (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
return Boolean(win && !win.isDestroyed() && win.isFullScreen())
})
}
app.whenReady().then(() => {
const bundleDir = appDir()
// 配置与规则跟数据目录一样搬出安装目录(见 userpaths.js)。这一步在加载配置之前做:
// Console 自己的 config.json 也要先落到用户目录。失败只记日志,攒着等日志器建好再写。
const setupNotes = []
userPaths = prepareUserPaths({
plan: planUserPaths({
installDir: bundleDir,
userRoot: resolveUserRoot(process.env, process.platform),
isPackaged: app.isPackaged,
platform: process.platform
}),
log: (message) => setupNotes.push(message)
})
cfg = loadConfig(resolveConfigPath(), process.platform)
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)
// 配置里还留着改名前的旧键(如 display.ignoreMouse):只在启动这里提示一次。
// 判断与文案都在 config.js 的 loadConfig 里算好,这里只负责落日志——
// 大屏重排路径(displays.js)绝不碰这件事,否则每次插拔屏幕都要刷一行
for (const notice of cfg.notices) log.push('console', `警告:${notice}`)
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,
platform: process.platform
})
// 老版本把数据放在安装目录里,而覆盖安装会先静默跑旧卸载器把安装目录整个删掉,
// 所以先做一次性迁移。只在打包态做,开发态不动仓库里的 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()
// 托盘只是操作入口,服务端守护必须照常起来。Linux 的通知区宿主不一定存在
// (精简桌面、纯 X11 无 StatusNotifier 时 new Tray 会直接抛错),抛出去会把整个
// whenReady 打断、连服务端守护都起不来,所以只在 Linux 上兜住这一下:
// Windows 的托盘一定在,那边保持原样(出错照旧抛出去),不改 Windows 的行为。
// 托盘没建起来时管理窗口的「关闭」按退出处理(见 createAdminWindow),
// 否则窗口一藏起来就再也叫不回来。
if (process.platform === 'linux') {
try {
createTray()
} catch (err) {
tray = null
log.push('console', `托盘创建失败:${err.message}(管理窗口与服务端守护继续运行)`)
}
} else {
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()
})
}