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

194 lines
7.6 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')
// 这三样以前随安装包落在安装目录里,但都属于「用户改出来的东西」,必须跟数据目录一样
// 挪到安装目录之外:卸载器结尾是 `RMDir /r $INSTDIR`,覆盖安装又会先静默跑一遍旧卸载器,
// 放在安装目录里等于用户改一次丢一次。
// console\config.json → <用户根>\console\config.json
// config.yaml → <用户根>\config.yaml
// rules\ → <用户根>\rules\
// 用户根与数据目录同一基座,只是基座本身按平台不同:
// Windows %LOCALAPPDATA%\ScoreboardConsole\
// Linux $XDG_DATA_HOME/ScoreboardConsole/(缺省 ~/.local/share/ScoreboardConsole/)
const CONSOLE_SUBDIR = 'console'
const CONSOLE_CONFIG_FILE = 'config.json'
const SERVER_CONFIG_FILE = 'config.yaml'
const RULES_SUBDIR = 'rules'
const ITEM_NAMES = ['consoleConfig', 'serverConfig', 'rulesDir']
const ITEM_LABEL = {
consoleConfig: 'Console 配置文件',
serverConfig: '服务端配置文件',
rulesDir: '规则目录'
}
// 拿不到用户数据基座时的提示:两边缺的不是同一个变量,文案各说各的,
// 免得现场照着去设一个本平台不存在的环境变量
const NO_ROOT_WARNING = {
'no-localappdata':
'警告:拿不到 %LOCALAPPDATA%,config.json / config.yaml / rules 继续用安装目录里的那份,' +
'覆盖安装会连同它们一起丢失',
'no-user-root':
'警告:拿不到 $XDG_DATA_HOME,也没有可用的 $HOME,config.json / config.yaml / rules ' +
'继续用安装目录里的那份,覆盖安装会连同它们一起丢失'
}
// 用户根目录;拿不到用户数据基座时返回 null,由调用方回落到安装目录。
// platform 缺省按 Windows 解析(改动前的口径),生产调用点传 process.platform。
function resolveUserRoot(env, platform = 'win32') {
if (platform === 'win32') {
const localAppData = env ? env.LOCALAPPDATA : ''
const value = typeof localAppData === 'string' ? localAppData.trim() : ''
return value ? path.join(value, APP_DIR_NAME) : null
}
const xdg = xdgDataHome(env)
return xdg ? path.join(xdg, APP_DIR_NAME) : null
}
// 解析落点,纯函数不碰磁盘。mode='user' 才接管到用户目录;
// 其余情况(开发态、拿不到用户数据基座)三项一律用安装目录里随包的那份,
// 行为与以前完全一致 —— 开发态不能动仓库里的文件。
function planUserPaths({ installDir, userRoot, isPackaged, platform = 'win32' }) {
const install = path.resolve(installDir || '.')
const templates = {
consoleConfig: path.join(install, CONSOLE_CONFIG_FILE),
serverConfig: path.join(install, SERVER_CONFIG_FILE),
rulesDir: path.join(install, RULES_SUBDIR)
}
const items = {}
const targets = {
consoleConfig: path.join(install, CONSOLE_CONFIG_FILE),
serverConfig: path.join(install, SERVER_CONFIG_FILE),
rulesDir: path.join(install, RULES_SUBDIR)
}
// 缺的基座两边不同名:Windows 是 %LOCALAPPDATA%,Linux 是 $XDG_DATA_HOME / $HOME。
// 文案按这个 reason 分(见 NO_ROOT_WARNING),reason 本身也留着给调用方判断
const reason = !isPackaged
? 'dev'
: userRoot
? null
: platform === 'win32'
? 'no-localappdata'
: 'no-user-root'
if (!reason) {
targets.consoleConfig = path.join(userRoot, CONSOLE_SUBDIR, CONSOLE_CONFIG_FILE)
targets.serverConfig = path.join(userRoot, SERVER_CONFIG_FILE)
targets.rulesDir = path.join(userRoot, RULES_SUBDIR)
}
for (const name of ITEM_NAMES) {
items[name] = { target: path.resolve(targets[name]), template: path.resolve(templates[name]) }
}
return {
mode: reason ? 'install' : 'user',
reason,
root: userRoot ? path.resolve(userRoot) : null,
installDir: install,
items
}
}
// 目标目录必须真的能写:mkdir 只保证目录建出来,权限不足或路径被占要在这一步就暴露,
// 好让调用方回落到安装目录,而不是等到写配置文件时才失败
function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true })
fs.accessSync(dir, fs.constants.W_OK)
}
function tryRemove(dir) {
try {
fs.rmSync(dir, { recursive: true, force: true })
} catch {
// 清不掉就留着:只是本次拷贝留下的临时目录,下次会原地重建
}
}
// 规则目录先拷到临时目录再整体改名到位:中途失败不会留下半个 rules\,
// 否则用户目录里一旦存在同名目录,下次启动就按「已存在」跳过、再也不补拷
function copyDirOnce(template, target) {
const tmp = `${target}.tmp-${process.pid}`
try {
tryRemove(tmp)
fs.cpSync(template, tmp, { recursive: true })
fs.renameSync(tmp, target)
} catch (err) {
tryRemove(tmp)
throw err
}
}
// 按 plan 落盘,返回最终生效的路径。判定只有两条:
// 目标不存在 + 安装目录里有 → 复制过去(安装目录那份留作模板,反正卸载器会删);
// 目标已存在 → 一律用用户目录那份,绝不覆盖用户改过的东西。
// 任一目录建不出来 / 拷不动都只记日志,该项继续用安装目录里的那份,绝不删用户文件、
// 也绝不因此启动失败。
function prepareUserPaths({ plan, log }) {
const notify = typeof log === 'function' ? log : () => {}
const result = {
mode: plan.mode,
reason: plan.reason,
root: plan.mode === 'user' ? plan.root : null,
consoleConfig: plan.items.consoleConfig.template,
rulesDir: plan.items.rulesDir.template,
// null = 不接管:拉起服务端与 passwd 时沿用 config.json 里 -config 的原值
serverConfig: null
}
if (plan.mode !== 'user') {
// 两个 reason 对应两个平台缺的那个变量,文案不同但处理完全一致
const warning = NO_ROOT_WARNING[plan.reason]
if (warning) notify(warning)
return result
}
for (const name of ITEM_NAMES) {
const { target, template } = plan.items[name]
const label = ITEM_LABEL[name]
if (path.resolve(target) === path.resolve(template)) continue
try {
ensureDir(path.dirname(target))
if (!fs.existsSync(target)) {
if (!fs.existsSync(template)) {
notify(`警告:${label}在用户目录与安装目录里都不存在(${template}),本次跳过复制`)
} else {
if (name === 'rulesDir') copyDirOnce(template, target)
else fs.copyFileSync(template, target)
notify(`已把 ${label} 复制到 ${target}(安装目录里那份留作模板)`)
}
}
} catch (err) {
notify(
`警告:准备 ${label} 失败(${err.message}),继续用安装目录里的 ${template};` +
'覆盖安装会丢失对它的改动'
)
continue
}
if (name === 'serverConfig') result.serverConfig = target
else result[name] = target
}
return result
}
// 服务端读哪份 config.yaml 由 Console 定:config.json 里的 server.args 只声明「有个 -config 参数」,
// 它的值在拉起时换成上面解析出的绝对路径(不传 configPath 时原样返回,开发态就走这条路)
function withConfigArg(args, configPath) {
const list = Array.isArray(args) ? [...args] : []
if (typeof configPath !== 'string' || configPath === '') return list
const index = list.indexOf('-config')
if (index !== -1 && typeof list[index + 1] === 'string') list[index + 1] = configPath
else list.push('-config', configPath)
return list
}
module.exports = { resolveUserRoot, planUserPaths, prepareUserPaths, withConfigArg }