diff --git a/console/README.md b/console/README.md index ff18900..3c2257d 100644 --- a/console/README.md +++ b/console/README.md @@ -28,6 +28,7 @@ console/ │ ├── instances.js # 已连接大屏实例看板:复用管理面板登录态读 /api/display/instances │ ├── account.js # 管理员账号重置:参数校验 + 调 scoreboard.exe passwd │ ├── displays.js # 大屏窗口管理:选屏、kiosk、排布、24h 重载 +│ ├── displays.test.js # 大屏窗口模式与 URL 一致性的回归测试 │ ├── log-bus.js # 日志环形缓冲 + 落文件 + 广播 │ └── tray-icon.js # 托盘图标(base64 内嵌,无外部资源文件) ├── scripts/ @@ -119,6 +120,7 @@ electron-builder 生成的卸载器结尾是 `RMDir /r $INSTDIR`,而**覆盖 | 场景 | 行为 | | --- | --- | | 只有一块屏 | 窗口化(屏幕 80% 大小、居中),不做多屏编排,**不挂** `display-metrics-changed` 等监听(行为冻结);`F11` 手动切换全屏,窗口级全屏状态经 `display-preload.js` 下发给页面,页面据此收起自绘标题栏 | +| 模式与 URL | **必须永远一致**:模式(窗口化 / kiosk)记在窗口的 entry 上,谁改模式谁按新模式重载 URL。`layout()` 只对排布自己开的窗口套用全局 `single`,管理面板手动开的窗口保持自己的模式——否则会出现「窗口化窗口被改成 kiosk 全屏、URL 里还留着 `windowed=1`」这种页面认知与窗口真实模式脱钩、标题栏画反的问题(回归测试见 `main/displays.test.js`)| | 多块屏 | 除内置屏外的每块屏一个 kiosk 窗口,按 `display.bounds`(DIP)定位,窗口 `setIgnoreMouseEvents(true)`,光标由页面 CSS 隐藏 | | 选屏 | 运行时枚举,不写死坐标;`selection` 可选 `all-secondary`(默认,用 `display.internal` 排除内置屏)/ `all` / `specified` | | 页面锁定 | 每块屏各锁一页,URL 形如 `http://127.0.0.1:19540/display?page=standings&lock=1&screen=&instance=-`,页序按 `display.pages` 轮转 | diff --git a/console/main/displays.js b/console/main/displays.js index 7ba361a..e77e968 100644 --- a/console/main/displays.js +++ b/console/main/displays.js @@ -22,7 +22,9 @@ class DisplayManager { this.enabled = false this.single = true this.boundSingle = null - this.windows = new Map() // displayId -> { win, page, reloadTimer } + // entry.single 记这个窗口是以哪种模式建的(true=窗口化,false=kiosk), + // entry.manual 记它是管理面板手动开的(不跟随自动排布改模式) + this.windows = new Map() // displayId -> { win, page, single, manual, reloadTimer } this.screenListeners = [] this.layoutTimer = null } @@ -102,10 +104,17 @@ class DisplayManager { // 窗口可能被外部关闭/崩溃销毁而没走 destroyEntry,这里当作不存在重建, // 否则 applyGeometry/loadPage 对 destroyed 窗口静默 return,现场只剩余黑屏 if (existing && existing.win && !existing.win.isDestroyed()) { - this.applyGeometry(existing.win, display, single) - if (existing.page !== page) { + // 手动开的窗口保持建窗时定的模式,自动排布只负责摆位置; + // 排布自己开的窗口跟随本次排布(电视拔了就该变回窗口化) + 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, single) + this.loadPage(existing, display, page, target) } return } @@ -119,7 +128,7 @@ class DisplayManager { this.log.push('console', `大屏排布完成:${single ? '单屏窗口化' : `多屏 kiosk × ${displays.length}`},显示器 ${displays.map((d) => d.id).join(', ')}`) } - createWindow(display, index, single, page) { + createWindow(display, index, single, page, manual = false) { const bounds = display.bounds const win = new BrowserWindow({ ...this.geometryFor(display, single), @@ -149,26 +158,32 @@ class DisplayManager { 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() - win.setFullScreen(!win.isFullScreen()) + const next = !win.isFullScreen() + win.setFullScreen(next) + notifyFullscreen(next) } }) } - // 窗口级全屏只在主进程可见:页面拿不到 setFullScreen 的结果,标题栏也就不会让位。 // 状态变化下发一次之外,加载完成还要补发一次——重载/渲染进程崩溃恢复后窗口可能 // 已经是全屏,只挂事件会让页面一直画着标题栏。 - const notifyFullscreen = () => { - if (win.isDestroyed()) return - win.webContents.send('window:fullscreen-changed', win.isFullScreen()) - } - win.on('enter-full-screen', notifyFullscreen) - win.on('leave-full-screen', notifyFullscreen) - win.webContents.on('did-finish-load', notifyFullscreen) + 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() @@ -179,7 +194,9 @@ class DisplayManager { if (!win.isDestroyed()) win.webContents.reload() }) - const entry = { win, page, reloadTimer: null } + // single 必须跟着 entry 走:页面 URL 里的 windowed 是按它生成的, + // 排布后来改了模式而这里没记,窗口的真实模式就和页面的认知脱钩了 + const entry = { win, page, single, manual, reloadTimer: null } // 窗口销毁(外部关闭/渲染进程崩溃)后必须把 entry 从 map 摘掉, // 否则 layout/relayout 一直拿到已销毁窗口而静默跳过,现场只剩余黑屏 @@ -299,10 +316,10 @@ class DisplayManager { this.layout() } - // 窗口的当前全屏状态:以窗口为准,配置改了 kiosk 但窗口没重建时也能报对 - isKioskWindow(win) { - if (!win || win.isDestroyed()) return false - return Boolean(win.isKiosk() || win.isFullScreen()) + // 窗口模式只看 entry 记的建窗模式,不用 win.isKiosk()/isFullScreen() 反推: + // 窗口化窗口被 F11 全屏后 isFullScreen() 也是 true,反推会把窗口化误报成 kiosk + modeOf(entry) { + return entry && entry.single === false ? 'kiosk' : 'windowed' } // 管理面板手动为某块屏开窗:已经开着就只聚焦,重建会打断正在展示的大屏 @@ -315,8 +332,8 @@ class DisplayManager { if (existing.win.isMinimized()) existing.win.restore() existing.win.show() existing.win.focus() - // 已有窗口按它的真实状态报模式(可能与本次请求不同),面板才不会说反 - return { created: false, mode: this.isKioskWindow(existing.win) ? 'kiosk' : 'windowed' } + // 已有窗口按它的真实模式报(可能与本次请求不同),面板才不会说反 + return { created: false, mode: this.modeOf(existing) } } // 已销毁的窗口占着 map 会让后续 layout 静默跳过,先摘掉旧 entry 再建 if (existing) { @@ -324,10 +341,12 @@ class DisplayManager { this.windows.delete(display.id) } - // 手动开的窗口也计入 enabled:服务停止时 closeAll 才会把它一起收走 + // 手动开的窗口也计入 enabled:服务停止时 closeAll 才会把它一起收走。 + // manual=true:自动排布不许改它的模式,否则面板开的小窗会被下一次排布顶成 kiosk this.enabled = true - this.windows.set(display.id, this.createWindow(display, 0, !kiosk, wantedPage)) - return { created: true, mode: kiosk ? 'kiosk' : 'windowed' } + const entry = this.createWindow(display, 0, !kiosk, wantedPage, true) + this.windows.set(display.id, entry) + return { created: true, mode: this.modeOf(entry) } } // 关掉某块屏上的大屏窗口:kiosk 窗口刻意忽略鼠标,误开后只能从这里收 @@ -355,7 +374,7 @@ class DisplayManager { scaleFactor: display.scaleFactor, open, page: open ? entry.page : this.pageFor(index), - mode: open ? (this.isKioskWindow(entry.win) ? 'kiosk' : 'windowed') : null + mode: open ? this.modeOf(entry) : null } }) // 单屏机器上谈「扩展屏全屏」没有意义,与 pickDisplays 同一口径 diff --git a/console/main/displays.test.js b/console/main/displays.test.js new file mode 100644 index 0000000..6da68f1 --- /dev/null +++ b/console/main/displays.test.js @@ -0,0 +1,335 @@ +'use strict' + +const assert = require('node:assert/strict') +const Module = require('node:module') +const test = require('node:test') +const { EventEmitter } = require('node:events') + +// displays.js 顶层 require('electron'),而开发机上的 electron 包没有下载 dist,直接 require 会抛。 +// 这里在加载它之前把 electron 换成桩:本文件只验证「窗口模式 ↔ URL 里的 windowed」这条不变式, +// 不需要真窗口,也跑不了真的窗口级全屏(那只能在 Windows 实机上验)。 +class FakeWebContents extends EventEmitter { + constructor() { + super() + this.url = '' + this.sent = [] + this.loadCount = 0 + } + + send(channel, payload) { + this.sent.push([channel, payload]) + } + + loadURL(url) { + this.url = url + this.loadCount += 1 + this.emit('did-finish-load') + return Promise.resolve() + } + + getURL() { + return this.url + } + + reload() { + this.loadCount += 1 + this.emit('did-finish-load') + } +} + +let created = [] + +class FakeBrowserWindow extends EventEmitter { + constructor(options) { + super() + this.options = options + this.webContents = new FakeWebContents() + this.destroyed = false + this.kiosk = options.kiosk === true + this.fullScreen = false + // 模拟真实 Electron 在切换瞬间回读到的旧值:设了它 isFullScreen() 就返回这个 + this.staleFullScreen = undefined + this.bounds = { x: options.x, y: options.y, width: options.width, height: options.height } + this.shown = false + created.push(this) + } + + // Electron 的 BrowserWindow.loadURL 是 webContents.loadURL 的快捷方式,displays.js 用的是前者 + loadURL(url) { + return this.webContents.loadURL(url) + } + + isDestroyed() { + return this.destroyed + } + + destroy() { + this.destroyed = true + this.emit('closed') + } + + isFullScreen() { + return this.staleFullScreen === undefined ? this.fullScreen : this.staleFullScreen + } + + setFullScreen(value) { + this.fullScreen = value + } + + isKiosk() { + return this.kiosk + } + + setKiosk(value) { + this.kiosk = value + } + + getBounds() { + return this.bounds + } + + setBounds(bounds) { + this.bounds = bounds + } + + setIgnoreMouseEvents(value) { + this.ignoreMouse = value + } + + show() { + this.shown = true + } + + focus() {} + + isMinimized() { + return false + } + + restore() {} +} + +const fakeScreen = { + displays: [], + primaryId: 1, + getAllDisplays() { + return this.displays + }, + getPrimaryDisplay() { + return this.displays.find((item) => item.id === this.primaryId) || this.displays[0] + }, + on() {}, + removeListener() {} +} + +const electronStub = { BrowserWindow: FakeBrowserWindow, screen: fakeScreen } +const originalLoad = Module._load +Module._load = function (request, parent, isMain) { + if (request === 'electron') return electronStub + return originalLoad.call(this, request, parent, isMain) +} +const { DisplayManager } = require('./displays') +Module._load = originalLoad + +const WIDTH = 1920 +const HEIGHT = 1080 + +function fakeDisplay(id, { internal = false } = {}) { + return { id, label: `显示器 ${id}`, internal, bounds: { x: 0, y: 0, width: WIDTH, height: HEIGHT }, scaleFactor: 1 } +} + +function makeManager(t, { selection = 'all', kiosk = true } = {}) { + fakeScreen.displays = [] + fakeScreen.primaryId = 1 + created = [] + const log = { lines: [], push(_scope, text) { this.lines.push(text) } } + const manager = new DisplayManager({ + values: { + display: { + selection, + displays: [], + pages: ['standings', 'medals'], + pageParam: 'page', + kiosk, + ignoreMouse: true, + reloadEveryHours: 0, + backgroundColor: '#000000' + } + }, + server: { host: '0.0.0.0', port: 19540 }, + log + }) + t.after(() => manager.closeAll()) + return manager +} + +/** + * 唯一不变式:窗口的几何/模式、页面 URL 里的 windowed 参数必须一致。 + * 这条断言在每个用例里都跑,任何一种脱钩都会在这里炸出来。 + */ +function assertModeConsistent(manager, displayId, expectedSingle) { + const entry = manager.windows.get(displayId) + assert.ok(entry, `display=${displayId} 上应该有窗口`) + + assert.equal(entry.single, expectedSingle, `entry.single 应为 ${expectedSingle}`) + assert.equal(entry.win.isKiosk(), expectedSingle === false, 'kiosk 标记应与 entry.single 一致') + + const url = new URL(entry.win.webContents.getURL()) + assert.equal(url.pathname, '/display') + assert.equal( + url.searchParams.get('windowed'), + expectedSingle ? '1' : null, + `URL 的 windowed 应与 entry.single 一致:${url.href}` + ) + + const bounds = entry.win.getBounds() + assert.equal(bounds.width, expectedSingle ? Math.round(WIDTH * 0.8) : WIDTH, '窗口宽度应与模式一致') + assert.equal(bounds.height, expectedSingle ? Math.round(HEIGHT * 0.8) : HEIGHT, '窗口高度应与模式一致') + + return entry +} + +test('单屏排布:窗口化窗口的 URL 带 windowed=1', (t) => { + const manager = makeManager(t) + fakeScreen.displays = [fakeDisplay(1, { internal: true })] + + manager.enable() + + const entry = assertModeConsistent(manager, 1, true) + assert.equal(entry.manual, false) +}) + +test('多屏排布:kiosk 窗口的 URL 不带 windowed', (t) => { + const manager = makeManager(t) + fakeScreen.displays = [fakeDisplay(1, { internal: true }), fakeDisplay(2)] + + manager.enable() + + assertModeConsistent(manager, 1, false) + assertModeConsistent(manager, 2, false) +}) + +test('回归:窗口化窗口被排布改成 kiosk 时,URL 一并换成不带 windowed 并重载', (t) => { + const manager = makeManager(t) + fakeScreen.displays = [fakeDisplay(1, { internal: true })] + manager.enable() + + const before = assertModeConsistent(manager, 1, true) + const loadsBefore = before.win.webContents.loadCount + assert.match(before.win.webContents.getURL(), /windowed=1/) + + // 插上第二块屏,整机从「单屏窗口化」变成「多屏 kiosk」 + fakeScreen.displays = [fakeDisplay(1, { internal: true }), fakeDisplay(2)] + manager.layout() + + const after = assertModeConsistent(manager, 1, false) + assert.equal(after.win, before.win, '同一个窗口应当就地改模式,而不是重建') + assert.equal(after.win.webContents.loadCount, loadsBefore + 1, '模式变了必须重载一次 URL') + assert.doesNotMatch(after.win.webContents.getURL(), /windowed=1/) +}) + +test('回归反向:kiosk 窗口被排布改成窗口化时,URL 补上 windowed=1', (t) => { + const manager = makeManager(t) + fakeScreen.displays = [fakeDisplay(1, { internal: true }), fakeDisplay(2)] + manager.enable() + + const before = assertModeConsistent(manager, 1, false) + const loadsBefore = before.win.webContents.loadCount + + // 拔掉扩展屏,只剩主屏 → 退化成窗口化 + fakeScreen.displays = [fakeDisplay(1, { internal: true })] + manager.layout() + + const after = assertModeConsistent(manager, 1, true) + assert.equal(after.win, before.win, '同一个窗口应当就地改模式,而不是重建') + assert.equal(after.win.webContents.loadCount, loadsBefore + 1, '模式变了必须重载一次 URL') + assert.match(after.win.webContents.getURL(), /windowed=1/) +}) + +test('管理面板手动开的窗口保持自己的模式,不被自动排布改成 kiosk', (t) => { + const manager = makeManager(t) + const primary = fakeDisplay(1, { internal: true }) + fakeScreen.displays = [primary] + + const opened = manager.openOn(primary, { page: 'standings', single: true }) + assert.deepEqual(opened, { created: true, mode: 'windowed' }) + assertModeConsistent(manager, 1, true) + + // 之后整机变成多屏,自动排布也不许把这个手动开的小窗顶成 kiosk + fakeScreen.displays = [primary, fakeDisplay(2)] + manager.layout() + + assertModeConsistent(manager, 1, true) + assertModeConsistent(manager, 2, false) +}) + +test('模式上报以 entry 为准:窗口化窗口被 F11 全屏后仍报 windowed', (t) => { + const manager = makeManager(t) + const primary = fakeDisplay(1, { internal: true }) + fakeScreen.displays = [primary] + + manager.openOn(primary, { single: true }) + const win = manager.windows.get(1).win + win.setFullScreen(true) + + // 只聚焦不重建时,模式必须还说窗口化:拿 isFullScreen() 反推会误报成 kiosk + assert.deepEqual(manager.openOn(primary, { single: true }), { created: false, mode: 'windowed' }) + assert.equal(manager.listDisplays().displays.find((item) => item.id === 1).mode, 'windowed') + assert.equal(manager.modeOf(manager.windows.get(1)), 'windowed') + + // kiosk 窗口不受 F11 影响,仍报 kiosk + fakeScreen.displays = [primary, fakeDisplay(2)] + manager.layout() + assert.equal(manager.listDisplays().displays.find((item) => item.id === 2).mode, 'kiosk') +}) + +test('F11 下发的是算出来的确定值,不回读窗口', (t) => { + const manager = makeManager(t) + fakeScreen.displays = [fakeDisplay(1, { internal: true })] + manager.enable() + + const win = manager.windows.get(1).win + const [handler] = win.webContents.listeners('before-input-event') + assert.ok(handler, '窗口化窗口必须挂 F11 处理') + + let prevented = 0 + const pressF11 = () => handler({ preventDefault: () => { prevented += 1 } }, { type: 'keyDown', key: 'F11' }) + + win.webContents.sent.length = 0 + pressF11() + assert.equal(prevented, 1) + assert.equal(win.isFullScreen(), true) + assert.deepEqual(win.webContents.sent, [['window:fullscreen-changed', true]]) + + win.webContents.sent.length = 0 + pressF11() + assert.equal(win.isFullScreen(), false) + assert.deepEqual(win.webContents.sent, [['window:fullscreen-changed', false]]) + + // 切换瞬间回读可能拿到旧值:enter/leave 必须下发事件本身的确定值 + win.webContents.sent.length = 0 + win.staleFullScreen = false + win.emit('enter-full-screen') + assert.deepEqual(win.webContents.sent, [['window:fullscreen-changed', true]]) + + win.webContents.sent.length = 0 + win.staleFullScreen = true + win.emit('leave-full-screen') + assert.deepEqual(win.webContents.sent, [['window:fullscreen-changed', false]]) + win.staleFullScreen = undefined + + // 加载完成那次窗口状态已经稳定,继续用「读一次」 + win.webContents.sent.length = 0 + win.fullScreen = true + win.webContents.emit('did-finish-load') + assert.deepEqual(win.webContents.sent, [['window:fullscreen-changed', true]]) +}) + +test('kiosk 窗口不挂 F11 处理', (t) => { + const manager = makeManager(t) + fakeScreen.displays = [fakeDisplay(1, { internal: true }), fakeDisplay(2)] + + manager.enable() + + assert.equal(manager.windows.get(2).win.webContents.listeners('before-input-event').length, 0) +})