Files
Scoreboard/console/main/displays.js
T
AskaEth 793d2ac064 fix(console): 修「全屏时反而画标题栏」——窗口模式必须与 URL 的 windowed 一致
用户实机反馈:单屏窗口化窗口按 F11 全屏后标题栏不消失,退回窗口化反而没标题栏,
表现与预期完全相反。

根因不在标题栏,而在 Console 侧的窗口模式管理:entry 没记录窗口是以什么模式建的,
layout() 每次排布都拿「整机是否单屏」的 single 去覆盖每个已存在窗口的几何与模式
(!single 时会 win.setKiosk(true) + 铺满),而页面 URL 里的 windowed=1 是建窗时定死的、
loadPage 只在页码变化时才重载 —— 于是窗口的真实模式与页面认知脱钩:
窗口化窗口被改成 kiosk 全屏而 URL 仍带 windowed=1(全屏却画标题栏),反向亦然。

修复:建立并保证「窗口几何/模式 ↔ URL 的 windowed 必须永远一致」
- entry 记 { win, page, single, manual, reloadTimer }
- createWindow(..., manual=false);openOn() 传 manual=true(手动开的窗口不被排布改模式)
- layout() 的 existing 分支:target = manual ? entry.single : single,applyGeometry 与
  loadPage 都用同一个 target,且 modeChanged || page 变了都要重载 URL
- 删掉 isKioskWindow()(用 isFullScreen() 反推会把「窗口化窗口被 F11 全屏」误报成 kiosk),
  换成 modeOf(entry)
- F11 通信时序:enter/leave-full-screen 回调里不再回读 win.isFullScreen()(切换瞬间可能读到
  旧值,页面会收到相反状态),改为下发事件本身的确定值;F11 直接下发算出来的 next

回归测试 console/main/displays.test.js(8 例,assertModeConsistent 四处自洽)。
反向对照:修复前的 displays.js 跑同一份测试是 1 pass / 7 fail。
Console 语法检查 18 个文件、node --test 28 例全过。
2026-09-11 00:13:09 +08:00

404 lines
16 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'
}
// 大屏窗口管理:按显示器枚举结果开窗,绝不写死坐标。
// 坐标一律用 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
this.boundSingle = null
// 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 时(纯台式机)退回「排除主屏」的旧口径。
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(single)
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: !single && this.display.kiosk !== false,
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
}
})
// 大屏页零交互控件;窗口层再兜一道,防止现场误触鼠标
if (!single && this.display.ignoreMouse !== false) {
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
}
}
applyGeometry(win, display, single) {
if (!win || win.isDestroyed()) return
if (single) {
if (win.isKiosk()) win.setKiosk(false)
if (win.isFullScreen()) win.setFullScreen(false)
} else if (this.display.kiosk !== false) {
win.setKiosk(true)
}
win.setBounds(this.geometryFor(display, single))
}
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()}`
}
// 单屏模式要「行为冻结」:不跟随屏幕变化重排,所以连监听都不挂
bindScreenListeners(single) {
if (this.screenListeners.length && this.boundSingle === single) return
this.unbindScreenListeners()
this.boundSingle = single
if (single) return
const schedule = () => {
if (this.layoutTimer) clearTimeout(this.layoutTimer)
// 分辨率/缩放变化会连续触发多次,防抖后再重排
this.layoutTimer = setTimeout(() => {
this.layoutTimer = null
this.layout()
}, 600)
}
screen.on('display-metrics-changed', schedule)
screen.on('display-added', schedule)
screen.on('display-removed', schedule)
this.screenListeners = [
['display-metrics-changed', schedule],
['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
const entry = this.createWindow(display, 0, !kiosk, wantedPage, true)
this.windows.set(display.id, entry)
return { created: true, mode: this.modeOf(entry) }
}
// 关掉某块屏上的大屏窗口:kiosk 窗口刻意忽略鼠标,误开后只能从这里收
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 }