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