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

268 lines
12 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 {
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'])
})
// —— 以下是 Linux 分支:用户根从 %LOCALAPPDATA% 换成 XDG 的数据目录,
// 目录名与三项的落点(console/config.json、config.yaml、rules)一个字不变。
test('Linux 打包态三项都落到 $XDG_DATA_HOME/ScoreboardConsole 下', () => {
const xdg = path.resolve('/xdg/data')
const userRoot = resolveUserRoot({ XDG_DATA_HOME: xdg }, 'linux')
const plan = planUserPaths({ installDir: INSTALL, userRoot, isPackaged: true, platform: 'linux' })
assert.equal(plan.mode, 'user')
assert.equal(plan.root, path.join(xdg, 'ScoreboardConsole'))
assert.equal(plan.items.consoleConfig.target, path.join(xdg, 'ScoreboardConsole', 'console', 'config.json'))
assert.equal(plan.items.serverConfig.target, path.join(xdg, 'ScoreboardConsole', 'config.yaml'))
assert.equal(plan.items.rulesDir.target, path.join(xdg, 'ScoreboardConsole', 'rules'))
})
test('Linux 没设 $XDG_DATA_HOME 时用户根落在 ~/.local/share/ScoreboardConsole', () => {
const home = path.resolve('/home/operator')
assert.equal(resolveUserRoot({ HOME: home }, 'linux'), path.join(home, '.local', 'share', 'ScoreboardConsole'))
})
test('Linux 拿不到用户根时回落安装目录,提示文案点名 XDG 变量', () => {
const plan = planUserPaths({
installDir: INSTALL,
userRoot: resolveUserRoot({}, 'linux'),
isPackaged: true,
platform: 'linux'
})
assert.equal(plan.mode, 'install')
assert.equal(plan.reason, 'no-user-root')
const lines = []
const paths = prepareUserPaths({ plan, log: (line) => lines.push(line) })
assert.equal(paths.consoleConfig, path.join(INSTALL, 'config.json'))
assert.equal(paths.serverConfig, null)
assert.match(lines.join('\n'), /\$XDG_DATA_HOME/)
assert.match(lines.join('\n'), /\$HOME/)
})
test('Linux 开发态不接管,三项都指向安装目录里随包的那份', () => {
const home = path.resolve('/home/operator')
const plan = planUserPaths({
installDir: INSTALL,
userRoot: resolveUserRoot({ HOME: home }, 'linux'),
isPackaged: false,
platform: 'linux'
})
assert.equal(plan.mode, 'install')
assert.equal(plan.reason, 'dev')
assert.equal(plan.items.rulesDir.target, path.join(INSTALL, 'rules'))
})
test('Linux 首次运行同样把随包的三项复制到用户目录', (t) => {
const root = makeRoot(t)
const installDir = path.join(root, 'install')
const userRoot = path.join(root, 'xdg', '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')
const paths = prepareUserPaths({
plan: planUserPaths({ installDir, userRoot, isPackaged: true, platform: 'linux' }),
log: () => {}
})
assert.equal(paths.consoleConfig, path.join(userRoot, 'console', 'config.json'))
assert.equal(readFile(paths.serverConfig), 'server:\n port: 19541\n')
assert.equal(readFile(path.join(userRoot, 'rules', 'cup.yaml')), 'format: scoreboard-rules\n')
})