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

308 lines
11 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 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'), /跳过旧数据迁移/)
})
test('旧路径读不出来时不当成「没有旧数据」,要记一条日志', (t) => {
const root = makeRoot(t)
const targetDir = path.join(root, 'localappdata', 'ScoreboardConsole', 'data')
fs.mkdirSync(targetDir, { recursive: true })
// 旧目录的上级是个文件:readdir 报 ENOTDIR,等同于现场遇到 EACCES 这类「读不到」
const blocker = path.join(root, 'blocker')
writeFile(blocker, 'not a dir')
const lines = []
const result = migrateLegacyData({
targetDir,
legacyDir: path.join(blocker, 'data'),
log: (line) => lines.push(line)
})
assert.equal(result.migrated, false)
assert.equal(result.reason, 'legacy-unreadable')
assert.match(lines.join('\n'), /读不到旧数据目录/)
})
test('确实没有旧数据时只返回 no-legacy,不写警告日志', (t) => {
const root = makeRoot(t)
const targetDir = path.join(root, 'localappdata', 'ScoreboardConsole', 'data')
fs.mkdirSync(targetDir, { recursive: true })
const legacyDir = path.join(root, 'install', 'data')
fs.mkdirSync(legacyDir, { recursive: true })
const lines = []
const result = migrateLegacyData({ targetDir, legacyDir, log: (line) => lines.push(line) })
assert.equal(result.migrated, false)
assert.equal(result.reason, 'no-legacy')
assert.deepEqual(lines, [])
})
// —— 以下是 Linux 分支:默认位置换成 XDG 的数据目录,理由与 Windows 一致——
// 必须落在安装目录之外,否则覆盖安装时会被卸载器连目录一起删掉。
test('Linux 打包态默认数据目录落在 $XDG_DATA_HOME/ScoreboardConsole/data', () => {
const xdg = path.resolve('/xdg/data')
const info = resolveDataDir({
values: { server: { dataDir: '' } },
installDir: INSTALL,
env: { XDG_DATA_HOME: xdg, HOME: path.resolve('/home/operator') },
isPackaged: true,
platform: 'linux'
})
assert.equal(info.dir, path.join(xdg, 'ScoreboardConsole', 'data'))
assert.equal(info.source, 'xdg')
assert.equal(info.passToServer, true)
assert.equal(isInside(INSTALL, info.dir), false)
})
test('Linux 没设 $XDG_DATA_HOME 时用 ~/.local/share', () => {
const home = path.resolve('/home/operator')
const info = resolveDataDir({
values: { server: { dataDir: '' } },
installDir: INSTALL,
env: { HOME: home },
isPackaged: true,
platform: 'linux'
})
assert.equal(info.dir, path.join(home, '.local', 'share', 'ScoreboardConsole', 'data'))
assert.equal(info.passToServer, true)
})
test('Linux 拿不到 $XDG_DATA_HOME 与 $HOME 时退回安装目录,不下发 -data', () => {
const info = resolveDataDir({
values: { server: {} },
installDir: INSTALL,
env: {},
isPackaged: true,
platform: 'linux'
})
assert.equal(info.dir, path.join(INSTALL, 'data'))
assert.equal(info.source, 'fallback')
assert.equal(info.passToServer, false)
})
test('Linux 开发态同样不接管,也不给服务端加 -data', () => {
const home = path.resolve('/home/operator')
const info = resolveDataDir({
values: { server: { dataDir: '' } },
installDir: INSTALL,
env: { HOME: home },
isPackaged: false,
platform: 'linux'
})
assert.equal(info.dir, path.join(INSTALL, 'data'))
assert.equal(info.passToServer, false)
})
test('Linux 上配置了 dataDir 一样按配置走(绝对路径 / 相对安装目录)', () => {
const home = path.resolve('/home/operator')
const absolute = path.resolve('/scoreboard-data')
assert.equal(
resolveDataDir({
values: { server: { dataDir: absolute } },
installDir: INSTALL,
env: { HOME: home },
isPackaged: true,
platform: 'linux'
}).dir,
absolute
)
assert.equal(
resolveDataDir({
values: { server: { dataDir: 'data' } },
installDir: INSTALL,
env: { HOME: home },
isPackaged: true,
platform: 'linux'
}).dir,
path.join(INSTALL, 'data')
)
})