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