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` 两张)。
This commit is contained in:
AskaEth committed 2026-09-15 00:53:16 +08:00
1 parent 55051fd633
commit efa049c827
19 files changed
+665 -58

No files matched your search

+1 -1
View File
@@ -1 +1 @@
dev_0.5.9
dev_0.5.10
Binary file not shown.

Before

Width:  |  Height:  |  Size: 309 KiB

After

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

+1 -1
View File
@@ -1 +1 @@
0.5.9
0.5.10
+85
View File
@@ -0,0 +1,85 @@
'use strict'
const fs = require('fs')
const path = require('path')
const { xdgConfigHome } = require('./platform')
// Linux 的「开机自启」只能靠 XDG 自启目录里的一个 .desktop:Electron 的
// setLoginItemSettings 在 Linux 上是空实现,桌面环境(GNOME / UKUI / 麒麟 / KDE)
// 读的都是 $XDG_CONFIG_HOME/autostart 或 ~/.config/autostart。
const AUTOSTART_FILE = 'scoreboard-console.desktop'
// 返回 .desktop 的绝对路径;非 Linux(Windows/macOS 走系统登录项)或拿不到 ~/.config 时
// 返回 null,由调用方回滚配置并记日志,绝不静默当成功。
function resolveAutostartFile({ platform = 'linux', env = process.env } = {}) {
if (platform !== 'linux') return null
const base = xdgConfigHome(env)
return base ? path.join(base, 'autostart', AUTOSTART_FILE) : null
}
// Exec 字段不是 shell 命令行,路径带空格必须自己加引号,否则自启时会被拆成两个参数、
// 找不到程序(现场装到「Program Files」那种带空格的目录下必然遇到)。
function quoteExec(exePath) {
return `"${String(exePath).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
}
function desktopEntry(exePath) {
return [
'[Desktop Entry]',
'Type=Application',
'Version=1.0',
'Name=Scoreboard Console',
'Name[zh_CN]=赛事积分榜',
'Comment=守护服务端并把大屏投到扩展屏',
`Exec=${quoteExec(exePath)}`,
'Terminal=false',
// 有些桌面环境只认这一个键;写 true 表明这份文件是「启用」状态(关掉是直接删文件)
'X-GNOME-Autostart-enabled=true',
'Hidden=false',
''
].join('\n')
}
// 「开」与「关」以文件实际状态为准(与 Windows 读登录项同一口径):
// 文件在就是开。Hidden=true 是 XDG 规范里「忽略这个文件」的写法 —— 现场从桌面环境的
// 「启动应用程序」面板里关掉自启时会这么标,所以必须认出来,否则界面会显示成还开着。
function isAutostartEnabled({ platform = 'linux', env = process.env } = {}) {
const file = resolveAutostartFile({ platform, env })
if (!file) return false
let text
try {
text = fs.readFileSync(file, 'utf8')
} catch {
return false
}
return !/^\s*Hidden\s*=\s*true\s*$/im.test(text)
}
// 开 → 写文件(覆盖旧的,每次启动 Console 按配置重写一遍是幂等的);
// 关 → 删文件。与 Windows 的「登录项一直保留、只切 enabled」不同:各桌面环境对
// X-GNOME-Autostart-enabled / Hidden 的支持不一致,写了标记却被忽略等于开关失灵,
// 删文件是唯一在各地都生效的做法(差异见回报)。
// 任何失败(含拿不到路径、平台不对)都抛出去,由调用方回滚配置,界面才不会说反。
function setAutostart({ platform = 'linux', env = process.env, exePath, enabled } = {}) {
const file = resolveAutostartFile({ platform, env })
if (!file) {
if (platform !== 'linux') throw new Error('开机自启在 Linux 上走 XDG 自启目录,本平台请用系统登录项')
throw new Error('拿不到 $XDG_CONFIG_HOME 与 $HOME,无法写自启文件')
}
if (enabled !== true) {
// force: 文件本来就不在(目录也不在)时不算失败
fs.rmSync(file, { force: true })
return { file }
}
if (typeof exePath !== 'string' || exePath === '') {
throw new Error('拿不到 Console 自身的可执行文件路径,无法写自启文件')
}
fs.mkdirSync(path.dirname(file), { recursive: true })
fs.writeFileSync(file, desktopEntry(exePath), 'utf8')
return { file }
}
module.exports = { AUTOSTART_FILE, resolveAutostartFile, isAutostartEnabled, setAutostart }
+107
View File
@@ -0,0 +1,107 @@
'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 { AUTOSTART_FILE, resolveAutostartFile, isAutostartEnabled, setAutostart } = require('./autostart')
// 用绝对路径字符串拼测试目录,避免依赖运行平台的盘符写法
const HOME = path.resolve('/home/operator')
function makeRoot(t) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'scoreboard-autostart-'))
t.after(() => fs.rmSync(root, { recursive: true, force: true }))
return root
}
test('自启文件在 $XDG_CONFIG_HOME/autostart 下,缺省用 ~/.config/autostart', () => {
const xdg = path.resolve('/xdg/config')
assert.equal(
resolveAutostartFile({ platform: 'linux', env: { XDG_CONFIG_HOME: xdg } }),
path.join(xdg, 'autostart', AUTOSTART_FILE)
)
assert.equal(
resolveAutostartFile({ platform: 'linux', env: { HOME } }),
path.join(HOME, '.config', 'autostart', AUTOSTART_FILE)
)
})
test('非 Linux 平台没有这个文件(Windows/macOS 走系统登录项)', () => {
assert.equal(resolveAutostartFile({ platform: 'win32', env: { HOME } }), null)
assert.equal(resolveAutostartFile({ platform: 'darwin', env: { HOME } }), null)
assert.throws(
() => setAutostart({ platform: 'win32', env: { HOME }, exePath: '/x/Scoreboard_Console', enabled: true }),
/Linux/
)
})
test('开:写出 .desktop,Exec 指向 Console 自己;关:把文件删掉', (t) => {
const root = makeRoot(t)
const env = { XDG_CONFIG_HOME: root }
const exePath = path.join(root, 'install', 'Scoreboard_Console')
const file = resolveAutostartFile({ platform: 'linux', env })
assert.equal(isAutostartEnabled({ platform: 'linux', env }), false)
assert.equal(fs.existsSync(file), false)
setAutostart({ platform: 'linux', env, exePath, enabled: true })
assert.equal(isAutostartEnabled({ platform: 'linux', env }), true)
const text = fs.readFileSync(file, 'utf8')
assert.match(text, /^\[Desktop Entry\]$/m)
assert.match(text, /^Type=Application$/m)
assert.match(text, /^Exec=.*Scoreboard_Console.*$/m)
assert.match(text, /^X-GNOME-Autostart-enabled=true$/m)
// 重复开一次是幂等的(每次启动 Console 都会按配置重写一遍)
setAutostart({ platform: 'linux', env, exePath, enabled: true })
assert.equal(fs.readFileSync(file, 'utf8'), text)
assert.equal(isAutostartEnabled({ platform: 'linux', env }), true)
setAutostart({ platform: 'linux', env, exePath, enabled: false })
assert.equal(fs.existsSync(file), false)
assert.equal(isAutostartEnabled({ platform: 'linux', env }), false)
// 关两次不报错:现场连点开关、或文件本来就不在(目录也一并没了)
setAutostart({ platform: 'linux', env, exePath, enabled: false })
})
test('安装路径带空格时 Exec 加引号,否则自启会被拆成两个参数', (t) => {
const root = makeRoot(t)
const env = { XDG_CONFIG_HOME: root }
const exePath = path.join(root, 'Scoreboard Console', 'Scoreboard_Console')
setAutostart({ platform: 'linux', env, exePath, enabled: true })
const text = fs.readFileSync(resolveAutostartFile({ platform: 'linux', env }), 'utf8')
assert.match(text, new RegExp(`^Exec="${exePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"$`, 'm'))
})
test('文件被标了 Hidden=true(别的桌面环境关闭自启时的写法)视为关闭', (t) => {
const root = makeRoot(t)
const env = { XDG_CONFIG_HOME: root }
const file = resolveAutostartFile({ platform: 'linux', env })
setAutostart({ platform: 'linux', env, exePath: '/x/Scoreboard_Console', enabled: true })
assert.equal(isAutostartEnabled({ platform: 'linux', env }), true)
// 现场从桌面环境的「启动应用程序」里关掉自启时,各家的写法不一致:
// XDG 规范里 Hidden=true 就是「忽略这个文件」,必须认出来
fs.writeFileSync(file, fs.readFileSync(file, 'utf8').replace('Hidden=false', 'Hidden=true'), 'utf8')
assert.equal(isAutostartEnabled({ platform: 'linux', env }), false)
})
test('拿不到 $XDG_CONFIG_HOME 也拿不到 $HOME 时开关都报错,不静默当成功', () => {
const env = {}
assert.equal(resolveAutostartFile({ platform: 'linux', env }), null)
assert.equal(isAutostartEnabled({ platform: 'linux', env }), false)
assert.throws(() => setAutostart({ platform: 'linux', env, exePath: '/x/Scoreboard_Console', enabled: true }), /XDG_CONFIG_HOME/)
assert.throws(() => setAutostart({ platform: 'linux', env, exePath: '/x/Scoreboard_Console', enabled: false }), /XDG_CONFIG_HOME/)
})
test('拿不到 Console 自身路径时不写一个空 Exec 出去', (t) => {
const root = makeRoot(t)
const env = { XDG_CONFIG_HOME: root }
assert.throws(() => setAutostart({ platform: 'linux', env, exePath: '', enabled: true }), /路径/)
assert.equal(isAutostartEnabled({ platform: 'linux', env }), false)
})
+28 -4
View File
@@ -3,16 +3,21 @@
const fs = require('fs')
const path = require('path')
const { serverExeName } = require('./platform')
// 默认值写死在代码里:config.json 只覆盖要改的项。
// 这样升级新增字段时老配置依然能用,现场也不容易配出半成品。
// 注意 server.exe 这里写的是 **Windows** 的名字:它是历史上「随包发布的默认值」,
// 也是 config.json 模板里的值。按平台换值发生在 loadConfig 里,见那里的注释。
const DEFAULTS = {
server: {
exe: 'scoreboard.exe',
// -config 只声明「有个配置文件参数」,值在拉起服务端时被换成解析出的绝对路径:
// 打包态指向 <用户根>\config.yaml,开发态保持这里的相对值(见 userpaths.js)
args: ['-config', 'config.yaml'],
// 数据目录。空串 = 用 Console 选的默认位置(打包态是
// %LOCALAPPDATA%\ScoreboardConsole\data,装在安装目录之外,覆盖安装不会丢数据);
// 数据目录。空串 = 用 Console 选的默认位置(打包态在用户数据目录下、安装目录之外,
// 覆盖安装不会丢数据:Windows 是 %LOCALAPPDATA%\ScoreboardConsole\data,
// Linux 是 $XDG_DATA_HOME\ScoreboardConsole\data);
// 填了就按它来,相对路径以安装目录为基准(详见 datadir.js)
dataDir: '',
host: '127.0.0.1',
@@ -129,11 +134,30 @@ function stripRenamedKeys(values) {
return values
}
function loadConfig(configPath) {
// platform 缺省按 Windows 解析(改动前的口径),生产调用点传 process.platform
function loadConfig(configPath, platform = 'win32') {
const { values, warning } = readJson(configPath)
const merged = stripRenamedKeys(merge(DEFAULTS, values))
// 服务端可执行文件名按平台取:Windows 是 scoreboard.exe,Linux 是 scoreboard。
// 判断「文件里写没写过」要看**文件里真实写了什么**(readJson 的原始值),
// 与 deprecatedKeyNotices 同一套口径:merge 之后的值一律带 DEFAULTS,拿它判断会把
// 每个键都当成「用户写过」。另外,文件里写的正好是 Windows 那个默认名(随包 config.json
// 模板就是它,老机器上的用户配置也是从模板复制来的)时,在非 Windows 平台按「没写」处理——
// 那不是现场改过的名字,照它去找一个不存在的 .exe 只会「未找到服务端程序」。
// 与 RENAMED_KEYS 同一个道理:模板落进老文件里的值,光改默认值救不了。
const configured = isPlainObject(values) && isPlainObject(values.server) ? values.server.exe : undefined
const customized =
typeof configured === 'string' &&
configured.trim() !== '' &&
!(platform !== 'win32' && configured.trim() === DEFAULTS.server.exe)
// server 段被写成 null / 数组时(merge 会原样保留)不在这里报错:以前也是留到真正
// 用它的时候才抛,别在这一步凭空多出一个崩溃点
if (!customized && isPlainObject(merged.server)) merged.server.exe = serverExeName(platform)
return {
path: configPath,
values: stripRenamedKeys(merge(DEFAULTS, values)),
values: merged,
warning,
// 提示按**文件里真实写了什么**算,所以传的是 readJson 的原始值,不是上面那份合并结果
notices: deprecatedKeyNotices(values)
+31 -2
View File
@@ -15,11 +15,12 @@ function makeDir(t) {
}
// 现场机器上那份用户配置:内容原样写进文件,读的也必须是文件里真实写了什么
function loadWith(t, raw) {
// platform 与 env 一样是入参,缺省按 Windows 解析(改动前的既有口径)
function loadWith(t, raw, platform = 'win32') {
const dir = makeDir(t)
const file = path.join(dir, 'config.json')
fs.writeFileSync(file, typeof raw === 'string' ? raw : JSON.stringify(raw, null, 2), 'utf8')
return loadConfig(file)
return loadConfig(file, platform)
}
test('缺省的 allowClickThrough 不穿透(默认值就是 false)', (t) => {
@@ -85,3 +86,31 @@ test('配置文件读不出来/内容不是对象时不报废弃键、也不炸'
assert.deepEqual(loadWith(t, 'null').notices, [])
assert.deepEqual(loadWith(t, '[1,2,3]').notices, [])
})
// 服务端可执行文件名按平台取:Windows 是 scoreboard.exe,Linux(UOS / 麒麟)上没有 .exe。
// 内置默认值保持 Windows 那个名字不变(改动前的口径),按平台换值只发生在 loadConfig 里。
test('Windows 下不写 exe 时默认仍是 scoreboard.exe', (t) => {
assert.equal(loadWith(t, { server: {} }).values.server.exe, 'scoreboard.exe')
assert.equal(DEFAULTS.server.exe, 'scoreboard.exe')
})
test('Linux 下不写 exe 时默认是 scoreboard', (t) => {
assert.equal(loadWith(t, { server: {} }, 'linux').values.server.exe, 'scoreboard')
})
test('Linux 下配置文件里写着模板留下的 scoreboard.exe 时按 scoreboard 走', (t) => {
// 随包 config.json 模板与老机器的用户配置里都是 scoreboard.exe。那不是现场改过的值,
// 而是模板写进去的默认值;Linux 上照它去找一个 .exe 只会「未找到服务端程序」。
// 与 RENAMED_KEYS 同一个道理:模板落进老文件里的值,光改默认值救不了。
assert.equal(loadWith(t, { server: { exe: 'scoreboard.exe' } }, 'linux').values.server.exe, 'scoreboard')
})
test('Windows 下文件里写着别的名字就照用(两边都不做猜测)', (t) => {
assert.equal(loadWith(t, { server: { exe: 'my-scoreboard.exe' } }).values.server.exe, 'my-scoreboard.exe')
assert.equal(loadWith(t, { server: { exe: 'my-scoreboard' } }, 'linux').values.server.exe, 'my-scoreboard')
})
test('server 段被写成 null 时 loadConfig 不炸(与改动前一致)', (t) => {
const cfg = loadWith(t, { server: null }, 'linux')
assert.equal(cfg.values.server, null)
})
+31 -12
View File
@@ -3,17 +3,21 @@
const fs = require('fs')
const path = require('path')
const { APP_DIR_NAME, xdgDataHome } = require('./platform')
// 数据目录必须落在安装目录之外:electron-builder 的卸载器结尾是 `RMDir /r $INSTDIR`,
// 而覆盖安装会先静默跑一遍旧版卸载器 → 装在安装目录里的 data/ 会被整体删掉。
// 也不能放 %APPDATA%\<AppName>(卸载器同样会删),所以默认落在 %LOCALAPPDATA%\ScoreboardConsole\data。
// 配置与规则目录(userpaths.js)用同一个基座,这里导出给它复用。
const LOCAL_BASE = 'ScoreboardConsole'
// 也不能放 %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` 都能用。
// 相对路径按安装目录解析,现场写 `data`、`D:\scoreboard-data`、`/srv/scoreboard-data` 都能用。
// passToServer=false 表示不给服务端加 -data,沿用服务端自身默认(开发态)。
function resolveDataDir({ values, installDir, env, isPackaged }) {
// 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 : ''
@@ -31,16 +35,31 @@ function resolveDataDir({ values, installDir, env, isPackaged }) {
return { dir: path.join(base, DATA_SUBDIR), source: 'default', passToServer: false }
}
const localAppData = env ? env.LOCALAPPDATA : ''
if (!localAppData) {
// 拿不到 %LOCALAPPDATA% 时退回老位置:这次覆盖安装仍有丢数据风险,
// 但总好过把数据指向一个猜出来的路径
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(localAppData, LOCAL_BASE, DATA_SUBDIR),
source: 'localappdata',
dir: path.join(xdg, APP_DIR_NAME, DATA_SUBDIR),
source: 'xdg',
passToServer: true
}
}
@@ -120,4 +139,4 @@ function migrateLegacyData({ targetDir, legacyDir, log }) {
return { migrated: true, reason: 'moved' }
}
module.exports = { resolveDataDir, migrateLegacyData, isInside, LOCAL_BASE }
module.exports = { resolveDataDir, migrateLegacyData, isInside }
+81
View File
@@ -224,3 +224,84 @@ test('确实没有旧数据时只返回 no-legacy,不写警告日志', (t) =>
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')
)
})
+2
View File
@@ -72,6 +72,8 @@ class DisplayManager {
// 默认策略是「除内置屏外的每块屏」(doc 06 §6.2)。用 display.internal 判断,
// 而不是拿主屏当替身:现场常把主屏设成外接 HDMI,用主屏排除会把 kiosk 开到笔记本屏。
// 全部屏都报 internal=false 时(纯台式机)退回「排除主屏」的旧口径。
// Linux 上 Electron 不报 display.internal(只在 Windows/macOS 上给),恒为 undefined,
// 于是 Linux 一律走下面「排除主屏」那条:飞腾机器上多屏就是主屏 + 扩展屏,口径一致。
pickSecondary(all, primary) {
const external = all.filter((item) => item.internal === false)
if (external.length && external.length < all.length) return external
+86 -17
View File
@@ -20,6 +20,7 @@ const { ServiceSupervisor, healthHost } = require('./service')
const { DisplayManager } = require('./displays')
const { fetchDisplayInstances } = require('./instances')
const { createTrayIcon } = require('./tray-icon')
const { isAutostartEnabled, setAutostart } = require('./autostart')
const { validateAccountField, buildPasswdArgs, runPasswd } = require('./account')
// 现场只允许一个实例:两个 Console 会互相抢同一个服务端端口和同一块大屏
@@ -69,10 +70,17 @@ function main() {
return userPaths ? userPaths.consoleConfig : path.join(__dirname, '..', 'config.json')
}
// 开关显示以系统实际状态为准:打包后的 Windows 读登录项 enabled,其余回落配置值。
// 开关显示以系统实际状态为准:打包后的 Windows 读登录项的 enabled;Linux 读
// ~/.config/autostart 里那个 .desktop 在不在;其余回落配置值。
function openAtLoginEffective() {
const configured = cfg ? cfg.values.startup.openAtLogin === true : false
if (!app.isPackaged || process.platform !== 'win32') return configured
if (!app.isPackaged || (process.platform !== 'win32' && process.platform !== 'linux')) {
return configured
}
if (process.platform === 'linux') {
// Linux 不给「已注册但未启用」这种中间态:文件在就是开、不在就是关(见 autostart.js)
return isAutostartEnabled({ platform: 'linux', env: process.env })
}
try {
return app.getLoginItemSettings().executableWillLaunchAtLogin === true
} catch {
@@ -89,7 +97,9 @@ function main() {
displays: displays ? displays.getInfo() : [],
powerSaveBlocked: powerBlockerId !== null ? powerSaveBlocker.isStarted(powerBlockerId) : false,
openAtLogin: openAtLoginEffective(),
openAtLoginSupported: app.isPackaged && (process.platform === 'win32' || process.platform === 'darwin'),
openAtLoginSupported:
app.isPackaged &&
(process.platform === 'win32' || process.platform === 'darwin' || process.platform === 'linux'),
appVersion: app.getVersion(),
// 渲染层按同一个上限裁剪日志节点(见 renderer/app.js appendLog):
// 主进程只留最后 maxLines 行,页面若只增不减,长赛事跑一天会把窗口拖卡
@@ -142,9 +152,16 @@ function main() {
adminWindow.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'))
adminWindow.once('ready-to-show', () => adminWindow.show())
// 关窗只隐藏,服务端和守护逻辑要继续在托盘里跑
// 关窗只隐藏,服务端和守护逻辑要继续在托盘里跑。
// 前提是托盘真的建起来了:托盘没建起来时(Linux 的通知区宿主可能缺失)
// 藏起来就再也叫不回来,那种情况按普通窗口语义退出。
adminWindow.on('close', (event) => {
if (quitting) return
if (!tray || tray.isDestroyed()) {
quitting = true
app.quit()
return
}
event.preventDefault()
adminWindow.hide()
log.push('console', '管理窗口已最小化到托盘(服务端继续运行)')
@@ -377,15 +394,32 @@ function main() {
tray.setToolTip('Scoreboard Console')
tray.setContextMenu(buildTrayMenu())
tray.on('double-click', () => showAdmin())
// Linux 的通知区(appindicator / GtkStatusIcon)多数不派发 double-click,只有单击;
// 不补这一条,托盘就成了一个点不开的东西(Windows 上照旧走上面那条,行为不变)
if (process.platform === 'linux') tray.on('click', () => showAdmin())
}
// 开机自启:只对打包后的程序有意义,开发态注册的是 electron 可执行文件。
// Windows 上始终注册登录项(任务管理器「启动」里能看到),用 enabled 控制是否真的拉起:
// 实测 {openAtLogin:true, enabled:false} → openAtLogin=true 且 executableWillLaunchAtLogin=false,
// 即「已注册但未启用」。macOS 没有 enabled 参数,只能注册/注销。
// Linux 没有登录项 API(setLoginItemSettings 是空实现),靠 ~/.config/autostart 下的
// .desktop 文件,且没有「已注册但未启用」这个中间态:开=写文件、关=删文件(见 autostart.js)。
function applyOpenAtLogin() {
if (!app.isPackaged || (process.platform !== 'win32' && process.platform !== 'darwin')) return
if (!app.isPackaged) return
const wanted = cfg.values.startup?.openAtLogin === true
if (process.platform === 'linux') {
try {
setAutostart({ platform: 'linux', env: process.env, exePath: app.getPath('exe'), enabled: wanted })
log.push('console', wanted ? '开机自启已启用' : '开机自启已关闭')
} catch (err) {
log.push('console', `设置开机自启失败:${err.message}`)
}
return
}
if (process.platform !== 'win32' && process.platform !== 'darwin') return
try {
app.setLoginItemSettings(loginItemSettings(wanted))
log.push('console', wanted ? '开机自启已启用' : '开机自启已注册但未启用')
@@ -407,7 +441,17 @@ function main() {
: { openAtLogin: enabled }
}
// 服务管理窗口的「开机自启」开关:登录项一直保留,只切换是否真正随系统拉起。
// 把开关落到系统上;写不成功就抛,由调用方回滚配置。Linux 与 Windows 走的是两套机制
function applySystemOpenAtLogin(wanted) {
if (process.platform === 'linux') {
setAutostart({ platform: 'linux', env: process.env, exePath: app.getPath('exe'), enabled: wanted })
return
}
app.setLoginItemSettings(loginItemSettings(wanted))
}
// 服务管理窗口的「开机自启」开关:Windows 上登录项一直保留,只切换是否真正随系统拉起;
// Linux 上直接对应 .desktop 文件的增删(见 autostart.js)。
function setOpenAtLogin(enabled) {
if (!cfg) return { ok: false, message: '配置尚未加载' }
const wanted = enabled === true
@@ -422,11 +466,12 @@ function main() {
return { ok: false, message: `保存配置失败:${err.message}` }
}
if (app.isPackaged && (process.platform === 'win32' || process.platform === 'darwin')) {
const supported = process.platform === 'win32' || process.platform === 'darwin' || process.platform === 'linux'
if (app.isPackaged && supported) {
try {
app.setLoginItemSettings(loginItemSettings(wanted))
applySystemOpenAtLogin(wanted)
} catch (err) {
// 登录项没写成功,配置也回滚,避免界面与系统状态不一致
// 登录项/自启文件没写成功,配置也回滚,避免界面与系统状态不一致
cfg.values.startup.openAtLogin = previous
try {
writeOpenAtLogin(previous)
@@ -438,7 +483,14 @@ function main() {
}
}
log.push('console', wanted ? '开机自启已启用' : '开机自启已关闭(登录项保留,任务管理器里可见)')
log.push(
'console',
wanted
? '开机自启已启用'
: process.platform === 'linux'
? '开机自启已关闭(已删除自启文件)'
: '开机自启已关闭(登录项保留,任务管理器里可见)'
)
return { ok: true, openAtLogin: wanted }
}
@@ -459,8 +511,8 @@ function main() {
return dataDirInfo && dataDirInfo.passToServer ? dataDirInfo.dir : ''
}
// 账号重置走同目录的 scoreboard.exe passwd 子命令:本机进程、不经 HTTP,
// 所以不需要任何登录态,这就是面板能「免验证」的原因。
// 账号重置走同目录的服务端程序(Windows scoreboard.exe / Linux scoreboard)的 passwd
// 子命令:本机进程、不经 HTTP,所以不需要任何登录态,这就是面板能「免验证」的原因。
async function resetAdminAccount(event, payload) {
if (accountBusy) return { ok: false, error: '上一次重置还没结束,请稍候再试' }
// IPC 入参一律当不可信处理
@@ -588,13 +640,14 @@ function main() {
userPaths = prepareUserPaths({
plan: planUserPaths({
installDir: bundleDir,
userRoot: resolveUserRoot(process.env),
isPackaged: app.isPackaged
userRoot: resolveUserRoot(process.env, process.platform),
isPackaged: app.isPackaged,
platform: process.platform
}),
log: (message) => setupNotes.push(message)
})
cfg = loadConfig(resolveConfigPath())
cfg = loadConfig(resolveConfigPath(), process.platform)
log = new LogBus({
maxLines: cfg.values.log.maxLines,
// 日志固定在部署包根目录的 logs\ 下(log.file 相对它解析):配置文件搬走了,
@@ -623,7 +676,8 @@ function main() {
values: cfg.values,
installDir,
env: process.env,
isPackaged: app.isPackaged
isPackaged: app.isPackaged,
platform: process.platform
})
// 老版本把数据放在安装目录里,而覆盖安装会先静默跑旧卸载器把安装目录整个删掉,
@@ -684,7 +738,22 @@ function main() {
})
createAdminWindow()
createTray()
// 托盘只是操作入口,服务端守护必须照常起来。Linux 的通知区宿主不一定存在
// (精简桌面、纯 X11 无 StatusNotifier 时 new Tray 会直接抛错),抛出去会把整个
// whenReady 打断、连服务端守护都起不来,所以只在 Linux 上兜住这一下:
// Windows 的托盘一定在,那边保持原样(出错照旧抛出去),不改 Windows 的行为。
// 托盘没建起来时管理窗口的「关闭」按退出处理(见 createAdminWindow),
// 否则窗口一藏起来就再也叫不回来。
if (process.platform === 'linux') {
try {
createTray()
} catch (err) {
tray = null
log.push('console', `托盘创建失败:${err.message}(管理窗口与服务端守护继续运行)`)
}
} else {
createTray()
}
registerIpc()
// 单实例锁在 whenReady 之前就生效,但回调要用 cfg;注册放在这里,
+45
View File
@@ -0,0 +1,45 @@
'use strict'
const path = require('path')
// 平台差异集中在这一个文件里:用户数据的基座目录、服务端可执行文件名、XDG 的两个目录。
// platform 与 env 一样是**入参**(缺省按 Windows 解析 —— 这是这些函数改动前就有的口径),
// 生产调用点传 process.platform / process.env;纯函数才能把两个平台都断言一遍。
// 默认值刻意不写 process.platform:否则同一份调用在不同机器上解析出不同结果,
// 测试与现场会各跑一套,Windows 分支也没法被钉住。
// 用户数据那一级目录的名字。它原来叫 LOCAL_BASE(Windows 上就挂在 %LOCALAPPDATA% 下),
// Linux 上挂在 $XDG_DATA_HOME 下,名字本身与平台无关,所以改成中性一点的名字。
const APP_DIR_NAME = 'ScoreboardConsole'
// 服务端可执行文件名:Windows 是 scoreboard.exe,Linux(飞腾 D2000 上的 linux/arm64 静态
// 编译产物)没有后缀。写死一个名字必然有一边找不到程序。
function serverExeName(platform) {
return (platform || 'win32') === 'win32' ? 'scoreboard.exe' : 'scoreboard'
}
function readEnv(env, name) {
const value = env ? env[name] : ''
return typeof value === 'string' ? value.trim() : ''
}
// XDG 规范:$XDG_DATA_HOME 必须是绝对路径,没设 / 空 / 相对路径时一律退回 ~/.local/share。
// 两个都拿不到时返回 null,由调用方回落到安装目录 —— 与 Windows 上拿不到 %LOCALAPPDATA%
// 时同一套处理(宁可让现场看见一条警告,也不猜一个路径)。
function xdgDataHome(env) {
const xdg = readEnv(env, 'XDG_DATA_HOME')
if (xdg && path.isAbsolute(xdg)) return xdg
const home = readEnv(env, 'HOME')
// HOME 也要求绝对路径:拼出来的相对路径会随进程的工作目录变,比没有更糟
return home && path.isAbsolute(home) ? path.join(home, '.local', 'share') : null
}
// 同一套规格的 $XDG_CONFIG_HOME / ~/.config:开机自启的 .desktop 放在它的 autostart/ 下
function xdgConfigHome(env) {
const xdg = readEnv(env, 'XDG_CONFIG_HOME')
if (xdg && path.isAbsolute(xdg)) return xdg
const home = readEnv(env, 'HOME')
return home && path.isAbsolute(home) ? path.join(home, '.config') : null
}
module.exports = { APP_DIR_NAME, serverExeName, xdgDataHome, xdgConfigHome }
+49
View File
@@ -0,0 +1,49 @@
'use strict'
const assert = require('node:assert/strict')
const path = require('node:path')
const test = require('node:test')
const { APP_DIR_NAME, serverExeName, xdgDataHome, xdgConfigHome } = require('./platform')
// 用绝对路径字符串拼测试目录,避免依赖运行平台的盘符写法
const HOME = path.resolve('/home/operator')
const XDG_DATA = path.resolve('/xdg/data')
const XDG_CONFIG = path.resolve('/xdg/config')
test('目录名两个平台一致,只有它上面那一级不同', () => {
assert.equal(APP_DIR_NAME, 'ScoreboardConsole')
})
test('服务端可执行文件名按平台取:Windows 带 .exe,Linux 没有后缀', () => {
assert.equal(serverExeName('win32'), 'scoreboard.exe')
assert.equal(serverExeName('linux'), 'scoreboard')
// 不传平台时按 Windows 解析:这是这些纯函数改动前就有的口径
assert.equal(serverExeName(), 'scoreboard.exe')
})
test('Linux 用户数据基座:$XDG_DATA_HOME 优先,缺省用 ~/.local/share', () => {
assert.equal(xdgDataHome({ XDG_DATA_HOME: XDG_DATA, HOME }), XDG_DATA)
assert.equal(xdgDataHome({ HOME }), path.join(HOME, '.local', 'share'))
// 空串与纯空格都算「没设」,否则会拼出一个以空格命名的目录
assert.equal(xdgDataHome({ XDG_DATA_HOME: '', HOME }), path.join(HOME, '.local', 'share'))
assert.equal(xdgDataHome({ XDG_DATA_HOME: ' ', HOME }), path.join(HOME, '.local', 'share'))
})
test('$XDG_DATA_HOME 是相对路径时按规范忽略(规范要求它必须是绝对路径)', () => {
assert.equal(xdgDataHome({ XDG_DATA_HOME: 'xdg-data', HOME }), path.join(HOME, '.local', 'share'))
})
test('拿不到 $XDG_DATA_HOME 也拿不到 $HOME 时返回 null,由调用方回落安装目录', () => {
assert.equal(xdgDataHome({}), null)
assert.equal(xdgDataHome(undefined), null)
// HOME 是相对路径同样不认:拼出来会是个「相对当前目录」的路径,比没有更糟
assert.equal(xdgDataHome({ HOME: 'home/operator' }), null)
})
test('自启目录的基座 $XDG_CONFIG_HOME 是同一套规格', () => {
assert.equal(xdgConfigHome({ XDG_CONFIG_HOME: XDG_CONFIG, HOME }), XDG_CONFIG)
assert.equal(xdgConfigHome({ HOME }), path.join(HOME, '.config'))
assert.equal(xdgConfigHome({ XDG_CONFIG_HOME: 'cfg', HOME }), path.join(HOME, '.config'))
assert.equal(xdgConfigHome({}), null)
})
+2 -1
View File
@@ -51,7 +51,8 @@ function healthHost(server) {
return host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host
}
// 服务端守护:拉起 scoreboard.exe、转发日志、健康探测、崩溃自动重启。
// 服务端守护:拉起服务端(Windows 是 scoreboard.exe,Linux 是 scoreboard,名字由
// config.js 按平台给出)、转发日志、健康探测、崩溃自动重启。
// 现场无人值守,所以「崩了自动拉起」比「暴露复杂控制」更重要。
class ServiceSupervisor extends EventEmitter {
constructor({ values, exePath, log, dataDir, configPath, spawn: spawnFn, http: httpClient, timers, now }) {
+7 -3
View File
@@ -6,6 +6,7 @@ const { app, nativeImage } = require('electron')
// 托盘图标:优先用部署包里的 scoreboard.png(换图标不用重新打包),
// 找不到时回落到内嵌 base64,保证任何情况下托盘都有图标。
// 图标一律 PNG(Windows 的 .ico 只在安装器与快捷方式上用):两个平台的托盘都吃 PNG。
const ICON_PNG_BASE64 =
'iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAABUklEQVR42u2XwUdEURTG5z9JSholJY2SklJSf0+0iKFFtIhoES0iWkSLiBaRDKNSKU2GjCRGEkkSSZsvb3GfnHPnzTm382Y1P36b9x33fKv37stkmiiYLf1AovnimatvhPjvxdMXX7AwaPnU6ScsVS2fLH4gDUXLJwrvSNO6BcaP3pCmicvHDl/RCGsWGD14QSP0Lh/Zf4aVFN8MKzC89wQrKb4ZVmBotworKb4ZVmBw5xFWUnwzrEBu+wFWUnwzrMDA1j00UqSZkxXo36xAI0WaOVmBvo07aKRIMycr0LtehkaKNHOyAj1rt9BIkWZO79uwe/UGUinSLLLmt6Br5Rp/pVhkkYlfxOzyJZwUi6zufaBz6RxOikUmuhV1LJ4hkuKeh2aqe2F7/oQdFD1zarOgmzE9qG2hGKvJgv8L6EGt84VYTWZWoGXuOFaTNX9qk/gFLmHiXP3t+GEAAAAASUVORK5CYII='
@@ -19,17 +20,20 @@ function resolveIconPath() {
return candidates.find((candidate) => candidate && fs.existsSync(candidate)) || null
}
// 托盘按哪个尺寸出图:Windows 托盘是 16px,缩放交给系统会更模糊,这里直接给 16px;
// Linux 的通知区图标位通常按面板高度取 22px,只给 16px 会被放大成一团糊的。
const TRAY_ICON_SIZE = process.platform === 'linux' ? 22 : 16
function createTrayIcon() {
const file = resolveIconPath()
if (file) {
const image = nativeImage.createFromPath(file)
if (!image.isEmpty()) {
// Windows 托盘按 16px 显示,缩放交给系统会更模糊,这里直接给 16px
return image.resize({ width: 16, height: 16 })
return image.resize({ width: TRAY_ICON_SIZE, height: TRAY_ICON_SIZE })
}
}
const fallback = nativeImage.createFromDataURL(`data:image/png;base64,${ICON_PNG_BASE64}`)
return fallback.resize({ width: 16, height: 16 })
return fallback.resize({ width: TRAY_ICON_SIZE, height: TRAY_ICON_SIZE })
}
module.exports = { createTrayIcon }
+40 -16
View File
@@ -3,7 +3,7 @@
const fs = require('fs')
const path = require('path')
const { LOCAL_BASE } = require('./datadir')
const { APP_DIR_NAME, xdgDataHome } = require('./platform')
// 这三样以前随安装包落在安装目录里,但都属于「用户改出来的东西」,必须跟数据目录一样
// 挪到安装目录之外:卸载器结尾是 `RMDir /r $INSTDIR`,覆盖安装又会先静默跑一遍旧卸载器,
@@ -11,7 +11,9 @@ const { LOCAL_BASE } = require('./datadir')
// console\config.json → <用户根>\console\config.json
// config.yaml → <用户根>\config.yaml
// rules\ → <用户根>\rules\
// 用户根与数据目录同一基座:%LOCALAPPDATA%\ScoreboardConsole\
// 用户根与数据目录同一基座,只是基座本身按平台不同:
// 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'
@@ -25,17 +27,34 @@ const ITEM_LABEL = {
rulesDir: '规则目录'
}
// 用户根目录;拿不到 %LOCALAPPDATA% 时返回 null,由调用方回落到安装目录
function resolveUserRoot(env) {
const localAppData = env ? env.LOCALAPPDATA : ''
const value = typeof localAppData === 'string' ? localAppData.trim() : ''
return value ? path.join(value, LOCAL_BASE) : null
// 拿不到用户数据基座时的提示:两边缺的不是同一个变量,文案各说各的,
// 免得现场照着去设一个本平台不存在的环境变量
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' 才接管到用户目录;
// 其余情况(开发态、拿不到 %LOCALAPPDATA%)三项一律用安装目录里随包的那份,
// 其余情况(开发态、拿不到用户数据基座)三项一律用安装目录里随包的那份,
// 行为与以前完全一致 —— 开发态不能动仓库里的文件。
function planUserPaths({ installDir, userRoot, isPackaged }) {
function planUserPaths({ installDir, userRoot, isPackaged, platform = 'win32' }) {
const install = path.resolve(installDir || '.')
const templates = {
consoleConfig: path.join(install, CONSOLE_CONFIG_FILE),
@@ -49,7 +68,15 @@ function planUserPaths({ installDir, userRoot, isPackaged }) {
rulesDir: path.join(install, RULES_SUBDIR)
}
const reason = !isPackaged ? 'dev' : userRoot ? null : 'no-localappdata'
// 缺的基座两边不同名: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)
@@ -115,12 +142,9 @@ function prepareUserPaths({ plan, log }) {
}
if (plan.mode !== 'user') {
if (plan.reason === 'no-localappdata') {
notify(
'警告:拿不到 %LOCALAPPDATA%,config.json / config.yaml / rules 继续用安装目录里的那份,' +
'覆盖安装会连同它们一起丢失'
)
}
// 两个 reason 对应两个平台缺的那个变量,文案不同但处理完全一致
const warning = NO_ROOT_WARNING[plan.reason]
if (warning) notify(warning)
return result
}
+68
View File
@@ -197,3 +197,71 @@ test('-config 的值换成解析出的绝对路径,其余参数原样保留',
assert.deepEqual(withConfigArg(['-config', 'config.yaml'], null), ['-config', 'config.yaml'])
assert.deepEqual(withConfigArg(null, '/u/config.yaml'), ['-config', '/u/config.yaml'])
})
// —— 以下是 Linux 分支:用户根从 %LOCALAPPDATA% 换成 XDG 的数据目录,
// 目录名与三项的落点(console/config.json、config.yaml、rules)一个字不变。
test('Linux 打包态三项都落到 $XDG_DATA_HOME/ScoreboardConsole 下', () => {
const xdg = path.resolve('/xdg/data')
const userRoot = resolveUserRoot({ XDG_DATA_HOME: xdg }, 'linux')
const plan = planUserPaths({ installDir: INSTALL, userRoot, isPackaged: true, platform: 'linux' })
assert.equal(plan.mode, 'user')
assert.equal(plan.root, path.join(xdg, 'ScoreboardConsole'))
assert.equal(plan.items.consoleConfig.target, path.join(xdg, 'ScoreboardConsole', 'console', 'config.json'))
assert.equal(plan.items.serverConfig.target, path.join(xdg, 'ScoreboardConsole', 'config.yaml'))
assert.equal(plan.items.rulesDir.target, path.join(xdg, 'ScoreboardConsole', 'rules'))
})
test('Linux 没设 $XDG_DATA_HOME 时用户根落在 ~/.local/share/ScoreboardConsole', () => {
const home = path.resolve('/home/operator')
assert.equal(resolveUserRoot({ HOME: home }, 'linux'), path.join(home, '.local', 'share', 'ScoreboardConsole'))
})
test('Linux 拿不到用户根时回落安装目录,提示文案点名 XDG 变量', () => {
const plan = planUserPaths({
installDir: INSTALL,
userRoot: resolveUserRoot({}, 'linux'),
isPackaged: true,
platform: 'linux'
})
assert.equal(plan.mode, 'install')
assert.equal(plan.reason, 'no-user-root')
const lines = []
const paths = prepareUserPaths({ plan, log: (line) => lines.push(line) })
assert.equal(paths.consoleConfig, path.join(INSTALL, 'config.json'))
assert.equal(paths.serverConfig, null)
assert.match(lines.join('\n'), /\$XDG_DATA_HOME/)
assert.match(lines.join('\n'), /\$HOME/)
})
test('Linux 开发态不接管,三项都指向安装目录里随包的那份', () => {
const home = path.resolve('/home/operator')
const plan = planUserPaths({
installDir: INSTALL,
userRoot: resolveUserRoot({ HOME: home }, 'linux'),
isPackaged: false,
platform: 'linux'
})
assert.equal(plan.mode, 'install')
assert.equal(plan.reason, 'dev')
assert.equal(plan.items.rulesDir.target, path.join(INSTALL, 'rules'))
})
test('Linux 首次运行同样把随包的三项复制到用户目录', (t) => {
const root = makeRoot(t)
const installDir = path.join(root, 'install')
const userRoot = path.join(root, 'xdg', 'ScoreboardConsole')
writeFile(path.join(installDir, 'config.json'), '{"server":{"port":19541}}')
writeFile(path.join(installDir, 'config.yaml'), 'server:\n port: 19541\n')
writeFile(path.join(installDir, 'rules', 'cup.yaml'), 'format: scoreboard-rules\n')
const paths = prepareUserPaths({
plan: planUserPaths({ installDir, userRoot, isPackaged: true, platform: 'linux' }),
log: () => {}
})
assert.equal(paths.consoleConfig, path.join(userRoot, 'console', 'config.json'))
assert.equal(readFile(paths.serverConfig), 'server:\n port: 19541\n')
assert.equal(readFile(path.join(userRoot, 'rules', 'cup.yaml')), 'format: scoreboard-rules\n')
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "scoreboard-console",
"productName": "Scoreboard Console",
"version": "0.5.9",
"version": "0.5.10",
"private": true,
"author": "AskaEth",
"description": "赛事积分榜 · 桌面宿主:守护 Go 服务端并把大屏投到扩展屏",