diff --git a/VERSION b/VERSION index d7e8679..a48bb21 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -dev_0.5.9 +dev_0.5.10 diff --git a/console/installer/art/1x/brand.bmp b/console/installer/art/1x/brand.bmp index e6105b1..8dd9d3e 100644 Binary files a/console/installer/art/1x/brand.bmp and b/console/installer/art/1x/brand.bmp differ diff --git a/console/installer/art/2x/brand.bmp b/console/installer/art/2x/brand.bmp index 95d5c5b..e5a03f5 100644 Binary files a/console/installer/art/2x/brand.bmp and b/console/installer/art/2x/brand.bmp differ diff --git a/console/installer/art/version.txt b/console/installer/art/version.txt index 416bfb0..50c76ef 100644 --- a/console/installer/art/version.txt +++ b/console/installer/art/version.txt @@ -1 +1 @@ -0.5.9 +0.5.10 diff --git a/console/main/autostart.js b/console/main/autostart.js new file mode 100644 index 0000000..24b0b98 --- /dev/null +++ b/console/main/autostart.js @@ -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 } diff --git a/console/main/autostart.test.js b/console/main/autostart.test.js new file mode 100644 index 0000000..1368bf9 --- /dev/null +++ b/console/main/autostart.test.js @@ -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) +}) diff --git a/console/main/config.js b/console/main/config.js index f40cebb..df6eb33 100644 --- a/console/main/config.js +++ b/console/main/config.js @@ -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) diff --git a/console/main/config.test.js b/console/main/config.test.js index 8f0f6ae..f27fa0e 100644 --- a/console/main/config.test.js +++ b/console/main/config.test.js @@ -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) +}) diff --git a/console/main/datadir.js b/console/main/datadir.js index 5c7c84d..7b0c02f 100644 --- a/console/main/datadir.js +++ b/console/main/datadir.js @@ -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%\(卸载器同样会删),所以默认落在 %LOCALAPPDATA%\ScoreboardConsole\data。 -// 配置与规则目录(userpaths.js)用同一个基座,这里导出给它复用。 -const LOCAL_BASE = 'ScoreboardConsole' +// 也不能放 %APPDATA%\(卸载器同样会删),所以 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 } diff --git a/console/main/datadir.test.js b/console/main/datadir.test.js index 9ce058d..b7bbc04 100644 --- a/console/main/datadir.test.js +++ b/console/main/datadir.test.js @@ -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') + ) +}) diff --git a/console/main/displays.js b/console/main/displays.js index 5711586..78d8fa4 100644 --- a/console/main/displays.js +++ b/console/main/displays.js @@ -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 diff --git a/console/main/index.js b/console/main/index.js index c9200fc..fb3b50d 100644 --- a/console/main/index.js +++ b/console/main/index.js @@ -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;注册放在这里, diff --git a/console/main/platform.js b/console/main/platform.js new file mode 100644 index 0000000..db2deb6 --- /dev/null +++ b/console/main/platform.js @@ -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 } diff --git a/console/main/platform.test.js b/console/main/platform.test.js new file mode 100644 index 0000000..ec77bbe --- /dev/null +++ b/console/main/platform.test.js @@ -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) +}) diff --git a/console/main/service.js b/console/main/service.js index af7755a..17f690c 100644 --- a/console/main/service.js +++ b/console/main/service.js @@ -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 }) { diff --git a/console/main/tray-icon.js b/console/main/tray-icon.js index 5e846be..a8ab399 100644 --- a/console/main/tray-icon.js +++ b/console/main/tray-icon.js @@ -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 } diff --git a/console/main/userpaths.js b/console/main/userpaths.js index f2cd9b9..6ff6097 100644 --- a/console/main/userpaths.js +++ b/console/main/userpaths.js @@ -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 } diff --git a/console/main/userpaths.test.js b/console/main/userpaths.test.js index f3d535c..33541ae 100644 --- a/console/main/userpaths.test.js +++ b/console/main/userpaths.test.js @@ -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') +}) diff --git a/console/package.json b/console/package.json index 594bcd9..02274ac 100644 --- a/console/package.json +++ b/console/package.json @@ -1,7 +1,7 @@ { "name": "scoreboard-console", "productName": "Scoreboard Console", - "version": "0.5.9", + "version": "0.5.10", "private": true, "author": "AskaEth", "description": "赛事积分榜 · 桌面宿主:守护 Go 服务端并把大屏投到扩展屏",