fix: 大屏背景图被兜底渐变盖住 + 配置/规则也搬到用户数据目录

1) 大屏背景图不生效:App.vue 里 .bg 与 .bg-fallback 都是 absolute inset:0,
   而兜底渐变是不透明的且在图片之后绘制 → 配了背景图也只看得到渐变。
   改成 v-if/v-else 互斥(用无头 Chromium 真渲染对照验证:修复后取到图片像素,
   回退版本只剩渐变)。新增 background-layer.test.ts 锁住层次关系。

2) 规则/config 仍在安装目录,覆盖安装/卸载会连配置一起删:现在三样都落在
   %LOCALAPPDATA%\ScoreboardConsole\(与数据目录同一基座):
   - console\config.json(Console 读写都用这份)
   - config.yaml(Console 用绝对路径 -config 传给服务端与 passwd)
   - rules\(首次从安装目录复制过去,之后只认用户目录那份)
   首次运行复制迁移、已存在不覆盖、不可写则回落安装目录并记日志(不影响启动);
   开发态行为不变。备份脚本与文档同步。
   注意 resolveExecutable 必须显式传部署包根目录,否则会去用户目录找 scoreboard.exe。

自检:服务端 11 包全绿;console 语法 16 文件 + node:test 20/20;
前端 vue-tsc + build、node --test 69/69。
This commit is contained in:
AskaEth committed 2026-09-10 10:06:21 +08:00
1 parent 2d207fd654
commit 407abec3b0
15 files changed
+759 -81

No files matched your search

+42 -15
View File
@@ -16,12 +16,14 @@
```
console/
├── package.json # main 指向 main/index.js;electron-builder 配置
├── config.json # 运行配置(端口 / exe 路径 / 选屏策略)
├── config.json # 运行配置模板(打包后首次启动复制到用户数据目录,改这里的只在开发态生效)
├── main/
│ ├── index.js # 应用入口:窗口 / 托盘 / IPC / 生命周期
│ ├── config.js # 配置加载与合并、exe 路径解析
│ ├── datadir.js # 数据目录解析 + 旧数据一次性迁移
│ ├── datadir.test.js # 数据目录的单元测试(node --test main/datadir.test.js)
│ ├── datadir.test.js # 数据目录的单元测试
│ ├── userpaths.js # 配置/规则目录解析 + 首次复制迁移(config.json / config.yaml / rules)
│ ├── userpaths.test.js # 配置/规则路径的单元测试
│ ├── service.js # 服务端子进程守护:spawn / 日志转发 / 健康检查 / 自动重启
│ ├── instances.js # 已连接大屏实例看板:复用管理面板登录态读 /api/display/instances
│ ├── account.js # 管理员账号重置:参数校验 + 调 scoreboard.exe passwd
@@ -60,16 +62,16 @@ pnpm start # 等价于 electron .
scoreboard-windows-amd64/
├── Scoreboard_Console.exe
├── resources/app.asar # 内含 main/ renderer/ config.json
├── config.json # exe 旁边的可编辑配置
├── config.json # exe 旁边的配置模板(首次启动复制到用户数据目录)
├── scoreboard.exe
├── config.yaml
├── rules/
└── logs/console.log # 运行时生成
├── config.yaml # 服务端配置模板(同上)
├── rules/ # 规则包模板(同上)
└── logs/console.log # 运行时生成(日志仍留在安装目录)
```
数据不在安装目录里,默认落在 `%LOCALAPPDATA%\ScoreboardConsole\data`(见下文「数据目录」)。
数据、配置与规则都不在安装目录里,都在 `%LOCALAPPDATA%\ScoreboardConsole\` 下(见下文两节)。
打包后 `config.json` 读取顺序:`SCOREBOARD_CONSOLE_CONFIG` 环境变量 → exe 同目录的 `config.json`。
打包后 `config.json` 读取顺序:`SCOREBOARD_CONSOLE_CONFIG` 环境变量 → 用户数据目录 `<用户根>\console\config.json`(首次启动从 exe 旁边的模板复制)。开发态(`pnpm start`)三者都不接管,仍用仓库里的 `console/config.json`。
## 数据目录(重要)
@@ -89,6 +91,28 @@ electron-builder 生成的卸载器结尾是 `RMDir /r $INSTDIR`,而**覆盖
改完 `datadir.js` 跑 `node --test main/datadir.test.js`(Node 内置测试运行器,无额外依赖)。
## 配置与规则目录(重要)
同样因为卸载器会删掉整个安装目录,随包的 `config.json`、`config.yaml`、`rules\` 也不放在安装目录里,而是跟数据目录同一基座:
| 内容 | 打包态(`planUserPaths` mode=`user`) | 开发态(mode=`install`) |
| --- | --- | --- |
| Console 配置 | `<用户根>\console\config.json` | 仓库 `console/config.json` |
| 服务端配置 | `<用户根>\config.yaml` | 仓库 `config.yaml` 的位置不变 |
| 规则目录 | `<用户根>\rules\` | 仓库 `rules/` |
(`<用户根>` = `%LOCALAPPDATA%\ScoreboardConsole`,与数据目录同一处。)
- `userpaths.js` 只做三件事:解析这三个落点、首次复制、失败回落;`planUserPaths` 是纯函数(只算路径),`prepareUserPaths` 才碰磁盘,便于单测;
- **首次运行复制**:目标不存在且安装目录里有那份模板 → `fs.copyFileSync` / `fs.cpSync`(规则目录先拷到 `<target>.tmp-<pid>` 再整体 rename,失败不会留下半份)。安装目录那份**留作模板**,不做移动;
- **已存在就绝不覆盖**:用户目录里已经有的那份一律优先,既不覆盖也不删除(用户改过的配置、自己加的规则包都不会被模板顶掉);
- 拉起服务端时 `-config` 的值被换成 `<用户根>\config.yaml` 的绝对路径(`withConfigArg`:参数里有 `-config` 就改值,没有就补一条),`passwd` 用同一份;这样服务端读的配置不再取决于 cwd;
- 任一项目标目录建不出来或不可写时只记日志并**回落到安装目录里那份**(`serverConfig` 回落时返回 `null`,拉起服务端时沿用 `server.args` 原值),绝不抛异常、绝不删用户文件;
- 拿不到 `%LOCALAPPDATA%` 时三项全回落安装目录并记一条警告;
- 日志文件 `logs/console.log` 是个例外:仍然相对**部署包根目录**(打包态 = 安装目录,开发态 = `console/`)解析,不跟着配置搬(易失的排查材料,搬了反而与文档、现场习惯不一致)。
改完 `userpaths.js` 跑 `node --test main/*.test.js`。
## 行为说明
| 场景 | 行为 |
@@ -101,6 +125,7 @@ electron-builder 生成的卸载器结尾是 `RMDir /r $INSTDIR`,而**覆盖
| 服务端就绪 | 首次 `/api/health` 通过后开大屏窗口;等待超过 `display.waitForServerMs` 也先开,避免现场只见桌面 |
| 服务端崩溃 | 按 `restartDelayMs` 递增退避自动重启(`maxRestarts=0` 为无限);持续健康 `healthyResetMs` 后崩溃计数清零;恢复健康后自动重载大屏 |
| 数据目录 | 默认 `%LOCALAPPDATA%\ScoreboardConsole\data`(安装目录之外,覆盖安装/卸载都不删),启动时按 `-data <绝对路径>` 交给服务端;打包态首次启动做一次性迁移(见「数据目录」一节),开发态不接管 |
| 配置与规则 | 同样在 `%LOCALAPPDATA%\ScoreboardConsole\`(`console\config.json`、`config.yaml`、`rules\`),打包态首次从安装目录复制、之后只用用户目录那份;拉起服务端时 `-config` 传绝对路径(见「配置与规则目录」一节) |
| 停止服务 | 先 `POST server.shutdownPath` 并带 `X-Shutdown-Token`(启动时用 `crypto.randomBytes(32)` 生成,经环境变量 `SCOREBOARD_SHUTDOWN_TOKEN` 只交给该子进程)请求服务端优雅关闭;受理后最多等 `shutdownTimeoutMs`,超时或接口不可用(404/连接失败)再强杀 |
| 已连接大屏实例 | 管理窗口打开时每 5 秒读一次服务端 `GET /api/display/instances`(复用管理面板的 `scoreboard_session` Cookie,需记录员及以上),显示实例数、页面名与在线状态;窗口隐藏即停表,未登录/服务未启动时显示占位文案 |
| 关管理窗口 | 只隐藏到托盘,服务端继续运行;退出必须走托盘「退出」 |
@@ -109,15 +134,16 @@ electron-builder 生成的卸载器结尾是 `RMDir /r $INSTDIR`,而**覆盖
| 服务端管理面板 | 用独立无边框 Electron 窗口加载 `http://127.0.0.1:<port>/admin`(`shell-preload.js` 暴露 `window.desktopShell`),不再调系统浏览器;面板页在浏览器里直接打开时标题栏自动不渲染 |
| 显示器菜单 | 面板页的「大屏控制 → 显示器」卡片经 `display:list` / `display:open` / `display:close` / `display:relayout` 指挥本机开窗:按屏创建、聚焦、收窗。**全屏只在「多于一块屏 + 非主屏」时放行**,判定在主进程做,页面传 `fullscreen: true` 也会被降级为窗口化;面板页只拿到这几条白名单通道,拿不到 `ipcRenderer` |
| 管理员账号 | 主进程 `spawn` 同目录 `scoreboard.exe passwd -config <同一份配置> -data <同一个数据目录> -user <当前用户名> -new-username <新用户名> -password <新密码>`(留空 = 不改 / 随机生成)。本机进程、不经 HTTP,因此免登录验证;用户名/密码含空格、制表符或换行会被主进程拒绝,密码不进日志。`passwd` 会作废该用户全部会话,UI 提交前二次确认并提示 |
| 日志 | 内存环形缓冲(`log.maxLines`)+ 文件 `logs/console.log`(超 `maxFileBytes` 轮转一份 `.1`,轮转前会删掉旧 `.1` 以兼容 Windows) |
| 日志 | 内存环形缓冲(`log.maxLines`)+ 文件 `logs/console.log`(超 `maxFileBytes` 轮转一份 `.1`,轮转前会删掉旧 `.1` 以兼容 Windows);文件固定落在部署包根目录(打包态 = 安装目录),不随配置搬到用户数据目录 |
| 服务管理窗口的路径显示 | 「服务端程序」「配置文件」两行分别是解析出的 `scoreboard.exe` 与 Console 自己那份 `config.json` 的绝对路径(打包态后者在用户数据目录里,见上一节) |
## 配置项(`config.json`)
## 配置项(`config.json`;打包态生效的是用户数据目录里那份)
| 键 | 默认值 | 说明 |
| --- | --- | --- |
| `server.exe` | `scoreboard.exe` | 相对路径按上文顺序探测,也可写绝对路径 |
| `server.args` | `["-config","config.yaml"]` | 传给服务端的参数 |
| `server.dataDir` | `""` | 数据目录;空串 = 用默认位置(打包态 `%LOCALAPPDATA%\ScoreboardConsole\data`),填了就按它来(相对路径以安装目录为基准)。Console 会把它作为 `-data <绝对路径>` 追加到服务端参数后 |
| `server.exe` | `scoreboard.exe` | 相对路径会以部署包根目录(打包态 = exe 所在目录,开发态 = `console/`)、当前目录、上级目录、`dist\` 依次探测,也可写绝对路径 |
| `server.args` | `["-config","config.yaml"]` | 传给服务端的参数;其中 `-config` 的值会被换成解析出的绝对路径(`<用户根>\config.yaml`),其余参数(如 `-project`)原样传 |
| `server.dataDir` | `""` | 数据目录;空串 = 用默认位置(打包态 `%LOCALAPPDATA%\ScoreboardConsole\data`),填了就按它来(相对路径以部署包根目录为基准)。Console 会把它作为 `-data <绝对路径>` 追加到服务端参数后 |
| `server.host` / `server.port` | `127.0.0.1` / `19540` | 健康检查、关闭握手与管理面板地址(`0.0.0.0` 会自动换成回环地址探测) |
| `server.autoStart` | `true` | Console 启动后是否立刻拉起服务端 |
| `server.healthIntervalMs` | `5000` | 健康检查间隔 |
@@ -137,7 +163,7 @@ electron-builder 生成的卸载器结尾是 `RMDir /r $INSTDIR`,而**覆盖
| `display.backgroundColor` | `#0b0f1a` | 窗口底色,减少加载白闪 |
| `startup.openAtLogin` | `false` | 默认注册但不启用(任务管理器可见、已禁用);在服务管理窗口「开机自启」开关里控制(开发态不注册) |
| `admin.width` / `admin.height` | `1040` / `760` | 管理窗口初始尺寸(服务端管理面板窗口按 `width` 不小于 1100 取用) |
| `log.maxLines` / `log.file` / `log.maxFileBytes` | `2000` / `logs/console.log` / `4194304` | 日志缓冲与文件 |
| `log.maxLines` / `log.file` / `log.maxFileBytes` | `2000` / `logs/console.log` / `4194304` | 日志缓冲与文件;`log.file` 相对部署包根目录解析(不随配置搬到用户数据目录) |
配置缺项自动用内置默认值,文件不存在或 JSON 语法错误时回落到全默认值,并在管理窗口顶部给出提示。
@@ -154,7 +180,7 @@ pnpm pack # 只出目录,不生成安装包,便于现场调试
```
- 产物:NSIS 安装包 + zip(`Scoreboard Console-0.1.0-x64.*`)。
- `extraFiles` 会把 `../dist/scoreboard.exe`、`../config.yaml`、`config.json`(exe 旁的可编辑配置,缺失时 Console 会回落到内置默认值)与 `../rules` 拷到安装目录,最终形成第 4 节里的部署包形态。
- `extraFiles` 会把 `../dist/scoreboard.exe`、`../config.yaml`、`config.json`、`../rules` 拷到安装目录 —— 这四样里的后三样(`config.json`、`config.yaml`、`rules/`)在打包态只当**模板**:首次启动时复制到用户数据目录,之后 Console 与 `backup.bat` 都只用用户目录那份(见「配置与规则目录」一节)。`config.json` 缺失时 Console 会回落到内置默认值。
- `使用说明.txt` 是可选的:`deploy/build.bat` 在打包前依次找 `docs/使用说明.txt`、`docs/使用手册.md`,命中就暂存到 `console/extra/使用说明.txt`(`extraFiles` 会把该目录内容平铺到部署包根目录),都没有就跳过并在构建日志里注明。
- 图标在 `resources/icon.ico`(`package.json` 的 `win.icon` 指向它)。
- 主程序文件名由 `win.executableName` 决定,现在是 `Scoreboard_Console.exe`;`productName` 仍是 `Scoreboard Console`(显示名、`userData` 目录不变)。
@@ -171,4 +197,5 @@ pnpm pack # 只出目录,不生成安装包,便于现场调试
- 托盘图标在 Windows 任务栏的显示效果(16px 缩放)。
- `scoreboard.exe` 真实拉起、日志转发、健康检查与崩溃重启的端到端链路。
- 数据目录迁移实机路径:老版本安装目录里的 `data\` 是否被整体搬到 `%LOCALAPPDATA%\ScoreboardConsole\data`、跨盘安装(安装目录在 D 盘、`%LOCALAPPDATA%` 在 C 盘)时的失败日志与「旧数据原地保留」表现。
- 配置/规则首次复制实机路径:老版本升级到本版本后,安装目录里的 `config.json` / `config.yaml` / `rules\` 是否被复制到 `%LOCALAPPDATA%\ScoreboardConsole\` 下对应位置、改过配置后覆盖安装是否仍然生效、`%LOCALAPPDATA%` 不可写(或被杀毒软件拦截)时是否只记日志并回落到安装目录里那份。
- electron-builder 打包已通过 `deploy\build.bat` 实跑;NSIS 安装包在真机上的安装 / 卸载流程尚未验证。
+2 -2
View File
@@ -35,8 +35,8 @@ function runPasswd({ exePath, args, onOutput, timeoutMs = DEFAULT_TIMEOUT_MS })
return new Promise((resolve) => {
let child
try {
// cwd 取 exe 所在目录:config.yaml 与 rules/ 都跟 exe 平级
// (数据目录由 -data 显式指定,不依赖 cwd)
// cwd 取 exe 所在目录(部署包根目录);config.yaml 与数据目录都由参数给绝对路径,
// 不依赖 cwd
child = spawn(exePath, args, {
cwd: path.dirname(exePath),
windowsHide: true,
+7 -3
View File
@@ -8,6 +8,8 @@ const path = require('path')
const DEFAULTS = {
server: {
exe: 'scoreboard.exe',
// -config 只声明「有个配置文件参数」,值在拉起服务端时被换成解析出的绝对路径:
// 打包态指向 <用户根>\config.yaml,开发态保持这里的相对值(见 userpaths.js)
args: ['-config', 'config.yaml'],
// 数据目录。空串 = 用 Console 选的默认位置(打包态是
// %LOCALAPPDATA%\ScoreboardConsole\data,装在安装目录之外,覆盖安装不会丢数据);
@@ -102,12 +104,14 @@ function updateConfig(configPath, mutate) {
}
// 相对路径的 exe 需要按「部署包根目录」理解,而部署包根目录在不同形态下不同:
// 开发时是 console/ 的上级,打包后是 exe 所在目录。逐个候选位置探测,避免写死。
function resolveExecutable(exe, configPath) {
// 开发时是仓库的 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 = path.dirname(configPath)
const configDir = baseDir ? path.resolve(baseDir) : path.dirname(configPath)
const candidates = [
configDir,
process.cwd(),
+2 -1
View File
@@ -6,6 +6,7 @@ const path = require('path')
// 数据目录必须落在安装目录之外:electron-builder 的卸载器结尾是 `RMDir /r $INSTDIR`,
// 而覆盖安装会先静默跑一遍旧版卸载器 → 装在安装目录里的 data/ 会被整体删掉。
// 也不能放 %APPDATA%\<AppName>(卸载器同样会删),所以默认落在 %LOCALAPPDATA%\ScoreboardConsole\data。
// 配置与规则目录(userpaths.js)用同一个基座,这里导出给它复用。
const LOCAL_BASE = 'ScoreboardConsole'
const DATA_SUBDIR = 'data'
@@ -109,4 +110,4 @@ function migrateLegacyData({ targetDir, legacyDir, log }) {
return { migrated: true, reason: 'moved' }
}
module.exports = { resolveDataDir, migrateLegacyData, isInside }
module.exports = { resolveDataDir, migrateLegacyData, isInside, LOCAL_BASE }
+41 -10
View File
@@ -14,6 +14,7 @@ const {
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')
@@ -43,21 +44,29 @@ function main() {
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)
}
// 打包后 config.json 放在 exe 旁边,方便现场直接改,不藏在 asar 里
if (app.isPackaged) {
return path.join(path.dirname(app.getPath('exe')), 'config.json')
}
return path.join(__dirname, '..', 'config.json')
return userPaths ? userPaths.consoleConfig : path.join(__dirname, '..', 'config.json')
}
// 开关显示以系统实际状态为准:打包后的 Windows 读登录项 enabled,其余回落配置值。
@@ -424,8 +433,10 @@ function main() {
return { ok: true, openAtLogin: wanted }
}
// 服务端用什么配置文件,passwd 就得用同一份,否则改的是另一个 data/ 下的账号
// 服务端用什么配置文件,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]) {
@@ -456,7 +467,7 @@ function main() {
validateAccountField(password, '新密码', false)
if (invalid) return { ok: false, error: invalid }
const exe = exePath || resolveExecutable(cfg.values.server.exe, cfg.path).path
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}` }
}
@@ -561,17 +572,36 @@ function main() {
}
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,
file: path.resolve(path.dirname(cfg.path), cfg.values.log.file || 'logs/console.log'),
// 日志固定在部署包根目录的 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)
const resolved = resolveExecutable(cfg.values.server.exe, cfg.path, bundleDir)
exePath = resolved.path
log.push('console', `服务端程序:${resolved.path}`)
@@ -604,7 +634,8 @@ function main() {
values: cfg.values,
exePath: resolved.path,
log,
dataDir: dataDirInfo.passToServer ? dataDirInfo.dir : null
dataDir: dataDirInfo.passToServer ? dataDirInfo.dir : null,
configPath: userPaths.serverConfig
})
displays = new DisplayManager({
+11 -3
View File
@@ -7,6 +7,8 @@ const path = require('path')
const { EventEmitter } = require('events')
const { spawn } = require('child_process')
const { withConfigArg } = require('./userpaths')
const STATE_LABEL = {
stopped: '已停止',
starting: '启动中',
@@ -37,7 +39,7 @@ function healthHost(server) {
// 服务端守护:拉起 scoreboard.exe、转发日志、健康探测、崩溃自动重启。
// 现场无人值守,所以「崩了自动拉起」比「暴露复杂控制」更重要。
class ServiceSupervisor extends EventEmitter {
constructor({ values, exePath, log, dataDir }) {
constructor({ values, exePath, log, dataDir, configPath }) {
super()
this.values = values
this.server = values.server
@@ -45,6 +47,8 @@ class ServiceSupervisor extends EventEmitter {
this.log = log
// 绝对路径,非空时下发给服务端 -data;为空表示沿用服务端默认数据目录
this.dataDir = dataDir || null
// 绝对路径,非空时覆盖 args 里的 -config;为空表示沿用 args 原值(开发态)
this.configPath = configPath || null
this.child = null
this.state = 'stopped'
@@ -113,9 +117,13 @@ class ServiceSupervisor extends EventEmitter {
}
const args = Array.isArray(this.server.args) ? this.server.args : []
// -config 换成 Console 解析出的那份:打包态在用户数据目录里,
// 安装目录里那份只是随包的模板(会被卸载器删掉)
const withConfig = withConfigArg(args, this.configPath)
// 显式下发数据目录:默认位置在安装目录之外,覆盖安装时才不会被卸载器连目录删掉
const spawnArgs = this.dataDir ? [...args, '-data', this.dataDir] : args
// 工作目录取 exe 所在目录:部署包里 config.yaml / rules/ 都跟 exe 平级
const spawnArgs = this.dataDir ? [...withConfig, '-data', this.dataDir] : withConfig
// 工作目录取 exe 所在目录(部署包根目录);config.yaml 与数据目录都走绝对路径,
// 不再依赖 cwd
const cwd = path.dirname(this.exePath)
this.lastError = null
+169
View File
@@ -0,0 +1,169 @@
'use strict'
const fs = require('fs')
const path = require('path')
const { LOCAL_BASE } = require('./datadir')
// 这三样以前随安装包落在安装目录里,但都属于「用户改出来的东西」,必须跟数据目录一样
// 挪到安装目录之外:卸载器结尾是 `RMDir /r $INSTDIR`,覆盖安装又会先静默跑一遍旧卸载器,
// 放在安装目录里等于用户改一次丢一次。
// console\config.json → <用户根>\console\config.json
// config.yaml → <用户根>\config.yaml
// rules\ → <用户根>\rules\
// 用户根与数据目录同一基座:%LOCALAPPDATA%\ScoreboardConsole\
const CONSOLE_SUBDIR = 'console'
const CONSOLE_CONFIG_FILE = 'config.json'
const SERVER_CONFIG_FILE = 'config.yaml'
const RULES_SUBDIR = 'rules'
const ITEM_NAMES = ['consoleConfig', 'serverConfig', 'rulesDir']
const ITEM_LABEL = {
consoleConfig: 'Console 配置文件',
serverConfig: '服务端配置文件',
rulesDir: '规则目录'
}
// 用户根目录;拿不到 %LOCALAPPDATA% 时返回 null,由调用方回落到安装目录
function resolveUserRoot(env) {
const localAppData = env ? env.LOCALAPPDATA : ''
const value = typeof localAppData === 'string' ? localAppData.trim() : ''
return value ? path.join(value, LOCAL_BASE) : null
}
// 解析落点,纯函数不碰磁盘。mode='user' 才接管到用户目录;
// 其余情况(开发态、拿不到 %LOCALAPPDATA%)三项一律用安装目录里随包的那份,
// 行为与以前完全一致 —— 开发态不能动仓库里的文件。
function planUserPaths({ installDir, userRoot, isPackaged }) {
const install = path.resolve(installDir || '.')
const templates = {
consoleConfig: path.join(install, CONSOLE_CONFIG_FILE),
serverConfig: path.join(install, SERVER_CONFIG_FILE),
rulesDir: path.join(install, RULES_SUBDIR)
}
const items = {}
const targets = {
consoleConfig: path.join(install, CONSOLE_CONFIG_FILE),
serverConfig: path.join(install, SERVER_CONFIG_FILE),
rulesDir: path.join(install, RULES_SUBDIR)
}
const reason = !isPackaged ? 'dev' : userRoot ? null : 'no-localappdata'
if (!reason) {
targets.consoleConfig = path.join(userRoot, CONSOLE_SUBDIR, CONSOLE_CONFIG_FILE)
targets.serverConfig = path.join(userRoot, SERVER_CONFIG_FILE)
targets.rulesDir = path.join(userRoot, RULES_SUBDIR)
}
for (const name of ITEM_NAMES) {
items[name] = { target: path.resolve(targets[name]), template: path.resolve(templates[name]) }
}
return {
mode: reason ? 'install' : 'user',
reason,
root: userRoot ? path.resolve(userRoot) : null,
installDir: install,
items
}
}
// 目标目录必须真的能写:mkdir 只保证目录建出来,权限不足或路径被占要在这一步就暴露,
// 好让调用方回落到安装目录,而不是等到写配置文件时才失败
function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true })
fs.accessSync(dir, fs.constants.W_OK)
}
function tryRemove(dir) {
try {
fs.rmSync(dir, { recursive: true, force: true })
} catch {
// 清不掉就留着:只是本次拷贝留下的临时目录,下次会原地重建
}
}
// 规则目录先拷到临时目录再整体改名到位:中途失败不会留下半个 rules\,
// 否则用户目录里一旦存在同名目录,下次启动就按「已存在」跳过、再也不补拷
function copyDirOnce(template, target) {
const tmp = `${target}.tmp-${process.pid}`
try {
tryRemove(tmp)
fs.cpSync(template, tmp, { recursive: true })
fs.renameSync(tmp, target)
} catch (err) {
tryRemove(tmp)
throw err
}
}
// 按 plan 落盘,返回最终生效的路径。判定只有两条:
// 目标不存在 + 安装目录里有 → 复制过去(安装目录那份留作模板,反正卸载器会删);
// 目标已存在 → 一律用用户目录那份,绝不覆盖用户改过的东西。
// 任一目录建不出来 / 拷不动都只记日志,该项继续用安装目录里的那份,绝不删用户文件、
// 也绝不因此启动失败。
function prepareUserPaths({ plan, log }) {
const notify = typeof log === 'function' ? log : () => {}
const result = {
mode: plan.mode,
reason: plan.reason,
root: plan.mode === 'user' ? plan.root : null,
consoleConfig: plan.items.consoleConfig.template,
rulesDir: plan.items.rulesDir.template,
// null = 不接管:拉起服务端与 passwd 时沿用 config.json 里 -config 的原值
serverConfig: null
}
if (plan.mode !== 'user') {
if (plan.reason === 'no-localappdata') {
notify(
'警告:拿不到 %LOCALAPPDATA%,config.json / config.yaml / rules 继续用安装目录里的那份,' +
'覆盖安装会连同它们一起丢失'
)
}
return result
}
for (const name of ITEM_NAMES) {
const { target, template } = plan.items[name]
const label = ITEM_LABEL[name]
if (path.resolve(target) === path.resolve(template)) continue
try {
ensureDir(path.dirname(target))
if (!fs.existsSync(target)) {
if (!fs.existsSync(template)) {
notify(`警告:${label}在用户目录与安装目录里都不存在(${template}),本次跳过复制`)
} else {
if (name === 'rulesDir') copyDirOnce(template, target)
else fs.copyFileSync(template, target)
notify(`已把 ${label} 复制到 ${target}(安装目录里那份留作模板)`)
}
}
} catch (err) {
notify(
`警告:准备 ${label} 失败(${err.message}),继续用安装目录里的 ${template};` +
'覆盖安装会丢失对它的改动'
)
continue
}
if (name === 'serverConfig') result.serverConfig = target
else result[name] = target
}
return result
}
// 服务端读哪份 config.yaml 由 Console 定:config.json 里的 server.args 只声明「有个 -config 参数」,
// 它的值在拉起时换成上面解析出的绝对路径(不传 configPath 时原样返回,开发态就走这条路)
function withConfigArg(args, configPath) {
const list = Array.isArray(args) ? [...args] : []
if (typeof configPath !== 'string' || configPath === '') return list
const index = list.indexOf('-config')
if (index !== -1 && typeof list[index + 1] === 'string') list[index + 1] = configPath
else list.push('-config', configPath)
return list
}
module.exports = { resolveUserRoot, planUserPaths, prepareUserPaths, withConfigArg }
+199
View File
@@ -0,0 +1,199 @@
'use strict'
const assert = require('node:assert/strict')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const test = require('node:test')
const {
resolveUserRoot,
planUserPaths,
prepareUserPaths,
withConfigArg
} = require('./userpaths')
// 用绝对路径字符串拼测试目录,避免依赖运行平台的盘符写法
const INSTALL = path.resolve('/scoreboard-install')
const LOCAL = path.resolve('/localappdata')
const USER_ROOT = path.join(LOCAL, 'ScoreboardConsole')
function makeRoot(t) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'scoreboard-userpaths-'))
t.after(() => fs.rmSync(root, { recursive: true, force: true }))
return root
}
function writeFile(file, text) {
fs.mkdirSync(path.dirname(file), { recursive: true })
fs.writeFileSync(file, text)
}
function readFile(file) {
return fs.readFileSync(file, 'utf8')
}
// 打包态 + 有 %LOCALAPPDATA% 是「接管」的唯一组合
function planFor({ installDir = INSTALL, env = { LOCALAPPDATA: LOCAL }, isPackaged = true } = {}) {
return planUserPaths({ installDir, userRoot: resolveUserRoot(env), isPackaged })
}
test('打包态三项都落到用户根下,与数据目录同一基座', () => {
const plan = planFor()
assert.equal(plan.mode, 'user')
assert.equal(plan.root, USER_ROOT)
assert.equal(plan.items.consoleConfig.target, path.join(USER_ROOT, 'console', 'config.json'))
assert.equal(plan.items.serverConfig.target, path.join(USER_ROOT, 'config.yaml'))
assert.equal(plan.items.rulesDir.target, path.join(USER_ROOT, 'rules'))
})
test('开发态不接管,三项都指向安装目录里随包的那份', () => {
const plan = planFor({ isPackaged: false })
assert.equal(plan.mode, 'install')
assert.equal(plan.reason, 'dev')
assert.equal(plan.items.consoleConfig.target, path.join(INSTALL, 'config.json'))
assert.equal(plan.items.serverConfig.target, path.join(INSTALL, 'config.yaml'))
assert.equal(plan.items.rulesDir.target, path.join(INSTALL, 'rules'))
})
test('拿不到 %LOCALAPPDATA% 时回落安装目录并记日志', () => {
const plan = planFor({ env: {} })
assert.equal(plan.mode, 'install')
assert.equal(plan.reason, 'no-localappdata')
const lines = []
const paths = prepareUserPaths({ plan, log: (line) => lines.push(line) })
assert.equal(paths.consoleConfig, path.join(INSTALL, 'config.json'))
assert.equal(paths.rulesDir, path.join(INSTALL, 'rules'))
assert.equal(paths.serverConfig, null)
assert.match(lines.join('\n'), /拿不到 %LOCALAPPDATA%/)
})
test('首次运行把随包的三项复制过去,安装目录那份留作模板', (t) => {
const root = makeRoot(t)
const installDir = path.join(root, 'install')
const userRoot = path.join(root, 'localappdata', 'ScoreboardConsole')
writeFile(path.join(installDir, 'config.json'), '{"server":{"port":19541}}')
writeFile(path.join(installDir, 'config.yaml'), 'server:\n port: 19541\n')
writeFile(path.join(installDir, 'rules', 'cup.yaml'), 'format: scoreboard-rules\n')
writeFile(path.join(installDir, 'rules', 'README.md'), '说明\n')
const lines = []
const paths = prepareUserPaths({
plan: planUserPaths({ installDir, userRoot, isPackaged: true }),
log: (line) => lines.push(line)
})
assert.equal(paths.consoleConfig, path.join(userRoot, 'console', 'config.json'))
assert.equal(paths.serverConfig, path.join(userRoot, 'config.yaml'))
assert.equal(paths.rulesDir, path.join(userRoot, 'rules'))
assert.equal(readFile(paths.consoleConfig), '{"server":{"port":19541}}')
assert.equal(readFile(paths.serverConfig), 'server:\n port: 19541\n')
assert.equal(readFile(path.join(userRoot, 'rules', 'cup.yaml')), 'format: scoreboard-rules\n')
assert.equal(readFile(path.join(userRoot, 'rules', 'README.md')), '说明\n')
// 复制不是移动:安装目录那三份必须原样留着当模板
assert.equal(readFile(path.join(installDir, 'config.json')), '{"server":{"port":19541}}')
assert.equal(readFile(path.join(installDir, 'config.yaml')), 'server:\n port: 19541\n')
assert.equal(fs.existsSync(path.join(installDir, 'rules', 'cup.yaml')), true)
assert.equal(lines.filter((line) => line.startsWith('已把 ')).length, 3)
})
test('用户目录已有那份就一律用它,不覆盖也不重拷', (t) => {
const root = makeRoot(t)
const installDir = path.join(root, 'install')
const userRoot = path.join(root, 'localappdata', 'ScoreboardConsole')
writeFile(path.join(installDir, 'config.json'), '{"server":{"port":1111}}')
writeFile(path.join(installDir, 'config.yaml'), 'server:\n port: 1111\n')
writeFile(path.join(installDir, 'rules', 'cup.yaml'), 'template\n')
writeFile(path.join(userRoot, 'console', 'config.json'), '{"server":{"port":2222}}')
writeFile(path.join(userRoot, 'config.yaml'), 'server:\n port: 2222\n')
writeFile(path.join(userRoot, 'rules', 'mine.yaml'), 'mine\n')
const lines = []
const paths = prepareUserPaths({
plan: planUserPaths({ installDir, userRoot, isPackaged: true }),
log: (line) => lines.push(line)
})
assert.equal(readFile(paths.consoleConfig), '{"server":{"port":2222}}')
assert.equal(readFile(paths.serverConfig), 'server:\n port: 2222\n')
assert.equal(readFile(path.join(userRoot, 'rules', 'mine.yaml')), 'mine\n')
// 用户目录里的 rules\ 已存在(哪怕内容和模板不同)→ 不补拷模板里的 cup.yaml
assert.equal(fs.existsSync(path.join(userRoot, 'rules', 'cup.yaml')), false)
assert.equal(lines.filter((line) => line.startsWith('已把 ')).length, 0)
})
test('用户目录写不进去时记日志并回落到安装目录,不抛异常', (t) => {
const root = makeRoot(t)
const installDir = path.join(root, 'install')
writeFile(path.join(installDir, 'config.json'), '{}')
writeFile(path.join(installDir, 'config.yaml'), 'server: {}\n')
writeFile(path.join(installDir, 'rules', 'cup.yaml'), 'template\n')
// 用户根的位置被一个文件占着:mkdir/rename 必然失败(ENOTDIR)
const userRoot = path.join(root, 'blocker')
writeFile(userRoot, 'not a dir')
const lines = []
const paths = prepareUserPaths({
plan: planUserPaths({ installDir, userRoot, isPackaged: true }),
log: (line) => lines.push(line)
})
assert.equal(paths.consoleConfig, path.join(installDir, 'config.json'))
assert.equal(paths.serverConfig, null)
assert.equal(paths.rulesDir, path.join(installDir, 'rules'))
assert.equal(readFile(paths.consoleConfig), '{}')
assert.equal(readFile(path.join(installDir, 'rules', 'cup.yaml')), 'template\n')
assert.equal(lines.filter((line) => line.includes('继续用安装目录里的')).length, 3)
})
test('两边都没有的那一份跳过复制,路径仍指向用户目录', (t) => {
const root = makeRoot(t)
const installDir = path.join(root, 'install')
const userRoot = path.join(root, 'localappdata', 'ScoreboardConsole')
fs.mkdirSync(installDir, { recursive: true })
const lines = []
const paths = prepareUserPaths({
plan: planUserPaths({ installDir, userRoot, isPackaged: true }),
log: (line) => lines.push(line)
})
// 不接管就没人去读用户目录,服务端会按 config.Load 的默认值跑
assert.equal(paths.serverConfig, path.join(userRoot, 'config.yaml'))
assert.equal(fs.existsSync(paths.serverConfig), false)
assert.equal(lines.filter((line) => line.includes('本次跳过复制')).length, 3)
})
test('规则目录拷贝到位后不留临时目录,重复运行不重做', (t) => {
const root = makeRoot(t)
const installDir = path.join(root, 'install')
const userRoot = path.join(root, 'localappdata', 'ScoreboardConsole')
writeFile(path.join(installDir, 'config.json'), '{}\n')
writeFile(path.join(installDir, 'config.yaml'), 'server: {}\n')
writeFile(path.join(installDir, 'rules', 'cup.yaml'), 'template\n')
const plan = planUserPaths({ installDir, userRoot, isPackaged: true })
const first = prepareUserPaths({ plan, log: () => {} })
assert.deepEqual(fs.readdirSync(userRoot).sort(), ['config.yaml', 'console', 'rules'])
assert.deepEqual(fs.readdirSync(first.rulesDir), ['cup.yaml'])
// 第二次启动:目标都在了,不再复制(用户改过的内容不会被模板顶掉)
writeFile(path.join(userRoot, 'config.yaml'), 'server:\n port: 3333\n')
const second = prepareUserPaths({ plan, log: () => {} })
assert.deepEqual(second, first)
assert.equal(readFile(path.join(userRoot, 'config.yaml')), 'server:\n port: 3333\n')
})
test('-config 的值换成解析出的绝对路径,其余参数原样保留', () => {
assert.deepEqual(withConfigArg(['-config', 'config.yaml'], '/u/config.yaml'), ['-config', '/u/config.yaml'])
assert.deepEqual(
withConfigArg(['-config', 'config.yaml', '-project', 'cup'], '/u/config.yaml'),
['-config', '/u/config.yaml', '-project', 'cup']
)
// 参数里没写 -config 就补一条,否则用户目录里那份永远不会被读到
assert.deepEqual(withConfigArg(['-project', 'cup'], '/u/config.yaml'), ['-project', 'cup', '-config', '/u/config.yaml'])
// 不接管(开发态)原样返回
assert.deepEqual(withConfigArg(['-config', 'config.yaml'], null), ['-config', 'config.yaml'])
assert.deepEqual(withConfigArg(null, '/u/config.yaml'), ['-config', '/u/config.yaml'])
})