需求:让积分榜能在飞腾 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` 两张)。
268 lines
12 KiB
JavaScript
268 lines
12 KiB
JavaScript
'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')
|
||
})
|