需求:让积分榜能在飞腾 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` 两张)。
143 lines
6.0 KiB
JavaScript
143 lines
6.0 KiB
JavaScript
'use strict'
|
||
|
||
const fs = require('fs')
|
||
const path = require('path')
|
||
|
||
const { APP_DIR_NAME, xdgDataHome } = require('./platform')
|
||
|
||
// 数据目录必须落在安装目录之外:electron-builder 的卸载器结尾是 `RMDir /r $INSTDIR`,
|
||
// 而覆盖安装会先静默跑一遍旧版卸载器 → 装在安装目录里的 data/ 会被整体删掉。
|
||
// 也不能放 %APPDATA%\<AppName>(卸载器同样会删),所以 Windows 上默认落在
|
||
// %LOCALAPPDATA%\ScoreboardConsole\data;Linux 上同一套思路,换成 XDG 的数据目录
|
||
// ($XDG_DATA_HOME\ScoreboardConsole\data,缺省 ~/.local/share/…)。
|
||
// 目录名与配置/规则目录(userpaths.js)同一个基座,名字在 platform.js 里。
|
||
const DATA_SUBDIR = 'data'
|
||
|
||
// 解析最终数据目录。installDir 是安装目录(打包后 = exe 所在目录),
|
||
// 相对路径按安装目录解析,现场写 `data`、`D:\scoreboard-data`、`/srv/scoreboard-data` 都能用。
|
||
// passToServer=false 表示不给服务端加 -data,沿用服务端自身默认(开发态)。
|
||
// platform 缺省按 Windows 解析(改动前的口径),生产调用点传 process.platform。
|
||
function resolveDataDir({ values, installDir, env, isPackaged, platform = 'win32' }) {
|
||
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 }
|
||
}
|
||
|
||
if (platform === 'win32') {
|
||
const localAppData = env ? env.LOCALAPPDATA : ''
|
||
if (!localAppData) {
|
||
// 拿不到 %LOCALAPPDATA% 时退回老位置:这次覆盖安装仍有丢数据风险,
|
||
// 但总好过把数据指向一个猜出来的路径
|
||
return { dir: path.join(base, DATA_SUBDIR), source: 'fallback', passToServer: false }
|
||
}
|
||
|
||
return {
|
||
dir: path.join(localAppData, APP_DIR_NAME, DATA_SUBDIR),
|
||
source: 'localappdata',
|
||
passToServer: true
|
||
}
|
||
}
|
||
|
||
// Linux(UOS / 麒麟)按 XDG 规范取数据目录;取不到 $XDG_DATA_HOME 也取不到 $HOME 时
|
||
// 与 Windows 上拿不到 %LOCALAPPDATA% 一样退回安装目录,宁可记一条警告也不猜路径
|
||
const xdg = xdgDataHome(env)
|
||
if (!xdg) {
|
||
return { dir: path.join(base, DATA_SUBDIR), source: 'fallback', passToServer: false }
|
||
}
|
||
|
||
return {
|
||
dir: path.join(xdg, APP_DIR_NAME, DATA_SUBDIR),
|
||
source: 'xdg',
|
||
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' }
|
||
}
|
||
|
||
// 旧目录读不出来(EACCES / EPERM / ENOTDIR 等)与「没有旧数据」是两回事:
|
||
// 前者可能是一份没搬成功的真实数据,不能静默按「没数据」处理,必须留一条日志让人去人工确认。
|
||
if (legacy === 'error') {
|
||
notify(
|
||
`警告:读不到旧数据目录 ${legacyDir}(权限不足或路径不可用),本次不做迁移。` +
|
||
'请人工确认该目录里是否有需要保留的数据'
|
||
)
|
||
return { migrated: false, reason: 'legacy-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 }
|