Files
Scoreboard/console/main/displays.js
T
AskaEth efa049c827 feat(platform): Console 支持 Linux(arm64),Windows 行为逐字不变;版本 dev_0.5.10
需求:让积分榜能在飞腾 D2000(ARM64)的 Linux 上跑起来,**同时不破坏 Windows**。

## 一、Go 服务端:交叉编译即可(**已实测**)
- SQLite 用的是 `modernc.org/sqlite`(纯 Go 驱动),全项目**没有一处 `import "C"`** → `CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build` 直接出;
- 产物是**静态链接**(`ELF 64-bit LSB executable, ARM aarch64, statically linked`)→ **不依赖 glibc 版本**,对国产系统友好;
- 本机(arm64)**实际启动验证**:数据目录、局域网地址探测、数据库迁移(0001/0002/0003)全部正常;
- 产物:`debug/服务端-arm64/scoreboard`(29 MB,`-s -w`)。

## 二、Console(Electron 宿主):平台差异收口到一个新文件
- **新增 `console/main/platform.js`**:平台差异集中在这 45 行里,其余代码不写平台判断。
  ⚠️ **`platform` 参数缺省按 Windows 解析** —— 漏传参数走的就是改动前那条老路(这比"改了再小心"更保守:**默认行为不变,新行为必须显式传参**)。
- `userpaths.js` / `datadir.js`:Windows 分支 `%LOCALAPPDATA%\ScoreboardConsole\` **逐字保留**;Linux 分支按 XDG(`$XDG_DATA_HOME`,缺省 `~/.local/share`)。两平台各自的"拿不到用户目录"提示文案分开写(免得现场去设一个本平台不存在的环境变量)。
- `config.js`:服务端文件名按平台取(Windows `scoreboard.exe` / Linux `scoreboard`)。
- **新增 `console/main/autostart.js`**:Linux 用 `~/.config/autostart/*.desktop`(开=写文件、关=删文件;Linux 没有"已注册但未启用"这个中间态)。
- 托盘与窗口行为:Linux 的通知区多数不派发 double-click,因此按平台分支成单击。

## 三、怎么保证没弄坏 Windows
1. **设计上**:平台差异集中 + 缺省值就是 Windows(见上);
2. **逐行核对 diff**:Windows 分支那几行原样未动;
3. **测试**:Console 由 **68 → 96 例**,新增全是平台分支用例,**既有断言一条没改**;
4. **真机验证**:构建机重打 Windows 包并跑(这一道才算收口)。

## 四、自检
全量自检两轮**全绿、`EXIT=0`**:服务端 14 包 / 前端 **944 例** / Console **96 例**。

## 版本
`dev_0.5.9` → **`dev_0.5.10`**;安装器素材同步重生成(照例只动 `brand.bmp` 两张)。
2026-09-15 00:53:16 +08:00

461 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict'
const os = require('os')
const path = require('path')
const { BrowserWindow, screen } = require('electron')
const { healthHost } = require('./service')
const SELECTION = {
ALL_SECONDARY: 'all-secondary',
ALL: 'all',
SPECIFIED: 'specified'
}
// 只比窗口矩形本身:getBounds() 在真实 Electron 里会多带几个键,比整对象会永远判不等
function sameBounds(a, b) {
return Boolean(a) && a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height
}
// 大屏窗口管理:按显示器枚举结果开窗,绝不写死坐标。
// 坐标一律用 DIP(display.bounds),页面内部再用 CSS 缩放适配,两边互不干扰。
class DisplayManager {
constructor({ values, server, log }) {
this.display = values.display
this.server = server
this.log = log
this.enabled = false
this.single = true
// entry.single 记这个窗口是以哪种模式建的(true=窗口化,false=kiosk),
// entry.manual 记它是管理面板手动开的(不跟随自动排布改模式)
this.windows = new Map() // displayId -> { win, page, single, manual, reloadTimer }
this.screenListeners = []
this.layoutTimer = null
}
enable() {
if (this.enabled) return
this.enabled = true
this.layout()
}
pageFor(index) {
const pages = Array.isArray(this.display.pages) && this.display.pages.length ? this.display.pages : ['standings']
// 单屏没有编排可言,固定锁第一页(默认总积分榜)
if (this.single) return pages[0]
return pages[index % pages.length]
}
pickDisplays() {
const all = screen.getAllDisplays()
const primary = screen.getPrimaryDisplay()
// 单屏机器上谈「扩展屏编排」没有意义,直接窗口化
if (all.length <= 1) return { single: true, displays: all.slice(0, 1) }
const selection = this.display.selection || SELECTION.ALL_SECONDARY
let picked
if (selection === SELECTION.ALL) {
picked = all.slice()
} else if (selection === SELECTION.SPECIFIED) {
picked = this.pickSpecified(all)
} else {
picked = this.pickSecondary(all, primary)
}
// 只有整机就一块屏时才是「单屏测试模式」;笔记本 + 一块扩展屏属于多屏场景,
// 扩展屏要正常 kiosk,不能退化成窗口化
if (!picked.length) return { single: true, displays: [primary] }
return { single: false, displays: picked }
}
// 默认策略是「除内置屏外的每块屏」(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
return all.filter((item) => item.id !== primary.id)
}
// displays 配置里可以写 display.id,也可以写 0 起的序号
pickSpecified(all) {
const wanted = Array.isArray(this.display.displays) ? this.display.displays : []
const picked = []
for (const key of wanted) {
const match = typeof key === 'number' ? all.find((item) => item.id === key) || all[key] : all.find((item) => String(item.id) === String(key))
if (match && !picked.includes(match)) picked.push(match)
}
return picked
}
layout() {
if (!this.enabled) return
const { single, displays } = this.pickDisplays()
this.single = single
this.bindScreenListeners()
const wanted = new Set(displays.map((item) => item.id))
for (const [displayId, entry] of [...this.windows]) {
if (!wanted.has(displayId)) {
this.destroyEntry(displayId, entry)
}
}
displays.forEach((display, index) => {
const page = this.pageFor(index)
const existing = this.windows.get(display.id)
// 窗口可能被外部关闭/崩溃销毁而没走 destroyEntry,这里当作不存在重建,
// 否则 applyGeometry/loadPage 对 destroyed 窗口静默 return,现场只剩余黑屏
if (existing && existing.win && !existing.win.isDestroyed()) {
// 手动开的窗口保持建窗时定的模式,自动排布只负责摆位置;
// 排布自己开的窗口跟随本次排布(电视拔了就该变回窗口化)
const target = existing.manual ? existing.single : single
const modeChanged = target !== existing.single
existing.single = target
this.applyGeometry(existing.win, display, target)
// 模式变了必须连 URL 一起换:URL 里的 windowed 是页面对「要不要画标题栏」的全部认知,
// 只改几何不重载,页面就会带着旧参数画(全屏仍画标题栏、窗口化却空着一条)
if (modeChanged || existing.page !== page) {
existing.page = page
this.loadPage(existing, display, page, target)
}
return
}
if (existing) {
if (existing.reloadTimer) clearInterval(existing.reloadTimer)
this.windows.delete(display.id)
}
this.windows.set(display.id, this.createWindow(display, index, single, page))
})
this.log.push('console', `大屏排布完成:${single ? '单屏窗口化' : `多屏 kiosk × ${displays.length}`},显示器 ${displays.map((d) => d.id).join(', ')}`)
}
createWindow(display, index, single, page, manual = false) {
const bounds = display.bounds
const win = new BrowserWindow({
...this.geometryFor(display, single),
show: false,
// 窗口化模式由页面自绘标题栏(与应用其它窗口同风格),保留原生边框会多出一条标题栏;
// 多屏 kiosk 本来就不要边框
frame: false,
// 与 Console 主窗口同一套兜底:个别平台会忽略 frame:false
titleBarStyle: 'hidden',
// 这里刻意不给 kiosk:进 kiosk 必须发生在 setBounds 之后,而 setBounds 与 kiosk
// 的先后顺序是「压不压得住任务栏」的关键,只留 applyGeometry 一处去管,
// 免得构造函数和 applyGeometry 各设一遍、顺序互相打架
autoHideMenuBar: true,
backgroundColor: this.display.backgroundColor || '#000000',
title: `大屏 · ${page}`,
webPreferences: {
// 页面看不到窗口级全屏,靠这个 preload 转达(见 display-preload.js)
preload: path.join(__dirname, '..', 'renderer', 'display-preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
// 大屏常年在后台焦点之外,默认节流会冻结动画和定时器
backgroundThrottling: false
}
})
// 大屏页没有可点的控件,但窗口**默认仍然吞掉鼠标**(不穿透)。
// 这里原来是「默认 setIgnoreMouseEvents(true)」,方向恰好反了:
// setIgnoreMouseEvents(true) 的语义不是「本窗口不吃误触」,而是「本窗口完全忽略鼠标」,
// 点击会落到它后面的窗口或桌面上——现场想防的误触反而变成了误点别的东西;
// 而且被忽略鼠标的窗口点不活、被盖住后也抢不回前台。
// 只有确实要穿透(把大屏当纯展示层、同一块屏上还要操作别的窗口)才显式配
// display.allowClickThrough: true,代价见 README。
// 旧键 display.ignoreMouse 在这里**完全不读**:老机器的配置文件里还被旧版模板写着
// ignoreMouse: true,读它就等于老机器升级后继续穿透(详见 config.js 的 DEFAULTS 注释);
// 旧键的存在与否由 config.js 在启动时提示一次,这里不掺和。
if (!single && this.display.allowClickThrough === true) {
win.setIgnoreMouseEvents(true)
}
// 窗口级全屏只在主进程可见:页面拿不到 setFullScreen 的结果,标题栏也就不会让位。
// 负载优先用调用方给的确定值——在回调里回读 win.isFullScreen() 会读到切换瞬间的旧值
// (Windows 上尤其),页面就会收到相反的状态、把标题栏画反。只有加载完成那次窗口
// 状态已经稳定,才回落到「读一次」。
const notifyFullscreen = (fullscreen) => {
if (win.isDestroyed()) return
win.webContents.send('window:fullscreen-changed', fullscreen === undefined ? win.isFullScreen() : fullscreen === true)
}
// F11 只在单屏模式下有意义(现场调试用),多屏 kiosk 不允许切走
if (single) {
win.webContents.on('before-input-event', (event, input) => {
if (input.type === 'keyDown' && input.key === 'F11') {
event.preventDefault()
const next = !win.isFullScreen()
win.setFullScreen(next)
notifyFullscreen(next)
}
})
}
// 状态变化下发一次之外,加载完成还要补发一次——重载/渲染进程崩溃恢复后窗口可能
// 已经是全屏,只挂事件会让页面一直画着标题栏。
win.on('enter-full-screen', () => notifyFullscreen(true))
win.on('leave-full-screen', () => notifyFullscreen(false))
win.webContents.on('did-finish-load', () => notifyFullscreen())
win.once('ready-to-show', () => {
if (!win.isDestroyed()) win.show()
})
win.webContents.on('render-process-gone', (_event, details) => {
this.log.push('console', `大屏渲染进程异常(display=${display.id},${details.reason}),将重载`)
if (!win.isDestroyed()) win.webContents.reload()
})
// single 必须跟着 entry 走:页面 URL 里的 windowed 是按它生成的,
// 排布后来改了模式而这里没记,窗口的真实模式就和页面的认知脱钩了
const entry = { win, page, single, manual, reloadTimer: null }
// 窗口销毁(外部关闭/渲染进程崩溃)后必须把 entry 从 map 摘掉,
// 否则 layout/relayout 一直拿到已销毁窗口而静默跳过,现场只剩余黑屏
win.on('closed', () => {
if (entry.reloadTimer) clearInterval(entry.reloadTimer)
// 只有 map 里还是这个 entry 时才删,避免旧窗口的 closed 误删重建后的新窗口
if (this.windows.get(display.id) === entry) this.windows.delete(display.id)
})
// 无人值守跑 24h 以上,定时重载兜底内存与连接泄漏
const hours = Number(this.display.reloadEveryHours) || 0
if (hours > 0) {
entry.reloadTimer = setInterval(() => {
if (!win.isDestroyed()) win.webContents.reload()
}, hours * 3600 * 1000)
}
this.applyGeometry(win, display, single)
this.loadPage(entry, display, page, single)
this.log.push('console', `打开大屏窗口 display=${display.id} bounds=${bounds.width}x${bounds.height}@${bounds.x},${bounds.y} page=${page}`)
return entry
}
geometryFor(display, single) {
const bounds = display.bounds
if (!single) return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }
// 单屏窗口化:不铺满,留出边距方便看管理窗口和调试
const width = Math.round(bounds.width * 0.8)
const height = Math.round(bounds.height * 0.8)
return {
x: bounds.x + Math.round((bounds.width - width) / 2),
y: bounds.y + Math.round((bounds.height - height) / 2),
width,
height
}
}
// 把窗口摆到目标屏、并把窗口态调到目标模式。**这里的调用顺序是「大屏能不能压住任务栏」
// 的全部理由**,动它之前先读下面 kiosk 分支的注释。
applyGeometry(win, display, single) {
if (!win || win.isDestroyed()) return
const bounds = this.geometryFor(display, single)
const wantKiosk = !single && this.display.kiosk !== false
if (!wantKiosk) {
// 窗口化(单屏调试态)与「多屏但 display.kiosk=false」:一律不置顶。
// 单屏窗口化的 F11 全屏由用户自己控制,置顶会把它变成一块收不掉、盖住一切的覆盖层;
// 从 kiosk 退回来的窗口也必须在这里把置顶摘掉,否则会带着 WS_EX_TOPMOST 留在窗口化态
if (win.isKiosk()) win.setKiosk(false)
if (win.isFullScreen()) win.setFullScreen(false)
win.setBounds(bounds)
win.setAlwaysOnTop(false)
return
}
// layout() 会因为插拔屏幕/改分辨率/改缩放在运行期反复调用;已经是对的状态就直接返回,
// 否则每次重排都要退出再进入一次 kiosk,现场大屏会白闪一下
if (win.isKiosk() && win.isAlwaysOnTop() && sameBounds(win.getBounds(), bounds)) return
// 顺序不能反,三条各自都是一次现场事故:
// ① 已经在 kiosk 里就先退出。Windows 上对全屏窗口调 setBounds 会把它从真正的全屏态
// 拽回「恰好铺满该屏的无边框窗口」;此时再 setKiosk(true),Electron 侧会因为内部
// 仍认为是 kiosk 而直接返回、什么都不做,窗口就永远停在那个假全屏态——它不是
// topmost,任务栏(topmost)和任何拖到这块屏上的窗口都能画在它上面;
// ② 先 setBounds 定位,后 setKiosk(true) 进全屏。这样窗口进 kiosk 时已经在正确的屏上,
// 真正处于全屏态的窗口既压住任务栏,也不会被拖过来的窗口盖住;
// ③ 置顶放最后,之后不许再有任何改动窗口几何/窗口态的调用,否则 topmost 可能又丢。
if (win.isKiosk()) win.setKiosk(false)
if (win.isFullScreen()) win.setFullScreen(false)
win.setBounds(bounds)
win.setKiosk(true)
// 层级必须给 'screen-saver':Windows 上只有 pop-up-menu 及以上才画在任务栏之上,
// setAlwaysOnTop(true) 的默认层级 'floating' 仍然在任务栏下面。置顶之后别的窗口
// 拖到这块屏上也盖不住它,点一下就能回到最前——大屏没有键盘,这是唯一的抢回前台手段。
// 这里不需要再监听 blur 或起定时器补偿:topmost 是 z 序属性、与焦点无关,
// 定时重设只会白白抢焦点,属于无因之果
win.setAlwaysOnTop(true, 'screen-saver')
}
loadPage(entry, display, page, single) {
if (!entry || !entry.win || entry.win.isDestroyed()) return
const url = this.urlFor(display, page, single)
this.log.push('console', `大屏加载 ${url}`)
entry.win.loadURL(url).catch((err) => {
// 服务端还没起来时加载失败是正常的,健康检查通过后会重排重载
this.log.push('console', `大屏加载失败(display=${display.id}):${err.message}`)
})
}
urlFor(display, page, single) {
const params = new URLSearchParams()
params.set(this.display.pageParam || 'page', page)
// lock=1 告知页面锁死单页,不参与轮播
params.set('lock', '1')
params.set('screen', String(display.id))
params.set('instance', `${os.hostname()}-${display.id}`)
// 只有单屏窗口化才画标题栏;多屏 kiosk 全屏无人值守,不能占高度
if (single) params.set('windowed', '1')
// 与健康检查/管理面板同一套 host 归一:0.0.0.0/:: 走回环,其余(如内网 IP)原样
return `http://${healthHost(this.server)}:${this.server.port}/display?${params.toString()}`
}
// 屏幕监听常驻,只有 closeAll()(服务停止 / 应用退出)才摘:
// 单屏态的「行为冻结」只冻结分辨率与缩放,display-added / display-removed 必须一直听着。
// 否则「只剩一块屏」那次排布会把监听整个摘掉,扩展屏插回来再没人重排——
// 大屏永远停在窗口化小窗,电视黑屏,只能重启 Console(审查日志 P1(18))。
bindScreenListeners() {
if (this.screenListeners.length) return
const schedule = () => {
if (this.layoutTimer) clearTimeout(this.layoutTimer)
// 分辨率/缩放变化会连续触发多次,防抖后再重排
this.layoutTimer = setTimeout(() => {
this.layoutTimer = null
this.layout()
}, 600)
}
// 单屏窗口化是现场调试态,分辨率/缩放变化不跟着重排(保留原有的冻结语义);
// 屏幕数量变化不能冻结,那是「电视被拔/被插」的唯一信号
const metricsChanged = () => {
if (this.single) return
schedule()
}
screen.on('display-metrics-changed', metricsChanged)
screen.on('display-added', schedule)
screen.on('display-removed', schedule)
this.screenListeners = [
['display-metrics-changed', metricsChanged],
['display-added', schedule],
['display-removed', schedule]
]
}
unbindScreenListeners() {
for (const [event, handler] of this.screenListeners) {
screen.removeListener(event, handler)
}
this.screenListeners = []
}
destroyEntry(displayId, entry) {
if (entry.reloadTimer) clearInterval(entry.reloadTimer)
if (entry.win && !entry.win.isDestroyed()) entry.win.destroy()
this.windows.delete(displayId)
this.log.push('console', `关闭大屏窗口 display=${displayId}`)
}
relayout() {
if (!this.enabled) return
for (const [, entry] of this.windows) {
if (entry.win && !entry.win.isDestroyed()) entry.win.webContents.reload()
}
this.layout()
}
// 窗口模式只看 entry 记的建窗模式,不用 win.isKiosk()/isFullScreen() 反推:
// 窗口化窗口被 F11 全屏后 isFullScreen() 也是 true,反推会把窗口化误报成 kiosk
modeOf(entry) {
return entry && entry.single === false ? 'kiosk' : 'windowed'
}
// 管理面板手动为某块屏开窗:已经开着就只聚焦,重建会打断正在展示的大屏
openOn(display, { page, single } = {}) {
const kiosk = !single && this.display.kiosk !== false
const wantedPage = page || this.pageFor(0)
const existing = this.windows.get(display.id)
if (existing && existing.win && !existing.win.isDestroyed()) {
if (existing.win.isMinimized()) existing.win.restore()
existing.win.show()
existing.win.focus()
// 已有窗口按它的真实模式报(可能与本次请求不同),面板才不会说反
return { created: false, mode: this.modeOf(existing) }
}
// 已销毁的窗口占着 map 会让后续 layout 静默跳过,先摘掉旧 entry 再建
if (existing) {
if (existing.reloadTimer) clearInterval(existing.reloadTimer)
this.windows.delete(display.id)
}
// 手动开的窗口也计入 enabled:服务停止时 closeAll 才会把它一起收走。
// manual=true:自动排布不许改它的模式,否则面板开的小窗会被下一次排布顶成 kiosk
this.enabled = true
// 面板开窗同样是「管理已激活」,监听一并挂上:否则在这个状态下拔插电视没人听见
this.bindScreenListeners()
const entry = this.createWindow(display, 0, !kiosk, wantedPage, true)
this.windows.set(display.id, entry)
return { created: true, mode: this.modeOf(entry) }
}
// 关掉某块屏上的大屏窗口:窗口默认置顶且压住任务栏,误开/要收场时只能从这里收
closeDisplay(displayId) {
const entry = this.windows.get(displayId)
if (!entry) return false
this.destroyEntry(displayId, entry)
return true
}
// 只读枚举显示器与窗口状态,给管理面板的显示器菜单用,不改动任何窗口
listDisplays() {
const all = screen.getAllDisplays()
const primary = screen.getPrimaryDisplay()
const list = all.map((display, index) => {
const entry = this.windows.get(display.id)
const open = Boolean(entry && entry.win && !entry.win.isDestroyed())
return {
id: display.id,
label: display.label || `显示器 ${display.id}`,
primary: display.id === primary.id,
// 一律给 DIP,与窗口坐标同一口径;物理像素对不上时看 scaleFactor
width: display.bounds.width,
height: display.bounds.height,
scaleFactor: display.scaleFactor,
open,
page: open ? entry.page : this.pageFor(index),
mode: open ? this.modeOf(entry) : null
}
})
// 单屏机器上谈「扩展屏全屏」没有意义,与 pickDisplays 同一口径
return { single: all.length <= 1, displays: list }
}
getInfo() {
return [...this.windows.entries()].map(([displayId, entry]) => ({
displayId,
page: entry.page,
url: entry.win && !entry.win.isDestroyed() ? entry.win.webContents.getURL() : '',
bounds: entry.win && !entry.win.isDestroyed() ? entry.win.getBounds() : null
}))
}
closeAll() {
if (this.layoutTimer) clearTimeout(this.layoutTimer)
this.unbindScreenListeners()
for (const [displayId, entry] of [...this.windows]) {
this.destroyEntry(displayId, entry)
}
this.enabled = false
}
}
module.exports = { DisplayManager }