Files
Scoreboard/console/main/config.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

201 lines
10 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 { serverExeName } = require('./platform')
// 默认值写死在代码里:config.json 只覆盖要改的项。
// 这样升级新增字段时老配置依然能用,现场也不容易配出半成品。
// 注意 server.exe 这里写的是 **Windows** 的名字:它是历史上「随包发布的默认值」,
// 也是 config.json 模板里的值。按平台换值发生在 loadConfig 里,见那里的注释。
const DEFAULTS = {
server: {
exe: 'scoreboard.exe',
// -config 只声明「有个配置文件参数」,值在拉起服务端时被换成解析出的绝对路径:
// 打包态指向 <用户根>\config.yaml,开发态保持这里的相对值(见 userpaths.js)
args: ['-config', 'config.yaml'],
// 数据目录。空串 = 用 Console 选的默认位置(打包态在用户数据目录下、安装目录之外,
// 覆盖安装不会丢数据:Windows 是 %LOCALAPPDATA%\ScoreboardConsole\data,
// Linux 是 $XDG_DATA_HOME\ScoreboardConsole\data);
// 填了就按它来,相对路径以安装目录为基准(详见 datadir.js)
dataDir: '',
host: '127.0.0.1',
port: 19540,
autoStart: true,
healthIntervalMs: 5000,
restartDelayMs: 3000,
// 0 表示无限重启;服务端是现场唯一进程,崩了必须拉起来
maxRestarts: 0,
// 优雅关闭握手:服务端需实现该回环接口并校验 X-Shutdown-Token,
// 未实现(404/连接失败)时回退到进程信号
shutdownPath: '/api/shutdown',
// 服务端受理关闭请求后等待其自行退出的上限,超过则强杀
shutdownTimeoutMs: 5000,
// 连续健康这么久后清零崩溃计数,避免历史崩溃耗尽 maxRestarts
healthyResetMs: 60000
},
display: {
// all-secondary | all | specified
selection: 'all-secondary',
displays: [],
// 只能写大屏实际支持的页名(见 web/src/display/types.ts 的 PAGE_ALIASES)
pages: ['standings', 'medals', 'individual', 'results'],
pageParam: 'page',
kiosk: true,
// 多屏 kiosk 窗口是否**允许点击穿透**(写 true 才穿透)。默认 false = 窗口吞掉点击,
// 可以点活、被盖住后能抢回前台。
// 这个键是 display.ignoreMouse 改名来的,**为什么换键名而不是只改默认值**:
// 旧版默认 true,而且旧版模板已经把 ignoreMouse: true 写进了现场机器的
// <用户根>\console\config.json,userpaths.js 的策略是「已存在就绝不覆盖」——
// 光把默认值改成 false 救不了老机器(文件里那个显式 true 会一直压着默认值),
// 我们也没法区分「操作员刻意要穿透」和「旧模板留下的 true」(去改用户文件就是猜)。
// 换个新键名,老文件里的旧键自然失效,现场不动配置文件就回到不穿透;
// 确实要穿透的再显式写 allowClickThrough: true(旧键会触发一条启动提示,见 loadConfig)
allowClickThrough: false,
reloadEveryHours: 24,
// 服务端迟迟没就绪时的兜底:超时后也把大屏开出来,避免现场一片黑
waitForServerMs: 20000,
backgroundColor: '#0b0f1a'
},
startup: {
// 默认注册登录项但不启用(任务管理器「启动」里可见);服务管理窗口的开关控制是否真的随系统启动
openAtLogin: false
},
admin: {
width: 1280,
height: 860
},
log: {
maxLines: 2000,
file: 'logs/console.log',
maxFileBytes: 4 * 1024 * 1024
}
}
function isPlainObject(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
function merge(base, override) {
const out = isPlainObject(base) ? { ...base } : {}
if (!isPlainObject(override)) return out
for (const [key, value] of Object.entries(override)) {
if (value === undefined) continue
out[key] = isPlainObject(value) && isPlainObject(base[key]) ? merge(base[key], value) : value
}
return out
}
function readJson(file) {
try {
const raw = fs.readFileSync(file, 'utf8')
return { values: JSON.parse(raw), warning: null }
} catch (err) {
if (err.code === 'ENOENT') return { values: {}, warning: `配置文件不存在,使用内置默认值:${file}` }
return { values: {}, warning: `配置文件无法解析,使用内置默认值:${file}(${err.message})` }
}
}
// 改名过的配置键:旧键 → 新键。只提示、**不自动迁移**——改现场机器上的配置文件不是
// Console 该做的事,而且「操作员刻意写的」和「旧模板留下的」在文件里长得一模一样,猜错就改了人家的意图。
const RENAMED_KEYS = {
'display.ignoreMouse': 'display.allowClickThrough'
}
// 看看持久化的配置文件里还留着哪些废弃键。读的是**文件里真实写了什么**(readJson 的原始值),
// 不是 merge 之后的结果:merge 出来的值一律带 DEFAULTS,拿它判断会把每个键都当成「用户写过」。
// 只在这里算一次、由 index.js 在启动时记一次,绝不在 layout/重排路径里判断,否则日志会被刷爆。
function deprecatedKeyNotices(values) {
const notices = []
// 配置文件内容可能是 null / 数组 / 字符串(JSON 语法合法但不是对象),一律当作「没有旧键」
if (!isPlainObject(values)) return notices
for (const [oldKey, newKey] of Object.entries(RENAMED_KEYS)) {
const [section, key] = oldKey.split('.')
if (!isPlainObject(values[section]) || !(key in values[section])) continue
notices.push(
`配置项 ${oldKey} 已废弃、不再生效:本机配置文件里写的 ${key}: ${JSON.stringify(values[section][key])} 会被忽略。` +
`大屏窗口现在默认「不穿透」,点击不会再漏到大屏后面的窗口或桌面;` +
`要让大屏恢复点击穿透,请改用 ${newKey}: true`
)
}
return notices
}
// 把改名前的旧键从**合并后的配置**里删掉:要让「旧键失效」彻底一点,
// 不能只做到「没人读它」——merge 是通用深合并,旧键会原样留在 cfg.values.display 里,
// 以后谁再加一行读旧键的代码,就会读到老机器配置文件里那个陈年 true。
// 只动这份内存里的配置,绝不写回用户文件(现场那份要原样留着,人自己决定怎么改)。
function stripRenamedKeys(values) {
for (const oldKey of Object.keys(RENAMED_KEYS)) {
const [section, key] = oldKey.split('.')
if (isPlainObject(values[section])) delete values[section][key]
}
return values
}
// platform 缺省按 Windows 解析(改动前的口径),生产调用点传 process.platform
function loadConfig(configPath, platform = 'win32') {
const { values, warning } = readJson(configPath)
const merged = stripRenamedKeys(merge(DEFAULTS, values))
// 服务端可执行文件名按平台取:Windows 是 scoreboard.exe,Linux 是 scoreboard。
// 判断「文件里写没写过」要看**文件里真实写了什么**(readJson 的原始值),
// 与 deprecatedKeyNotices 同一套口径:merge 之后的值一律带 DEFAULTS,拿它判断会把
// 每个键都当成「用户写过」。另外,文件里写的正好是 Windows 那个默认名(随包 config.json
// 模板就是它,老机器上的用户配置也是从模板复制来的)时,在非 Windows 平台按「没写」处理——
// 那不是现场改过的名字,照它去找一个不存在的 .exe 只会「未找到服务端程序」。
// 与 RENAMED_KEYS 同一个道理:模板落进老文件里的值,光改默认值救不了。
const configured = isPlainObject(values) && isPlainObject(values.server) ? values.server.exe : undefined
const customized =
typeof configured === 'string' &&
configured.trim() !== '' &&
!(platform !== 'win32' && configured.trim() === DEFAULTS.server.exe)
// server 段被写成 null / 数组时(merge 会原样保留)不在这里报错:以前也是留到真正
// 用它的时候才抛,别在这一步凭空多出一个崩溃点
if (!customized && isPlainObject(merged.server)) merged.server.exe = serverExeName(platform)
return {
path: configPath,
values: merged,
warning,
// 提示按**文件里真实写了什么**算,所以传的是 readJson 的原始值,不是上面那份合并结果
notices: deprecatedKeyNotices(values)
}
}
// 就地改配置并写回文件:只动 raw 里已有的键,不把 DEFAULTS 全量落盘,
// 否则升级新增的默认值会被老文件冻结成显式值。
function updateConfig(configPath, mutate) {
const { values } = readJson(configPath)
const raw = isPlainObject(values) ? values : {}
mutate(raw)
fs.mkdirSync(path.dirname(configPath), { recursive: true })
fs.writeFileSync(configPath, JSON.stringify(raw, null, 2) + '\n', 'utf8')
return raw
}
// 相对路径的 exe 需要按「部署包根目录」理解,而部署包根目录在不同形态下不同:
// 开发时是仓库的 console/,打包后是 exe 所在目录。逐个候选位置探测,避免写死。
// baseDir 是显式给出的部署包根目录;不传时退回「按 configPath 所在目录推断」——
// 打包态 config.json 已经搬到用户数据目录,那里推不出安装目录,必须显式传。
function resolveExecutable(exe, configPath, baseDir) {
if (!exe) return { path: null, candidates: [] }
if (path.isAbsolute(exe)) return { path: exe, candidates: [exe] }
const configDir = baseDir ? path.resolve(baseDir) : path.dirname(configPath)
const candidates = [
configDir,
process.cwd(),
path.resolve(configDir, '..'),
path.resolve(configDir, '..', 'dist')
].map((dir) => path.resolve(dir, exe))
const found = candidates.find((candidate) => fs.existsSync(candidate))
return { path: found || candidates[0], candidates: [...new Set(candidates)] }
}
// DEFAULTS 一并导出:displays.test.js 用它断言「随包发布的默认值」本身
// (例如 display.allowClickThrough 必须是不穿透),测试里自己写死一份就验不到默认值被改回去
module.exports = { DEFAULTS, loadConfig, updateConfig, resolveExecutable }