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

143 lines
6.0 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 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 }