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

+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;注册放在这里,