fix: 数据目录移出安装目录,修掉「覆盖安装/卸载即丢数据」

根因:数据目录是 <安装目录>\data(config.yaml 的 dataDir 相对服务端工作目录),
而 electron-builder 的卸载器结尾执行 RMDir /r $INSTDIR,且覆盖安装会先跑旧卸载器
(installSection.nsh:52 的 uninstallOldVersion)→ 赛事库/规则/用户/审计/上传图片
连同安装目录被整个删除,再装新版。卸载一次同样如此。

- 服务端新增 -data <dir>(主命令、backup、passwd 三处),非空时覆盖 config.yaml 的
  dataDir;不传时行为完全不变(兼容便携/手工运行)
- Console 把数据指到当前用户目录 %LOCALAPPDATA%\ScoreboardConsole\data(卸载器不会碰
  这里;没有用 app.getPath('userData'),因为 %APPDATA%\<AppName> 正是被删的那个)
- 一次性迁移:目标为空且 <安装目录>\data 有数据时整体改名过去;两边都有数据时不动作
  只记警告;任何失败都保留原地数据、绝不删除
- passwd 也带 -data(否则重置的是安装目录下那个空库里的账号)
- deploy/backup.bat 指向同一数据目录
- 文档:部署指南新增「数据放在哪里(升级/卸载都不会删)」+ 备份/恢复/迁移路径改写,
  上线检查清单与使用手册同步

自检:服务端 12 包全绿;console 语法 14 文件 + datadir 单测 11/11;backup.bat 保持纯 ASCII。
This commit is contained in:
AskaEth committed 2026-09-10 09:17:56 +08:00
1 parent 6483acf9ad
commit 005b51b2ec
18 files changed
+626 -64

No files matched your search

+26 -2
View File
@@ -20,6 +20,8 @@ console/
├── main/
│ ├── index.js # 应用入口:窗口 / 托盘 / IPC / 生命周期
│ ├── config.js # 配置加载与合并、exe 路径解析
│ ├── datadir.js # 数据目录解析 + 旧数据一次性迁移
│ ├── datadir.test.js # 数据目录的单元测试(node --test main/datadir.test.js)
│ ├── service.js # 服务端子进程守护:spawn / 日志转发 / 健康检查 / 自动重启
│ ├── instances.js # 已连接大屏实例看板:复用管理面板登录态读 /api/display/instances
│ ├── account.js # 管理员账号重置:参数校验 + 调 scoreboard.exe passwd
@@ -62,12 +64,31 @@ scoreboard-windows-amd64/
├── scoreboard.exe
├── config.yaml
├── rules/
├── data/
└── logs/console.log # 运行时生成
```
数据不在安装目录里,默认落在 `%LOCALAPPDATA%\ScoreboardConsole\data`(见下文「数据目录」)。
打包后 `config.json` 读取顺序:`SCOREBOARD_CONSOLE_CONFIG` 环境变量 → exe 同目录的 `config.json`。
## 数据目录(重要)
electron-builder 生成的卸载器结尾是 `RMDir /r $INSTDIR`,而**覆盖安装会先静默跑一遍旧版卸载器**,所以装在安装目录里的 `data\` 会被整体删掉;`%APPDATA%\Scoreboard Console` 卸载时同样会删,因此也不能用 `app.getPath('userData')`。最终方案:
| 场景 | 数据目录 |
| --- | --- |
| 打包态,`server.dataDir` 留空(默认) | `%LOCALAPPDATA%\ScoreboardConsole\data`(卸载器不碰这个位置) |
| 配置了 `server.dataDir` | 配什么用什么:绝对路径直接用;相对路径以安装目录为基准 |
| 开发态(`pnpm start`),`server.dataDir` 留空 | 不接管,沿用服务端默认(工作目录下的 `data`),仓库里的 `data/` 不受影响 |
- Console 把最终目录通过服务端的 `-data <绝对路径>` 传下去,`passwd` 子命令带同一个参数,保证改的是同一个库里的账号;
- 迁移只在**打包态**做一次:**目标目录为空(或不存在)且 `<安装目录>\data` 有数据**时,用 `fs.renameSync` 把旧目录整体改名过去(同盘秒完成)。跨盘、权限不足等任何失败都只记日志,旧数据原地保留;
- 两边都有数据时**不做任何动作**,只写一条醒目日志提示人工确认,两份都不会被自动删除;
- 迁移结果与最终数据目录都写进 `logs/console.log`(搜「数据目录」「已把旧数据从」);
- 数据目录若仍在安装目录内,启动时会额外打一条警告。
改完 `datadir.js` 跑 `node --test main/datadir.test.js`(Node 内置测试运行器,无额外依赖)。
## 行为说明
| 场景 | 行为 |
@@ -79,6 +100,7 @@ scoreboard-windows-amd64/
| 防熄屏 | Console 启动即生效,退出时释放;前台后台都算 |
| 服务端就绪 | 首次 `/api/health` 通过后开大屏窗口;等待超过 `display.waitForServerMs` 也先开,避免现场只见桌面 |
| 服务端崩溃 | 按 `restartDelayMs` 递增退避自动重启(`maxRestarts=0` 为无限);持续健康 `healthyResetMs` 后崩溃计数清零;恢复健康后自动重载大屏 |
| 数据目录 | 默认 `%LOCALAPPDATA%\ScoreboardConsole\data`(安装目录之外,覆盖安装/卸载都不删),启动时按 `-data <绝对路径>` 交给服务端;打包态首次启动做一次性迁移(见「数据目录」一节),开发态不接管 |
| 停止服务 | 先 `POST server.shutdownPath` 并带 `X-Shutdown-Token`(启动时用 `crypto.randomBytes(32)` 生成,经环境变量 `SCOREBOARD_SHUTDOWN_TOKEN` 只交给该子进程)请求服务端优雅关闭;受理后最多等 `shutdownTimeoutMs`,超时或接口不可用(404/连接失败)再强杀 |
| 已连接大屏实例 | 管理窗口打开时每 5 秒读一次服务端 `GET /api/display/instances`(复用管理面板的 `scoreboard_session` Cookie,需记录员及以上),显示实例数、页面名与在线状态;窗口隐藏即停表,未登录/服务未启动时显示占位文案 |
| 关管理窗口 | 只隐藏到托盘,服务端继续运行;退出必须走托盘「退出」 |
@@ -86,7 +108,7 @@ scoreboard-windows-amd64/
| 窗口外观 | 管理窗口与服务端管理面板窗口都是 `frame: false` 无边框,标题栏与最小化/最大化/关闭三键由页面自绘(拖拽区 `-webkit-app-region: drag`,按钮 `no-drag`,双击标题栏切最大化,关闭键 hover 变红) |
| 服务端管理面板 | 用独立无边框 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 <同一份配置> -user <当前用户名> -new-username <新用户名> -password <新密码>`(留空 = 不改 / 随机生成)。本机进程、不经 HTTP,因此免登录验证;用户名/密码含空格、制表符或换行会被主进程拒绝,密码不进日志。`passwd` 会作废该用户全部会话,UI 提交前二次确认并提示 |
| 管理员账号 | 主进程 `spawn` 同目录 `scoreboard.exe passwd -config <同一份配置> -data <同一个数据目录> -user <当前用户名> -new-username <新用户名> -password <新密码>`(留空 = 不改 / 随机生成)。本机进程、不经 HTTP,因此免登录验证;用户名/密码含空格、制表符或换行会被主进程拒绝,密码不进日志。`passwd` 会作废该用户全部会话,UI 提交前二次确认并提示 |
| 日志 | 内存环形缓冲(`log.maxLines`)+ 文件 `logs/console.log`(超 `maxFileBytes` 轮转一份 `.1`,轮转前会删掉旧 `.1` 以兼容 Windows) |
## 配置项(`config.json`)
@@ -95,6 +117,7 @@ scoreboard-windows-amd64/
| --- | --- | --- |
| `server.exe` | `scoreboard.exe` | 相对路径按上文顺序探测,也可写绝对路径 |
| `server.args` | `["-config","config.yaml"]` | 传给服务端的参数 |
| `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` | 健康检查间隔 |
@@ -147,4 +170,5 @@ pnpm pack # 只出目录,不生成安装包,便于现场调试
- `powerSaveBlocker` 在长时间后台下的实际效果。
- 托盘图标在 Windows 任务栏的显示效果(16px 缩放)。
- `scoreboard.exe` 真实拉起、日志转发、健康检查与崩溃重启的端到端链路。
- 数据目录迁移实机路径:老版本安装目录里的 `data\` 是否被整体搬到 `%LOCALAPPDATA%\ScoreboardConsole\data`、跨盘安装(安装目录在 D 盘、`%LOCALAPPDATA%` 在 C 盘)时的失败日志与「旧数据原地保留」表现。
- electron-builder 打包已通过 `deploy\build.bat` 实跑;NSIS 安装包在真机上的安装 / 卸载流程尚未验证。
+1
View File
@@ -2,6 +2,7 @@
"server": {
"exe": "scoreboard.exe",
"args": ["-config", "config.yaml"],
"dataDir": "",
"host": "127.0.0.1",
"port": 19540,
"autoStart": true,
+9 -9
View File
@@ -18,14 +18,13 @@ function validateAccountField(value, label, required) {
}
// 空的新用户名 / 空密码直接传空串:passwd 的语义就是「留空则不改 / 随机生成」
function buildPasswdArgs({ configArg, currentUsername, newUsername, password }) {
return [
'passwd',
'-config', configArg,
'-user', currentUsername,
'-new-username', newUsername,
'-password', password
]
// dataDir 非空时带上 -data,否则 passwd 会用 config.yaml 的默认目录、
// 改到另一个库里的账号上
function buildPasswdArgs({ configArg, dataDir, currentUsername, newUsername, password }) {
const args = ['passwd', '-config', configArg]
if (typeof dataDir === 'string' && dataDir !== '') args.push('-data', dataDir)
args.push('-user', currentUsername, '-new-username', newUsername, '-password', password)
return args
}
// passwd 正常是秒级完成;卡住(等待输入等)时必须自己收尾,否则 UI 重置按钮永久禁用
@@ -36,7 +35,8 @@ function runPasswd({ exePath, args, onOutput, timeoutMs = DEFAULT_TIMEOUT_MS })
return new Promise((resolve) => {
let child
try {
// cwd 取 exe 所在目录:config.yaml 与 data/ 都跟 exe 平级
// cwd 取 exe 所在目录:config.yaml 与 rules/ 都跟 exe 平级
// (数据目录由 -data 显式指定,不依赖 cwd)
child = spawn(exePath, args, {
cwd: path.dirname(exePath),
windowsHide: true,
+4
View File
@@ -9,6 +9,10 @@ const DEFAULTS = {
server: {
exe: 'scoreboard.exe',
args: ['-config', 'config.yaml'],
// 数据目录。空串 = 用 Console 选的默认位置(打包态是
// %LOCALAPPDATA%\ScoreboardConsole\data,装在安装目录之外,覆盖安装不会丢数据);
// 填了就按它来,相对路径以安装目录为基准(详见 datadir.js)
dataDir: '',
host: '127.0.0.1',
port: 19540,
autoStart: true,
+112
View File
@@ -0,0 +1,112 @@
'use strict'
const fs = require('fs')
const path = require('path')
// 数据目录必须落在安装目录之外:electron-builder 的卸载器结尾是 `RMDir /r $INSTDIR`,
// 而覆盖安装会先静默跑一遍旧版卸载器 → 装在安装目录里的 data/ 会被整体删掉。
// 也不能放 %APPDATA%\<AppName>(卸载器同样会删),所以默认落在 %LOCALAPPDATA%\ScoreboardConsole\data。
const LOCAL_BASE = 'ScoreboardConsole'
const DATA_SUBDIR = 'data'
// 解析最终数据目录。installDir 是安装目录(打包后 = exe 所在目录),
// 相对路径按安装目录解析,现场写 `data`、`D:\scoreboard-data` 都能用。
// passToServer=false 表示不给服务端加 -data,沿用服务端自身默认(开发态)。
function resolveDataDir({ values, installDir, env, isPackaged }) {
const base = path.resolve(installDir || '.')
const configured = values && values.server ? values.server.dataDir : ''
if (typeof configured === 'string' && configured.trim() !== '') {
const value = configured.trim()
return {
dir: path.isAbsolute(value) ? path.resolve(value) : path.resolve(base, value),
source: 'config',
passToServer: true
}
}
// 开发态不接管:继续用服务端默认(工作目录下的 data),免得仓库里的 data/ 被顶掉
if (!isPackaged) {
return { dir: path.join(base, DATA_SUBDIR), source: 'default', passToServer: false }
}
const localAppData = env ? env.LOCALAPPDATA : ''
if (!localAppData) {
// 拿不到 %LOCALAPPDATA% 时退回老位置:这次覆盖安装仍有丢数据风险,
// 但总好过把数据指向一个猜出来的路径
return { dir: path.join(base, DATA_SUBDIR), source: 'fallback', passToServer: false }
}
return {
dir: path.join(localAppData, LOCAL_BASE, DATA_SUBDIR),
source: 'localappdata',
passToServer: true
}
}
// 判断 dir 是否落在 base 之内(含 base 本身):用于提醒「数据还在安装目录里」,
// 这种配置在覆盖安装时会被卸载器连目录一起删掉
function isInside(base, dir) {
const rel = path.relative(path.resolve(base), path.resolve(dir))
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))
}
// missing = 不存在,empty = 存在但空,has-data = 有内容
// 读不出来时返回 error,由调用方按「可能有数据」处理,宁可不迁移
function dirState(dir) {
let entries
try {
entries = fs.readdirSync(dir)
} catch (err) {
return err.code === 'ENOENT' ? 'missing' : 'error'
}
return entries.length > 0 ? 'has-data' : 'empty'
}
// 一次性迁移:只在「目标为空且旧目录有数据」时把旧目录整体改名过去。
// 任何一步失败都只记日志、旧数据保持原地,绝不删除数据;
// 两边都有数据时不做任何动作,只提示现场人工确认。
function migrateLegacyData({ targetDir, legacyDir, log }) {
const notify = typeof log === 'function' ? log : () => {}
if (!targetDir || !legacyDir) return { migrated: false, reason: 'invalid' }
if (path.resolve(targetDir) === path.resolve(legacyDir)) return { migrated: false, reason: 'same-dir' }
const target = dirState(targetDir)
const legacy = dirState(legacyDir)
if (target === 'error') {
notify(`警告:读不到数据目录 ${targetDir},跳过旧数据迁移;旧数据仍在 ${legacyDir}`)
return { migrated: false, reason: 'target-unreadable' }
}
if (target === 'has-data') {
if (legacy === 'has-data') {
notify(
`警告:旧数据目录 ${legacyDir} 与新数据目录 ${targetDir} 都有数据,未做迁移。` +
'请人工确认要保留哪一份(两份都不会被自动删除)'
)
return { migrated: false, reason: 'both' }
}
return { migrated: false, reason: 'target-has-data' }
}
if (legacy !== 'has-data') return { migrated: false, reason: 'no-legacy' }
try {
// Windows 上 rename 到已存在的目录会失败,先摘掉这个空目录(空目录里没有数据)
if (target === 'empty') fs.rmdirSync(targetDir)
fs.mkdirSync(path.dirname(targetDir), { recursive: true })
fs.renameSync(legacyDir, targetDir)
} catch (err) {
notify(
`警告:迁移旧数据失败(${err.message}),旧数据仍保留在 ${legacyDir};` +
`本次服务端将使用 ${targetDir},请人工把旧目录内容拷过去`
)
return { migrated: false, reason: 'failed' }
}
notify(`已把旧数据从 ${legacyDir} 迁移到 ${targetDir}`)
return { migrated: true, reason: 'moved' }
}
module.exports = { resolveDataDir, migrateLegacyData, isInside }
+191
View File
@@ -0,0 +1,191 @@
'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 { resolveDataDir, migrateLegacyData, isInside } = require('./datadir')
// 用绝对路径字符串拼测试目录,避免依赖运行平台的盘符写法
const INSTALL = path.resolve('/scoreboard-install')
const LOCAL = path.resolve('/localappdata')
function makeRoot(t) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'scoreboard-datadir-'))
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)
}
test('打包态默认数据目录落在 %LOCALAPPDATA%\\ScoreboardConsole\\data', () => {
const info = resolveDataDir({
values: { server: { dataDir: '' } },
installDir: INSTALL,
env: { LOCALAPPDATA: LOCAL },
isPackaged: true
})
assert.equal(info.dir, path.join(LOCAL, 'ScoreboardConsole', 'data'))
assert.equal(info.source, 'localappdata')
assert.equal(info.passToServer, true)
assert.equal(isInside(INSTALL, info.dir), false)
})
test('开发态不接管数据目录,也不给服务端加 -data', () => {
const info = resolveDataDir({
values: { server: { dataDir: '' } },
installDir: INSTALL,
env: { LOCALAPPDATA: LOCAL },
isPackaged: false
})
assert.equal(info.dir, path.join(INSTALL, 'data'))
assert.equal(info.passToServer, false)
})
test('配置了绝对路径就按配置值走', () => {
const custom = path.resolve('/scoreboard-data')
const info = resolveDataDir({
values: { server: { dataDir: custom } },
installDir: INSTALL,
env: { LOCALAPPDATA: LOCAL },
isPackaged: true
})
assert.equal(info.dir, custom)
assert.equal(info.source, 'config')
assert.equal(info.passToServer, true)
})
test('配置了相对路径按安装目录解析', () => {
const info = resolveDataDir({
values: { server: { dataDir: 'data' } },
installDir: INSTALL,
env: { LOCALAPPDATA: LOCAL },
isPackaged: true
})
assert.equal(info.dir, path.join(INSTALL, 'data'))
assert.equal(info.passToServer, true)
assert.equal(isInside(INSTALL, info.dir), true)
})
test('缺 %LOCALAPPDATA% 时退回安装目录,不下发 -data', () => {
const info = resolveDataDir({
values: { server: {} },
installDir: INSTALL,
env: {},
isPackaged: true
})
assert.equal(info.dir, path.join(INSTALL, 'data'))
assert.equal(info.source, 'fallback')
assert.equal(info.passToServer, false)
})
test('目标为空且旧目录有数据 → 整体迁移', (t) => {
const root = makeRoot(t)
const legacyDir = path.join(root, 'install', 'data')
const targetDir = path.join(root, 'localappdata', 'ScoreboardConsole', 'data')
writeFile(path.join(legacyDir, 'system.db'), 'db')
writeFile(path.join(legacyDir, 'event_projs', 'default', 'event.db'), 'event')
const lines = []
const result = migrateLegacyData({ targetDir, legacyDir, log: (line) => lines.push(line) })
assert.equal(result.migrated, true)
assert.equal(result.reason, 'moved')
assert.equal(fs.existsSync(legacyDir), false)
assert.equal(fs.readFileSync(path.join(targetDir, 'system.db'), 'utf8'), 'db')
assert.equal(fs.existsSync(path.join(targetDir, 'event_projs', 'default', 'event.db')), true)
assert.match(lines.join('\n'), /已把旧数据从 .* 迁移到 /)
})
test('目标目录已存在但是空目录 → 照样迁移', (t) => {
const root = makeRoot(t)
const legacyDir = path.join(root, 'install', 'data')
const targetDir = path.join(root, 'localappdata', 'ScoreboardConsole', 'data')
writeFile(path.join(legacyDir, 'system.db'), 'db')
fs.mkdirSync(targetDir, { recursive: true })
const result = migrateLegacyData({ targetDir, legacyDir, log: () => {} })
assert.equal(result.migrated, true)
assert.equal(fs.readFileSync(path.join(targetDir, 'system.db'), 'utf8'), 'db')
assert.equal(fs.existsSync(legacyDir), false)
})
test('两边都有数据 → 不动任何一边,只记日志', (t) => {
const root = makeRoot(t)
const legacyDir = path.join(root, 'install', 'data')
const targetDir = path.join(root, 'localappdata', 'ScoreboardConsole', 'data')
writeFile(path.join(legacyDir, 'system.db'), 'old')
writeFile(path.join(targetDir, 'system.db'), 'new')
const lines = []
const result = migrateLegacyData({ targetDir, legacyDir, log: (line) => lines.push(line) })
assert.equal(result.migrated, false)
assert.equal(result.reason, 'both')
assert.equal(fs.readFileSync(path.join(legacyDir, 'system.db'), 'utf8'), 'old')
assert.equal(fs.readFileSync(path.join(targetDir, 'system.db'), 'utf8'), 'new')
assert.match(lines.join('\n'), /都有数据/)
})
test('旧目录没有数据 / 目标已有数据 / 两边同路径 → 都不动作', (t) => {
const root = makeRoot(t)
const emptyLegacy = path.join(root, 'a', 'data')
fs.mkdirSync(emptyLegacy, { recursive: true })
assert.equal(
migrateLegacyData({ targetDir: path.join(root, 'b', 'data'), legacyDir: emptyLegacy, log: () => {} }).reason,
'no-legacy'
)
assert.equal(
migrateLegacyData({ targetDir: path.join(root, 'c', 'data'), legacyDir: path.join(root, 'd', 'data'), log: () => {} }).reason,
'no-legacy'
)
const legacyDir = path.join(root, 'e', 'data')
const targetDir = path.join(root, 'f', 'data')
writeFile(path.join(targetDir, 'system.db'), 'new')
assert.equal(migrateLegacyData({ targetDir, legacyDir, log: () => {} }).reason, 'target-has-data')
assert.equal(migrateLegacyData({ targetDir, legacyDir: targetDir, log: () => {} }).reason, 'same-dir')
})
test('迁移失败时旧数据原地不动', (t) => {
const root = makeRoot(t)
// 目标目录嵌在旧目录内部,rename 必然失败(EINVAL)
const legacyDir = path.join(root, 'install', 'data')
const targetDir = path.join(legacyDir, 'nested')
writeFile(path.join(legacyDir, 'system.db'), 'db')
const lines = []
const result = migrateLegacyData({ targetDir, legacyDir, log: (line) => lines.push(line) })
assert.equal(result.migrated, false)
assert.equal(result.reason, 'failed')
assert.equal(fs.readFileSync(path.join(legacyDir, 'system.db'), 'utf8'), 'db')
assert.match(lines.join('\n'), /旧数据仍保留在/)
})
test('目标路径读不出来时按「可能有数据」处理,不做迁移', (t) => {
const root = makeRoot(t)
const legacyDir = path.join(root, 'install', 'data')
writeFile(path.join(legacyDir, 'system.db'), 'db')
// 目标目录的上级是个文件,readdir 会报 ENOTDIR
const blocker = path.join(root, 'blocker')
writeFile(blocker, 'not a dir')
const lines = []
const result = migrateLegacyData({
targetDir: path.join(blocker, 'data'),
legacyDir,
log: (line) => lines.push(line)
})
assert.equal(result.migrated, false)
assert.equal(result.reason, 'target-unreadable')
assert.equal(fs.readFileSync(path.join(legacyDir, 'system.db'), 'utf8'), 'db')
assert.match(lines.join('\n'), /跳过旧数据迁移/)
})
+39 -3
View File
@@ -13,6 +13,7 @@ const {
} = require('electron')
const { loadConfig, updateConfig, resolveExecutable } = require('./config')
const { resolveDataDir, migrateLegacyData, isInside } = require('./datadir')
const { LogBus } = require('./log-bus')
const { ServiceSupervisor, healthHost } = require('./service')
const { DisplayManager } = require('./displays')
@@ -40,6 +41,8 @@ function main() {
let quitting = false
let shutdownDone = false
let exePath = null
// 解析后的数据目录信息(见 datadir.js),拉起服务端和 passwd 都用它
let dataDirInfo = null
// 大屏实例看板只在管理窗口可见时轮询,窗口隐藏即停表
let instancesTimer = null
let instancesLastReason = null
@@ -431,6 +434,11 @@ function main() {
return 'config.yaml'
}
// 数据目录同理:不带上同一个 -data,passwd 会改到另一个库里的账号上
function passwdDataArg() {
return dataDirInfo && dataDirInfo.passToServer ? dataDirInfo.dir : ''
}
// 账号重置走同目录的 scoreboard.exe passwd 子命令:本机进程、不经 HTTP,
// 所以不需要任何登录态,这就是面板能「免验证」的原因。
async function resetAdminAccount(event, payload) {
@@ -454,14 +462,16 @@ function main() {
}
const configArg = passwdConfigArg()
const args = buildPasswdArgs({ configArg, currentUsername, newUsername, password })
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} -user ${currentUsername}` +
`重置管理员账号:${exe} passwd -config ${configArg}` +
`${dataDirArg ? ` -data ${dataDirArg}` : ''} -user ${currentUsername}` +
`${newUsername ? ` -new-username ${newUsername}` : ''}(密码已隐藏)`
)
@@ -565,10 +575,36 @@ function main() {
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
})
// 老版本把数据放在安装目录里,而覆盖安装会先静默跑旧卸载器把安装目录整个删掉,
// 所以先做一次性迁移。只在打包态做,开发态不动仓库里的 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
log,
dataDir: dataDirInfo.passToServer ? dataDirInfo.dir : null
})
displays = new DisplayManager({
+11 -4
View File
@@ -37,12 +37,14 @@ function healthHost(server) {
// 服务端守护:拉起 scoreboard.exe、转发日志、健康探测、崩溃自动重启。
// 现场无人值守,所以「崩了自动拉起」比「暴露复杂控制」更重要。
class ServiceSupervisor extends EventEmitter {
constructor({ values, exePath, log }) {
constructor({ values, exePath, log, dataDir }) {
super()
this.values = values
this.server = values.server
this.exePath = exePath
this.log = log
// 绝对路径,非空时下发给服务端 -data;为空表示沿用服务端默认数据目录
this.dataDir = dataDir || null
this.child = null
this.state = 'stopped'
@@ -111,7 +113,9 @@ class ServiceSupervisor extends EventEmitter {
}
const args = Array.isArray(this.server.args) ? this.server.args : []
// 工作目录取 exe 所在目录:部署包里 config.yaml / data/ 都跟 exe 平级
// 显式下发数据目录:默认位置在安装目录之外,覆盖安装时才不会被卸载器连目录删掉
const spawnArgs = this.dataDir ? [...args, '-data', this.dataDir] : args
// 工作目录取 exe 所在目录:部署包里 config.yaml / rules/ 都跟 exe 平级
const cwd = path.dirname(this.exePath)
this.lastError = null
@@ -120,7 +124,10 @@ class ServiceSupervisor extends EventEmitter {
this.forcedKill = false
this.startedAt = new Date().toISOString()
this.setState('starting')
this.log.push('console', `启动服务端:${this.exePath} ${args.join(' ')}(cwd=${cwd})`)
this.log.push(
'console',
`启动服务端:${this.exePath} ${spawnArgs.join(' ')}(cwd=${cwd},数据目录=${this.dataDir || '服务端默认'})`
)
// 每次拉起都换一把新 token,只通过环境变量交给这一个子进程,
// 停止时凭它调用服务端的 /api/shutdown
@@ -128,7 +135,7 @@ class ServiceSupervisor extends EventEmitter {
let child
try {
child = spawn(this.exePath, args, {
child = spawn(this.exePath, spawnArgs, {
cwd,
windowsHide: true,
stdio: ['ignore', 'pipe', 'pipe'],