This commit is contained in:
2026-10-17 20:57:16 +07:00
parent 40456eebf1
commit 1d4028b183
13 changed files with 849 additions and 48 deletions
+29 -5
View File
@@ -21,8 +21,10 @@ macOS Intel/Apple Silicon, Windows x64/ARM64 и Linux x64/ARM64.
общего правила хранения задач в `../docs/agent`. Не дублировать их там. общего правила хранения задач в `../docs/agent`. Не дублировать их там.
Идентификаторы `SCREEN-*` относятся только к этому приложению. Идентификаторы `SCREEN-*` относятся только к этому приложению.
Следующий этап: SCREEN-007 — устойчивость на площадке; аппаратная проверка Программная часть SCREEN-007 реализована: обработка сбоев показа, пауза таймера,
SCREEN-001/SCREEN-004 остаётся открытой. предотвращение автоматического сна и восстановление пульта. SCREEN-007 вместе
с SCREEN-001/SCREEN-004 остаётся IN_PROGRESS до аппаратной приёмки.
[Инструкция проверки устойчивости](docs/SCREEN-007_ACCEPTANCE.md).
Новые задачи: `Новая задача: <описание>`. Новые задачи: `Новая задача: <описание>`.
Commit и push выполняет только пользователь. Вложенные `.git` не изменять. Commit и push выполняет только пользователь. Вложенные `.git` не изменять.
@@ -173,7 +175,7 @@ URL, независимо от исходного имени файла. Лок
`selectPackage(null)` включает тестовый режим. Выбор повторно проверяет пакет. `selectPackage(null)` включает тестовый режим. Выбор повторно проверяет пакет.
Закрытие или пересоздание окна и смена пакета создают новую сессию и останавливают Закрытие или пересоздание окна и смена пакета создают новую сессию и останавливают
каналы; повторное открытие не возобновляет звук. После перезапуска всего приложения каналы; повторное открытие не возобновляет звук. После перезапуска всего приложения
выбор начинается с тестового режима; сохранение выбора относится к SCREEN-007. готовый выбранный пакет восстанавливается механизмом SCREEN-006 без автопуска.
Для ручной проверки с настоящим внешним дисплеем выполните `npm run acceptance`. Для ручной проверки с настоящим внешним дисплеем выполните `npm run acceptance`.
Команда использует синтетические fixtures и отдельный временный userData, создаёт Команда использует синтетические fixtures и отдельный временный userData, создаёт
@@ -204,8 +206,10 @@ URL, независимо от исходного имени файла. Лок
Таймер вычисляется в основном процессе по монотонному deadline; интервалы только Таймер вычисляется в основном процессе по монотонному deadline; интервалы только
обновляют снимок. Пауза сохраняет точный остаток, сброс возвращает полную длительность. обновляют снимок. Пауза сохраняет точный остаток, сброс возвращает полную длительность.
Таймер продолжает идти за другой сценой и при закрытом окне; смена пакета сбрасывает Таймер продолжает идти за другой сценой и при закрытом окне; смена пакета сбрасывает
его. По окончании остаётся 00:00, без сигнала и смены режима. Восстановление после его. По окончании остаётся 00:00, без сигнала и смены режима. С SCREEN-007 аварийное
сна/перезапуска относится к SCREEN-007. закрытие (дисплей, сон ОС, fullscreen или renderer) ставит работающий таймер на
паузу с точным остатком. После пробуждения требуется вручную открыть показ и
продолжить отсчёт; перезапуск приложения не возобновляет таймер.
Обложка и фон читаются через локальный протокол. Новая подготовка использует Обложка и фон читаются через локальный протокол. Новая подготовка использует
адрес с идентификатором сцены, исключая устаревший декодированный кеш изображения. адрес с идентификатором сцены, исключая устаревший декодированный кеш изображения.
@@ -235,3 +239,23 @@ capturedAt, вопросы и публичные строки таблицы (ID
изображения, отсутствие второго звука, предел частоты кадров предпросмотра, изображения, отсутствие второго звука, предел частоты кадров предпросмотра,
кириллицу и отрисовку/прокрутку 50 синтетических строк результатов. Эти строки кириллицу и отрисовку/прокрутку 50 синтетических строк результатов. Эти строки
подаются только в тестовый renderer и не сохраняются в состоянии приложения. подаются только в тестовый renderer и не сохраняются в состоянии приложения.
## Устойчивость SCREEN-007
Отключение выбранного дисплея, превращение его в основной, смена разрешения,
масштаба/ориентации, выход из fullscreen, сон ОС, падение или зависание renderer
закрывают показ и останавливают оба медиаканала. Причина видна в пульте; возврат
устройства не запускает окно и звук. Для повторного вывода нажмите «Открыть показ».
Пульт и показ используют разные сессии Electron; падение renderer показа
обрабатывается независимо от пульта. Выбор аудиовыхода остаётся в настройках ОС.
При открытом показе удерживается prevent-display-sleep, включая статичные сцены
и паузу. Эта блокировка не отменяет принудительный сон или закрытие крышки.
Пульт сохраняет bounds/maximized в userData/windows.json и восстанавливается
в пределах основного экрана; внешний дисплей предвыбирается только по совпадающим
ID и названию. Ошибка файла настроек не удаляет материалы или секреты.
GUI smoke на стенде без внешнего дисплея моделирует topology/fullscreen/питание;
native fullscreen проверяется при наличии реального второго экрана. Реальное
предотвращение сна, маршрутизацию звука ОС и физическое переподключение оборудования
проверять по [инструкции SCREEN-007](docs/SCREEN-007_ACCEPTANCE.md) на всей матрице.
+39 -4
View File
@@ -1,6 +1,6 @@
# Задачи приложения площадки # Задачи приложения площадки
SCREEN-001 и SCREEN-004 имеют статус IN_PROGRESS; SCREEN-002, SCREEN-003, SCREEN-005 и SCREEN-006 SCREEN-001, SCREEN-004 и SCREEN-007 имеют статус IN_PROGRESS; SCREEN-002, SCREEN-003, SCREEN-005 и SCREEN-006
завершены и перенесены в архив; остальные активные задачи — TODO. завершены и перенесены в архив; остальные активные задачи — TODO.
SCREEN-009 (LAN-пульт) отменена владельцем; ID не переиспользовать. SCREEN-009 (LAN-пульт) отменена владельцем; ID не переиспользовать.
Управление телефоном через сервер входит в SCREEN-006. План и общие ограничения: Управление телефоном через сервер входит в SCREEN-006. План и общие ограничения:
@@ -111,7 +111,7 @@ SCREEN-009 (LAN-пульт) отменена владельцем; ID не пе
## SCREEN-007 — Устойчивость на площадке ## SCREEN-007 — Устойчивость на площадке
- Статус: `TODO` - Статус: `IN_PROGRESS`
- Зависимости: SCREEN-005, SCREEN-006 (DONE, см. COMPLETED_TASKS.md). - Зависимости: SCREEN-005, SCREEN-006 (DONE, см. COMPLETED_TASKS.md).
- Цель: переживать типовые сбои без потери материалов и случайного показа пульта. - Цель: переживать типовые сбои без потери материалов и случайного показа пульта.
- Объём: отключение/возврат дисплея, смена разрешения и fullscreen, проверка - Объём: отключение/возврат дисплея, смена разрешения и fullscreen, проверка
@@ -124,8 +124,43 @@ SCREEN-009 (LAN-пульт) отменена владельцем; ID не пе
переходником и звуком по кабелю/USB и HDMI; тесты восстановления состояния; переходником и звуком по кабелю/USB и HDMI; тесты восстановления состояния;
проверки приложения. Результаты аппаратных проверок передаёт владелец. проверки приложения. Результаты аппаратных проверок передаёт владелец.
- Не входит: бесшовный аварийный резервный компьютер и несколько проекторов. - Не входит: бесшовный аварийный резервный компьютер и несколько проекторов.
- Следующий шаг: проверить события Electron display/window и определить - Программный результат (2026-10-17): общий аварийный обработчик отключения/
минимальную безопасную реакцию на отключение внешнего экрана. превращения дисплея в основной, изменения bounds/scaleFactor/rotation, выхода
из fullscreen, suspend ОС, падения/зависания renderer и ошибки основного документа.
Медиа останавливаются, работающий таймер сохраняет точный остаток на паузе;
возврат дисплея/пробуждение не открывают показ. Обычная кнопка закрытия сохраняет
прежнюю политику таймера. Изменения только workArea не прерывают показ.
- Окна и питание: prevent-display-sleep удерживается всё время открытого показа,
включая статичные сцены/паузу, освобождается при закрытии, сбое и before-quit.
Native-закрытие завершается до создания нового окна; уничтожение отложено за
пределы callback native-события, поздние события старого окна отклоняются.
Пульт восстанавливает проверенные bounds/maximized в рабочей области основного
дисплея; ID/label внешнего дисплея сохраняются отдельно в windows.json. Выбор
предзаполняется только при точном совпадении и никогда не открывает показ сам.
Выбранная игра восстанавливается существующим механизмом SCREEN-006.
- Изоляция: показ использует отдельную непостоянную Electron session/renderer;
обе сессии получают одинаковый локальный протокол, CSP и запреты разрешений.
IPC-привилегии пульта не расширены. В UI добавлены причина закрытия, состояние
блокировки сна и инструкция проверки системного аудиовыхода/HDMI.
- Проверки: type-check, ESLint, Stylelint, 68 тестов (5 новых), сборка и GUI smoke —
PASS. GUI проверяет удаление/возврат дисплея, bounds/scale/rotation, смену primary,
suspend/resume, fullscreen-события, ошибку документа и зависание окна, сохранение
настроек, отсутствие автопуска и старой серверной команды после аварии.
Renderer действительно завершён SIGKILL после проверки отдельного PID; пульт
сохранил работоспособность, показ открылся заново остановленным. Все 16 созданных
блокировок освобождены ровно один раз, включая before-quit. git diff --check — PASS.
- Ограничения стенда: Linux x64, один физический дисплей. В GUI synthetic=true:
topology/питание и fullscreen-события подставлены, медиа и окна Electron настоящие.
Native fullscreen используется только с реальным внешним дисплеем; иначе WM
сам снимал fullscreen синтетического экрана. В раннем прогоне было native-падение
Electron; исправлен порядок закрытия/повторного открытия. forcefullyCrashRenderer
зависал до таймаута, поэтому окончательный тест завершает renderer через SIGKILL.
Использованы тестовые no-sandbox/mute-audio/disable-gpu. Реальное предотвращение
сна, проектор/аудиовыходы и вся матрица ОС/архитектур пока не проверены.
Бэкенд, frontend, protobuf и зависимости не менялись; архивы не пересобирались.
- Следующий шаг: выполнить docs/SCREEN-007_ACCEPTANCE.md на всей матрице с реальным
проектором, переходником, USB/HDMI и передать аппаратные результаты. До этого
оставить IN_PROGRESS, не переносить в COMPLETED_TASKS.md. Commit/push не выполнять.
## SCREEN-008 — Сборки всех платформ и приёмка ## SCREEN-008 — Сборки всех платформ и приёмка
+91
View File
@@ -0,0 +1,91 @@
# SCREEN-007 — Аппаратная проверка устойчивости
Статус задачи остаётся IN_PROGRESS, пока нет результатов всей аппаратной матрицы.
Автоматический smoke подставляет события дисплея/питания и при синтетическом
мониторе также fullscreen. Native fullscreen остаётся для реального внешнего
дисплея. Renderer действительно завершается SIGKILL в отдельном процессе:
он проверяет обработчики, но не физические кабели, звук и предотвращение сна ОС.
## Стенд
Проверить macOS 13+ Intel/Apple Silicon, Windows 11 x64/ARM64, Linux x64/ARM64
на Ubuntu 24.04 LTS и Debian 13. На Linux под Wayland требуется XWayland.
Использовать реальный внешний дисплей в режиме расширенного рабочего стола,
проектные файлы WAV/MP3/OGG и H.264/AAC, проверяемые переходники и аудиоустройства.
1. Выполнить `npm ci`, `npm run media:generate`, `npm run dev` в каталоге screen.
2. Подготовить и выбрать игру по SCREEN-006_ACCEPTANCE.md; затем отключить сеть
для локальных проверок. Для синтетического WAV/видео доступен тестовый режим.
3. Пульт разместить на основном дисплее компьютера, показ открыть на внешнем.
Проверить сообщение «Автоматический сон заблокирован на время показа».
4. Перед каждым аварийным сценарием запустить медиа и таймер на несколько минут.
Для одновременной проверки обоих каналов включить музыку и вступление/видео.
Не использовать тестовый --mute-audio. Записать остальные флаги запуска.
## Дисплей и окно показа
- Отключить кабель проектора. Окно показа должно закрыться, оба канала остановиться,
работающий таймер — остаться на паузе с сохранённым остатком. В пульте видна
причина, блокировка сна выключена, сам пульт доступен на основном экране.
- Вернуть кабель. Показ не открывается и звук не запускается. Открыть показ
вручную: медиа остановлены, таймер на паузе. Отдельно продолжить таймер и медиа.
- Повторить со сменой разрешения, масштаба и ориентации выбранного внешнего
дисплея. Показ закрывается по той же политике; продолжение только вручную.
- Сделать выбранный дисплей основным средствами ОС: показ закрывается,
пульт остаётся доступен в рабочей области нового основного дисплея. Вернуть
основной дисплей компьютера и заново выбрать внешний.
- Выйти из fullscreen средствами ОС/Esc, если ОС это поддерживает. Показ
закрывается с аварийной паузой таймера. Простая потеря фокуса при работе
с пультом не должна закрывать показ.
- Нажать обычную кнопку «Закрыть показ»: медиа останавливаются, но уже работающий
таймер продолжает отсчёт — это прежняя политика явного закрытия.
- Если окно показа зависло, потеряло основной документ или его renderer упал,
оно закрывается, таймер ставится на паузу. Пульт должен оставаться доступен;
повторный показ открывается вручную. Программный smoke проверяет эти события,
включая настоящее принудительное завершение renderer; вызывать сбой драйвера
или ОС вручную не требуется.
## Сон и звук
- Временно задать короткое время автоматического выключения дисплея/сна в ОС.
Проверить, что при открытом показе компьютер и дисплей остаются активны
дольше этого интервала: во время воспроизведения, на паузе и на статичной обложке.
После закрытия показа/приложения прежний автоматический сон должен работать.
Вернуть исходные настройки питания после проверки.
- Принудительно отправить компьютер в сон, затем разбудить. Блокировка приложения
не отменяет принудительный сон и закрытие крышки. После пробуждения пульт
доступен, показ закрыт, звук сам не начинается, работающий таймер на паузе.
- Перед игрой выбрать выход ОС: macOS — настройки звука, «Выход»; Windows —
«Система → Звук»; Linux — настройки звука рабочего окружения. Проверить громкость,
mute и пробный пуск. Повторить с кабельным/USB-выходом и HDMI.
- После подключения/возврата HDMI проверить выбранный выход повторно: ОС может
автоматически переключить звук на проектор. Приложение не выбирает устройство
и не маршрутизирует звук на несколько выходов.
## Восстановление и сервер
1. Изменить размер/положение пульта, развернуть его, выбрать внешний дисплей
и игру. Закрыть приложение и снова запустить `npm run dev` с тем же профилем ОС.
Пульт восстанавливает размер/развёрнутое состояние в рабочей области основного
дисплея. Готовая игра доступна офлайн, показ закрыт, звук и таймер не запущены.
2. Повторить перезапуск с отключённым проектором и уменьшенным разрешением
основного дисплея. Пульт должен полностью помещаться в доступную область.
Внешний дисплей предвыбирается только при совпадении сохранённых ID и названия;
другой экран требуется выбрать вручную. Автоматического открытия нет.
3. Вернуть сеть и подключить управление игрой. После аварийного закрытия
отправить телефоном команду PLAY, затем восстановить и вручную открыть показ.
Накопленная команда не запускает звук. Следующая новая команда применяется.
4. Закрыть приложение во время показа. Звук прекращается, блокировка сна
освобождается, материалы остаются на диске. При повторном запуске нет автопуска.
Выбранная игра сохраняется механизмом SCREEN-006, настройки окон — в отдельном
`windows.json` внутри userData. Повреждённые настройки окон заменяются безопасным
расположением и сообщением в пульте; пакеты и секреты при этом не удаляются.
## Отчёт
Для каждого стенда записать ОС/архитектуру, CPU/RAM, версию приложения/Electron,
режим рабочего стола (для Linux — X11 или XWayland), дисплей, кабель/переходник,
аудиовыход, форматы, результаты каждого пункта и наблюдаемые сбои. Не передавать
пароли, токены и файлы секретов. Аппаратные задержки пуска относятся к
SCREEN-001/SCREEN-004; эта проверка не подтверждает p95 ≤150 мс на M1.
+9 -1
View File
@@ -208,7 +208,15 @@ export class Controller {
position: 0, position: 0,
}) })
} }
output(open: boolean) { output(open: boolean, pauseTimer = false) {
if (!open && pauseTimer && this.state.timer.status === 'running') {
this.updateTimer()
if (this.state.timer.status === 'running') {
this.timerDeadline = null
this.state.timer.status = 'paused'
this.state.timer.revision++
}
}
this.newSession() this.newSession()
this.state.outputOpen = open this.state.outputOpen = open
this.state.scene.status = open ? 'pending' : 'idle' this.state.scene.status = open ? 'pending' : 'idle'
+223 -34
View File
@@ -1,9 +1,20 @@
import { app, BrowserWindow, ipcMain, protocol, screen, session, safeStorage } from 'electron' import {
app,
BrowserWindow,
ipcMain,
protocol,
screen,
session,
safeStorage,
powerMonitor,
powerSaveBlocker,
} from 'electron'
import { createReadStream } from 'node:fs' import { createReadStream } from 'node:fs'
import { stat } from 'node:fs/promises' import { stat } from 'node:fs/promises'
import { join } from 'node:path' import { join } from 'node:path'
import { Readable } from 'node:stream' import { Readable } from 'node:stream'
import { Controller, parseCommand } from './controller.js' import { Controller, parseCommand } from './controller.js'
import { WindowSettings, fitControlBounds, preferredDisplayId } from './window-settings.js'
import { parseRange } from './range.js' import { parseRange } from './range.js'
import { Online } from './online.js' import { Online } from './online.js'
import type { OnlineState } from '../src/types/online.js' import type { OnlineState } from '../src/types/online.js'
@@ -22,8 +33,17 @@ protocol.registerSchemesAsPrivileged([
]) ])
let control: BrowserWindow | null = null let control: BrowserWindow | null = null
let output: BrowserWindow | null = null let output: BrowserWindow | null = null
let closingWindow: BrowserWindow | null = null
let openingOutput = false
let outputEpoch = 0
let quitting = false
let selectedDisplay: number | null = null let selectedDisplay: number | null = null
let online: Online | null = null let online: Online | null = null
let settings: WindowSettings
let sleepBlocker: number | null = null
let hostNotice = ''
let suspended = false
let saveTimer: ReturnType<typeof setTimeout> | undefined
const controller = new Controller((state) => { const controller = new Controller((state) => {
for (const window of [control, output]) for (const window of [control, output])
if (window && !window.isDestroyed()) window.webContents.send('state', state) if (window && !window.isDestroyed()) window.webContents.send('state', state)
@@ -32,6 +52,13 @@ const rendererRoot = () => join(app.getAppPath(), 'dist')
function host(): HostInfo { function host(): HostInfo {
const primary = screen.getPrimaryDisplay().id const primary = screen.getPrimaryDisplay().id
return { return {
notice: hostNotice,
sleepBlocked: sleepBlocker !== null && powerSaveBlocker.isStarted(sleepBlocker),
preferredDisplayId: preferredDisplayId(
settings?.value.display ?? null,
screen.getAllDisplays(),
primary,
),
displays: screen displays: screen
.getAllDisplays() .getAllDisplays()
.filter((d) => d.id !== primary) .filter((d) => d.id !== primary)
@@ -41,10 +68,11 @@ function host(): HostInfo {
})), })),
} }
} }
function windowOptions() { function windowOptions(isOutput = false) {
return { return {
backgroundColor: '#111111', backgroundColor: '#111111',
webPreferences: { webPreferences: {
...(isOutput ? { partition: 'screen-output' } : {}),
preload: join(__dirname, 'preload.js'), preload: join(__dirname, 'preload.js'),
nodeIntegration: false, nodeIntegration: false,
contextIsolation: true, contextIsolation: true,
@@ -64,31 +92,144 @@ function requireControl(event: Electron.IpcMainInvokeEvent) {
) )
throw new Error('Недоступно') throw new Error('Недоступно')
} }
function show(displayId: number) { function releaseSleepBlocker() {
if (online?.blocksOutput) throw new Error('Подождите завершения подготовки материалов') if (sleepBlocker === null) return
const display = screen.getAllDisplays().find((d) => d.id === displayId) powerSaveBlocker.stop(sleepBlocker)
if (!display || display.id === screen.getPrimaryDisplay().id) sleepBlocker = null
throw new Error('Выберите внешний дисплей; включите расширенный рабочий стол') }
if (output) output.destroy() function closeOutput(reason = '', destroy = true) {
selectedDisplay = display.id if (reason) outputEpoch++
output = new BrowserWindow({ ...windowOptions(), ...display.bounds, show: false })
secure(output)
controller.output(true)
const window = output const window = output
output.on('closed', () => { if (!window && (!reason || controller.state.timer.status !== 'running')) return
if (output !== window) return output = null
output = null if (window) closingWindow = window
selectedDisplay = null selectedDisplay = null
controller.output(false) if (reason)
}) hostNotice = `${reason}. Откройте показ заново; медиа остановлены, работающий таймер поставлен на паузу`
output.once('ready-to-show', () => { controller.output(false, !!reason)
if (!window.isDestroyed()) { releaseSleepBlocker()
window.show() if (destroy && window) {
window.setBounds(display.bounds) // Не уничтожать native-окно повторно внутри его fullscreen/close callback.
window.setFullScreen(true) setImmediate(() => {
if (!window.isDestroyed()) window.destroy()
})
}
}
function saveWindowSettings() {
clearTimeout(saveTimer)
if (!settings) return
if (control && !control.isDestroyed() && !control.isMinimized())
settings.value.control = { bounds: control.getNormalBounds(), maximized: control.isMaximized() }
try {
settings.save()
} catch {
hostNotice = 'Не удалось сохранить расположение окон; материалы игры не затронуты'
}
}
function keepControlOnPrimary() {
if (!control || control.isDestroyed()) return
const before = control.getNormalBounds()
const after = fitControlBounds(before, screen.getPrimaryDisplay().workArea)
if (JSON.stringify(before) === JSON.stringify(after)) return
const maximized = control.isMaximized()
if (maximized) control.unmaximize()
control.setBounds(after)
if (maximized) control.maximize()
}
function reconcileDisplays() {
if (
output &&
!screen
.getAllDisplays()
.some((d) => d.id === selectedDisplay && d.id !== screen.getPrimaryDisplay().id)
)
closeOutput('Выбранный дисплей отключён или стал основным')
keepControlOnPrimary()
}
async function show(displayId: number) {
if (openingOutput) throw new Error('Подождите открытия окна показа')
openingOutput = true
const epoch = outputEpoch
try {
if (suspended || quitting) throw new Error('Дождитесь пробуждения компьютера')
if (online?.blocksOutput) throw new Error('Подождите завершения подготовки материалов')
const display = screen.getAllDisplays().find((d) => d.id === displayId)
if (!display || display.id === screen.getPrimaryDisplay().id)
throw new Error('Выберите внешний дисплей; включите расширенный рабочий стол')
const geometry = JSON.stringify([display.bounds, display.scaleFactor, display.rotation])
closeOutput()
const closing = closingWindow
if (closing && !closing.isDestroyed())
await new Promise<void>((resolve) => closing.once('closed', () => setImmediate(resolve)))
if (online?.blocksOutput) throw new Error('Подождите завершения подготовки материалов')
const current = screen.getAllDisplays().find((d) => d.id === displayId)
if (
epoch !== outputEpoch ||
suspended ||
quitting ||
!current ||
current.id === screen.getPrimaryDisplay().id ||
geometry !== JSON.stringify([current.bounds, current.scaleFactor, current.rotation])
)
throw new Error('Открытие отменено: изменилось состояние компьютера или дисплея')
hostNotice = ''
const window = new BrowserWindow({ ...windowOptions(true), ...display.bounds, show: false })
output = window
selectedDisplay = display.id
settings.value.display = { id: display.id, label: display.label }
saveWindowSettings()
secure(window)
controller.output(true)
try {
sleepBlocker = powerSaveBlocker.start('prevent-display-sleep')
} catch {
hostNotice = 'Не удалось заблокировать автоматический сон. Проверьте настройки питания ОС'
} }
}) window.on('close', () => {
void output.loadURL('screen-app://app/index.html?output=1') if (output === window) closeOutput('', false)
})
window.on('closed', () => {
if (output === window) closeOutput('', false)
if (closingWindow === window) closingWindow = null
})
window.on('unresponsive', () => {
if (output === window) closeOutput('Окно показа не отвечает')
})
window.webContents.on('render-process-gone', () => {
if (output === window) closeOutput('Процесс окна показа завершился')
})
window.webContents.on('did-fail-load', (_event, _code, _description, _url, isMainFrame) => {
if (isMainFrame && output === window) closeOutput('Не удалось загрузить окно показа')
})
let enteredFullscreen = false
window.on('enter-full-screen', () => {
enteredFullscreen = true
})
window.on('leave-full-screen', () => {
if (enteredFullscreen && output === window)
closeOutput('Окно показа вышло из полноэкранного режима')
})
window.once('ready-to-show', () => {
if (output !== window || window.isDestroyed()) return
const current = screen.getAllDisplays().find((d) => d.id === display.id)
if (
!current ||
current.id === screen.getPrimaryDisplay().id ||
geometry !== JSON.stringify([current.bounds, current.scaleFactor, current.rotation])
) {
closeOutput('Параметры дисплея изменились при открытии показа')
return
}
window.show()
window.setBounds(current.bounds)
window.setFullScreen(true)
})
void window.loadURL('screen-app://app/index.html?output=1').catch(() => {
if (output === window) closeOutput('Не удалось загрузить окно показа')
})
} finally {
openingOutput = false
}
} }
// Две копии приложения не должны одновременно менять указатели пакетов. // Две копии приложения не должны одновременно менять указатели пакетов.
if (!app.requestSingleInstanceLock()) app.exit(0) if (!app.requestSingleInstanceLock()) app.exit(0)
@@ -100,6 +241,12 @@ app.on('second-instance', () => {
app app
.whenReady() .whenReady()
.then(async () => { .then(async () => {
settings = new WindowSettings(join(app.getPath('userData'), 'windows.json'))
try {
await settings.load()
} catch (error) {
hostNotice = error instanceof Error ? error.message : 'Не удалось восстановить окна'
}
const packages = new PackageStore(join(app.getPath('userData'), 'packages')) const packages = new PackageStore(join(app.getPath('userData'), 'packages'))
await packages.initialize() await packages.initialize()
online = new Online(join(app.getPath('userData'), 'connection'), packages, controller, { online = new Online(join(app.getPath('userData'), 'connection'), packages, controller, {
@@ -116,16 +263,16 @@ app
}, 1000) }, 1000)
const timerTick = setInterval(() => controller.tick(), 250) const timerTick = setInterval(() => controller.tick(), 250)
app.on('before-quit', () => { app.on('before-quit', () => {
quitting = true
saveWindowSettings()
closeOutput()
releaseSleepBlocker()
clearInterval(timerTick) clearInterval(timerTick)
clearInterval(onlineTick) clearInterval(onlineTick)
online?.close() online?.close()
packages.cancel() packages.cancel()
}) })
session.defaultSession.setPermissionRequestHandler((_contents, _permission, callback) => const handleAppRequest: Parameters<typeof protocol.handle>[1] = async (request) => {
callback(false),
)
session.defaultSession.setPermissionCheckHandler(() => false)
protocol.handle('screen-app', async (request) => {
const url = new URL(request.url) const url = new URL(request.url)
const headers = { const headers = {
'Content-Security-Policy': 'Content-Security-Policy':
@@ -189,8 +336,32 @@ app
} catch { } catch {
return new Response(null, { status: 404 }) return new Response(null, { status: 404 })
} }
}
// Разные сессии изолируют renderer показа от пульта, протокол и запреты одинаковы.
for (const target of [session.defaultSession, session.fromPartition('screen-output')]) {
target.setPermissionRequestHandler((_contents, _permission, callback) => callback(false))
target.setPermissionCheckHandler(() => false)
target.protocol.handle('screen-app', handleAppRequest)
}
control = new BrowserWindow({
...windowOptions(),
...fitControlBounds(settings.value.control?.bounds, screen.getPrimaryDisplay().workArea),
show: false,
}) })
control = new BrowserWindow({ ...windowOptions(), width: 1000, height: 800 }) control.once('ready-to-show', () => {
keepControlOnPrimary()
if (settings.value.control?.maximized) control!.maximize()
control!.show()
})
const scheduleSave = () => {
clearTimeout(saveTimer)
saveTimer = setTimeout(saveWindowSettings, 250)
}
control.on('move', scheduleSave)
control.on('resize', scheduleSave)
control.on('maximize', scheduleSave)
control.on('unmaximize', scheduleSave)
control.on('close', saveWindowSettings)
secure(control) secure(control)
ipcMain.handle('state', (event) => { ipcMain.handle('state', (event) => {
if ( if (
@@ -211,7 +382,7 @@ app
}) })
ipcMain.handle('close-output', (event) => { ipcMain.handle('close-output', (event) => {
requireControl(event) requireControl(event)
output?.close() closeOutput()
}) })
ipcMain.handle('host', (event) => { ipcMain.handle('host', (event) => {
requireControl(event) requireControl(event)
@@ -221,7 +392,7 @@ app
requireControl(event) requireControl(event)
if (selectingPackage) throw new Error('Подождите проверки пакета') if (selectingPackage) throw new Error('Подождите проверки пакета')
if (typeof id !== 'number') throw new Error('Некорректный дисплей') if (typeof id !== 'number') throw new Error('Некорректный дисплей')
show(id) return show(id)
}) })
let selectingPackage = false let selectingPackage = false
ipcMain.handle('packages', (event) => { ipcMain.handle('packages', (event) => {
@@ -330,7 +501,25 @@ app
} }
}) })
screen.on('display-removed', (_event, display) => { screen.on('display-removed', (_event, display) => {
if (display.id === selectedDisplay) output?.close() if (display.id === selectedDisplay) closeOutput('Дисплей показа отключён')
reconcileDisplays()
})
screen.on('display-added', reconcileDisplays)
screen.on('display-metrics-changed', (_event, display, metrics) => {
if (
display.id === selectedDisplay &&
metrics.some((m) => ['bounds', 'scaleFactor', 'rotation'].includes(m))
)
closeOutput('Изменились разрешение, масштаб или ориентация дисплея')
reconcileDisplays()
})
powerMonitor.on('suspend', () => {
suspended = true
closeOutput('Компьютер переходит в сон')
})
powerMonitor.on('resume', () => {
suspended = false
reconcileDisplays()
}) })
control.on('closed', () => { control.on('closed', () => {
app.quit() app.quit()
+28
View File
@@ -282,3 +282,31 @@ test('не принимает повреждённые снимки, повто
f.c.tick() f.c.tick()
assert.equal(f.c.state.timer.remainingMs, 299500) assert.equal(f.c.state.timer.remainingMs, 299500)
}) })
test('аварийное закрытие сохраняет точный остаток таймера на паузе, повторное открытие не запускает его', () => {
const f = fixture()
f.send({ action: 'timer-set', label: 'Перерыв', durationSeconds: 60 })
f.send({ action: 'timer-start' })
f.send({ action: 'play', channel: 'main' })
const oldSession = f.c.state.session
f.advance(1234)
f.c.output(false, true)
assert.equal(f.c.state.timer.status, 'paused')
assert.equal(f.c.state.timer.remainingMs, 58766)
assert.equal(f.c.state.channels.main.desired, 'stopped')
assert.equal(f.c.state.channels.music.desired, 'stopped')
assert.notEqual(f.c.state.session, oldSession)
f.advance(10000)
f.c.tick()
f.c.output(true)
assert.equal(f.c.state.timer.status, 'paused')
assert.equal(f.c.state.timer.remainingMs, 58766)
f.send({ action: 'timer-resume' })
f.advance(1000)
f.c.tick()
assert.equal(f.c.state.timer.remainingMs, 57766)
f.advance(60000)
f.c.output(false, true)
assert.equal(f.c.state.timer.status, 'finished')
assert.equal(f.c.state.timer.remainingMs, 0)
})
+80
View File
@@ -0,0 +1,80 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
WindowSettings,
fitControlBounds,
parseWindowPreferences,
preferredDisplayId,
} from './window-settings.js'
const primary = { x: -1920, y: -200, width: 1920, height: 1080 }
test('пульт возвращается с отключённого монитора и помещается в рабочую область основного', () => {
const bounds = fitControlBounds({ x: 5000, y: -5000, width: 3840, height: 2160 }, primary)
assert.deepEqual(bounds, primary)
assert.deepEqual(fitControlBounds(undefined, { x: 100, y: 50, width: 500, height: 300 }), {
x: 100,
y: 50,
width: 500,
height: 300,
})
const saved = { x: -1800, y: 0, width: 1000, height: 800 }
assert.deepEqual(fitControlBounds(saved, primary), saved)
const centered = fitControlBounds(undefined, primary)
assert.equal(centered.x, -1460)
assert.equal(centered.y, -60)
})
test('внешний дисплей предвыбирается только при совпадении ID и названия', () => {
const saved = { id: 2, label: 'Projector' }
const displays = [{ id: 1, label: 'Laptop' }, saved]
assert.equal(preferredDisplayId(saved, displays, 1), 2)
assert.equal(preferredDisplayId(saved, displays, 2), null)
assert.equal(preferredDisplayId(saved, [{ id: 2, label: 'Other' }], 1), null)
assert.equal(preferredDisplayId(saved, [{ id: 3, label: 'Projector' }], 1), null)
assert.equal(preferredDisplayId(saved, [], 1), null)
})
test('проверяет локальный файл настроек и не принимает повреждённую геометрию', () => {
const valid = {
version: 1,
control: { bounds: primary, maximized: true },
display: { id: -2, label: 'Экран' },
}
assert.equal(parseWindowPreferences(valid).control?.maximized, true)
for (const bad of [
null,
{},
{ ...valid, version: 2 },
{ ...valid, control: { bounds: { ...primary, width: 0 }, maximized: true } },
{ ...valid, control: { bounds: primary, maximized: 'true' } },
{ ...valid, display: { id: 2.5, label: 'Other' } },
])
assert.throws(() => parseWindowPreferences(bad))
})
test('последняя геометрия и выбор дисплея сохраняются атомарно, ошибочный файл не мешает запуску', async (t) => {
const dir = await mkdtemp(join(tmpdir(), 'screen-windows-'))
t.after(() => rm(dir, { recursive: true, force: true }))
const path = join(dir, 'windows.json')
const store = new WindowSettings(path)
await store.load()
assert.deepEqual(store.value, { control: null, display: null })
store.value = {
control: { bounds: primary, maximized: true },
display: { id: 2, label: 'Projector' },
}
store.save()
store.value.control!.bounds = { ...primary, width: 800 }
store.save()
const restored = new WindowSettings(path)
await restored.load()
assert.deepEqual(restored.value, store.value)
assert.equal(JSON.parse(await readFile(path, 'utf8')).version, 1)
await writeFile(path, '{bad')
const broken = new WindowSettings(path)
await assert.rejects(broken.load(), /повреждены/)
assert.deepEqual(broken.value, { control: null, display: null })
})
+100
View File
@@ -0,0 +1,100 @@
import { readFile } from 'node:fs/promises'
import { mkdirSync, renameSync, writeFileSync } from 'node:fs'
import { dirname } from 'node:path'
import type { Rectangle } from 'electron'
export interface WindowPreferences {
control: { bounds: Rectangle; maximized: boolean } | null
display: { id: number; label: string } | null
}
const defaults = (): WindowPreferences => ({ control: null, display: null })
function object(value: unknown): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value))
throw new Error('Некорректные настройки окон')
return value as Record<string, unknown>
}
function integer(value: unknown, min: number, max: number): number {
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < min || value > max)
throw new Error('Некорректная геометрия окна')
return value
}
export function parseWindowPreferences(value: unknown): WindowPreferences {
const data = object(value)
if (data.version !== 1) throw new Error('Неизвестная версия настроек окон')
const result = defaults()
if (data.control !== null) {
const c = object(data.control)
const b = object(c.bounds)
if (typeof c.maximized !== 'boolean') throw new Error('Некорректное состояние окна')
result.control = {
maximized: c.maximized,
bounds: {
x: integer(b.x, -10000000, 10000000),
y: integer(b.y, -10000000, 10000000),
width: integer(b.width, 1, 32768),
height: integer(b.height, 1, 32768),
},
}
}
if (data.display !== null) {
const d = object(data.display)
if (typeof d.label !== 'string' || d.label.length > 1024)
throw new Error('Некорректный дисплей')
result.display = {
id: integer(d.id, Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER),
label: d.label,
}
}
return result
}
export function fitControlBounds(saved: Rectangle | undefined, work: Rectangle): Rectangle {
const width = Math.min(work.width, Math.max(Math.min(640, work.width), saved?.width ?? 1000))
const height = Math.min(work.height, Math.max(Math.min(480, work.height), saved?.height ?? 800))
return {
x: Math.max(
work.x,
Math.min(
saved?.x ?? work.x + Math.floor((work.width - width) / 2),
work.x + work.width - width,
),
),
y: Math.max(
work.y,
Math.min(
saved?.y ?? work.y + Math.floor((work.height - height) / 2),
work.y + work.height - height,
),
),
width,
height,
}
}
export function preferredDisplayId(
preferred: WindowPreferences['display'],
displays: { id: number; label: string }[],
primary: number,
): number | null {
return (
displays.find((d) => d.id !== primary && d.id === preferred?.id && d.label === preferred.label)
?.id ?? null
)
}
export class WindowSettings {
value: WindowPreferences = defaults()
constructor(private path: string) {}
async load() {
try {
this.value = parseWindowPreferences(JSON.parse(await readFile(this.path, 'utf8')))
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT')
throw new Error('Настройки окон повреждены; использовано безопасное расположение')
}
}
save() {
const data = JSON.stringify({ version: 1, ...this.value })
mkdirSync(dirname(this.path), { recursive: true })
// Маленький файл пишется синхронно, чтобы последний resize не потерялся при выходе.
writeFileSync(`${this.path}.tmp`, data, { mode: 0o600 })
renameSync(`${this.path}.tmp`, this.path)
}
}
+15
View File
@@ -148,6 +148,21 @@ module.exports = async function onlineSmoke({
assert.equal((await state()).channels.main.desired, 'playing') assert.equal((await state()).channels.main.desired, 'playing')
version++ version++
await poll(async () => (await state()).channels.main.status === 'stopped', 'new remote stop') await poll(async () => (await state()).channels.main.status === 'stopped', 'new remote stop')
const { powerMonitor } = require('electron')
powerMonitor.emit('suspend')
assert.equal((await state()).outputOpen, false)
powerMonitor.emit('resume')
currentCommand = 'SCREEN_COMMAND_PLAY'
version++
const beforeReopenPolls = polls
await evaluate(`window.screenBridge.show(${displayId})`)
await poll(() => polls > beforeReopenPolls, 'post-incident baseline')
assert.equal((await state()).channels.main.desired, 'stopped')
version++
await poll(
async () => (await state()).channels.main.status === 'playing',
'new command after incident',
)
await act({ action: 'results' }) await act({ action: 'results' })
assert.equal((await state()).results.teams[0].name, 'Детективы') assert.equal((await state()).results.teams[0].name, 'Детективы')
assert.equal(JSON.stringify(await state()).includes('PRIVATE'), false) assert.equal(JSON.stringify(await state()).includes('PRIVATE'), false)
+37 -2
View File
@@ -1,5 +1,5 @@
const assert = require('node:assert/strict') const assert = require('node:assert/strict')
const { app, BrowserWindow, screen, nativeImage } = require('electron') const { app, BrowserWindow, screen, nativeImage, powerSaveBlocker } = require('electron')
const { join, resolve } = require('node:path') const { join, resolve } = require('node:path')
const { mkdtempSync, readFileSync, writeFileSync } = require('node:fs') const { mkdtempSync, readFileSync, writeFileSync } = require('node:fs')
const root = resolve(__dirname, '..') const root = resolve(__dirname, '..')
@@ -11,8 +11,26 @@ let targetDisplay
let isSyntheticDisplay = false let isSyntheticDisplay = false
const boundsBeforeFullscreen = new Map() const boundsBeforeFullscreen = new Map()
const fullscreenEntered = new Set() const fullscreenEntered = new Set()
const powerBlocks = { active: new Set(), starts: 0, stops: 0 }
const startBlocker = powerSaveBlocker.start.bind(powerSaveBlocker)
const stopBlocker = powerSaveBlocker.stop.bind(powerSaveBlocker)
powerSaveBlocker.start = (type) => {
assert.equal(type, 'prevent-display-sleep')
const id = startBlocker(type)
powerBlocks.active.add(id)
powerBlocks.starts++
return id
}
powerSaveBlocker.stop = (id) => {
assert.ok(powerBlocks.active.delete(id), 'blocker released exactly once')
powerBlocks.stops++
return stopBlocker(id)
}
const captures = [] const captures = []
app.on('browser-window-created', (_event, window) => { app.on('browser-window-created', (_event, window) => {
window.webContents.on('render-process-gone', (_event, details) =>
console.log('SMOKE_RENDERER_GONE', window.id, details.reason),
)
const capturePage = window.webContents.capturePage.bind(window.webContents) const capturePage = window.webContents.capturePage.bind(window.webContents)
window.webContents.capturePage = (...args) => { window.webContents.capturePage = (...args) => {
captures.push({ id: window.id, time: performance.now() }) captures.push({ id: window.id, time: performance.now() })
@@ -28,6 +46,13 @@ app.on('browser-window-created', (_event, window) => {
const setFullScreen = window.setFullScreen.bind(window) const setFullScreen = window.setFullScreen.bind(window)
window.setFullScreen = (flag) => { window.setFullScreen = (flag) => {
if (flag) boundsBeforeFullscreen.set(window.id, requestedBounds) if (flag) boundsBeforeFullscreen.set(window.id, requestedBounds)
// WM не знает синтетический монитор: native fullscreen проверяем только на реальном.
if (isSyntheticDisplay) {
queueMicrotask(() => {
if (!window.isDestroyed()) window.emit(flag ? 'enter-full-screen' : 'leave-full-screen')
})
return
}
return setFullScreen(flag) return setFullScreen(flag)
} }
}) })
@@ -562,6 +587,16 @@ app.whenReady().then(async () => {
displayId: targetDisplay.id, displayId: targetDisplay.id,
root, root,
}) })
await require('./venue-smoke.cjs')({
control,
evaluate,
state,
command,
poll,
targetDisplay,
offlinePackage,
powerBlocks,
})
console.log( console.log(
'SCREEN_SMOKE_PASS', 'SCREEN_SMOKE_PASS',
JSON.stringify({ JSON.stringify({
@@ -587,7 +622,7 @@ app.whenReady().then(async () => {
'SMOKE_DIAGNOSTIC', 'SMOKE_DIAGNOSTIC',
window.webContents.getURL(), window.webContents.getURL(),
await window.webContents.executeJavaScript( await window.webContents.executeJavaScript(
"(async () => ({ state: await window.screenBridge.state(), media: Array.from(document.querySelectorAll('audio,video')).map(m => ({channel:m.dataset.channel,src:m.src,paused:m.paused,ready:m.readyState,time:m.currentTime,duration:m.duration,error:m.error?.message})) }))()", "(async () => ({ state: await window.screenBridge.state(), host: await window.screenBridge.host().catch(() => null), media: Array.from(document.querySelectorAll('audio,video')).map(m => ({channel:m.dataset.channel,src:m.src,paused:m.paused,ready:m.readyState,time:m.currentTime,duration:m.duration,error:m.error?.message})) }))()",
), ),
) )
} catch {} } catch {}
+172
View File
@@ -0,0 +1,172 @@
const assert = require('node:assert/strict')
const { app, BrowserWindow, screen, powerMonitor } = require('electron')
const { readFileSync } = require('node:fs')
const { join } = require('node:path')
module.exports = async function venueSmoke({
control,
evaluate,
state,
command,
poll,
targetDisplay,
offlinePackage,
powerBlocks,
}) {
const getDisplays = screen.getAllDisplays.bind(screen)
const getPrimary = screen.getPrimaryDisplay.bind(screen)
let removed = false
screen.getAllDisplays = () => getDisplays().filter((d) => !removed || d.id !== targetDisplay.id)
const host = () => evaluate('window.screenBridge.host()')
const currentOutput = () => BrowserWindow.getAllWindows().find((w) => w !== control)
async function open() {
await evaluate(`window.screenBridge.show(${targetDisplay.id})`)
await poll(async () => {
const s = await state()
return s.outputOpen && s.scene.status === 'ready' && s.channels.main.status === 'stopped'
}, 'venue open prepared')
assert.equal((await host()).sleepBlocked, true)
assert.equal(powerBlocks.active.size, 1)
assert.equal((await state()).timer.status, 'paused')
return currentOutput()
}
async function start() {
await command({ action: 'timer-set', label: 'Аварийная проверка', durationSeconds: 120 })
await command({ action: 'timer-start' })
await command({ action: 'play', channel: 'main' })
await command({ action: 'play', channel: 'music' })
}
async function stopped(label) {
await poll(async () => !(await state()).outputOpen, label)
const s = await state()
assert.equal(s.timer.status, 'paused')
assert.ok(s.timer.remainingMs > 0 && s.timer.remainingMs <= 120000)
assert.equal(s.channels.main.desired, 'stopped')
assert.equal(s.channels.music.desired, 'stopped')
assert.equal((await host()).sleepBlocked, false)
assert.equal(powerBlocks.active.size, 0)
assert.ok((await host()).notice.length > 0)
assert.ok(!control.isDestroyed() && control.isVisible())
return s
}
try {
await evaluate('window.screenBridge.closeOutput()')
await evaluate(`window.screenBridge.selectPackage(${JSON.stringify(offlinePackage.key)})`)
// Первый пуск начинается с остановленного таймера; остальные — с аварийной паузы.
await evaluate(`window.screenBridge.show(${targetDisplay.id})`)
await poll(async () => (await state()).scene.status === 'ready', 'venue first open')
await start()
const old = currentOutput()
removed = true
screen.emit('display-removed', {}, targetDisplay)
const closed = await stopped('display removed')
screen.emit('display-removed', {}, targetDisplay)
assert.equal((await state()).session, closed.session)
removed = false
screen.emit('display-added', {}, targetDisplay)
assert.equal((await state()).outputOpen, false)
old.emit('ready-to-show')
old.emit('leave-full-screen')
assert.equal((await state()).outputOpen, false)
const remaining = closed.timer.remainingMs
let output = await open()
assert.equal((await state()).timer.remainingMs, remaining)
// Запоздавшие события старого окна не должны закрывать новое.
old.emit('closed')
assert.equal((await state()).outputOpen, true)
await start()
screen.emit('display-metrics-changed', {}, targetDisplay, ['workArea'])
assert.equal((await state()).outputOpen, true)
screen.emit('display-metrics-changed', {}, targetDisplay, ['bounds'])
await stopped('resolution changed')
for (const metric of ['scaleFactor', 'rotation']) {
await open()
await start()
screen.emit('display-metrics-changed', {}, targetDisplay, [metric])
await stopped(metric)
}
await open()
await start()
screen.getPrimaryDisplay = () => ({ ...targetDisplay, workArea: targetDisplay.bounds })
screen.emit('display-metrics-changed', {}, targetDisplay, ['workArea'])
await stopped('projector became primary')
assert.equal((await host()).preferredDisplayId, null)
screen.getPrimaryDisplay = getPrimary
screen.emit('display-added', {}, getPrimary())
const bounds = control.getNormalBounds()
const work = getPrimary().workArea
assert.ok(bounds.x >= work.x && bounds.y >= work.y)
assert.ok(bounds.x + bounds.width <= work.x + work.width)
assert.ok(bounds.y + bounds.height <= work.y + work.height)
output = await open()
await start()
powerMonitor.emit('suspend')
await stopped('system suspend')
await assert.rejects(evaluate(`window.screenBridge.show(${targetDisplay.id})`), /пробуждения/)
powerMonitor.emit('resume')
assert.equal((await state()).outputOpen, false)
output = await open()
await start()
// События fullscreen синтетические; физический переход проверяется отдельно.
output.emit('enter-full-screen')
output.emit('leave-full-screen')
await stopped('fullscreen exited')
output = await open()
await start()
output.webContents.emit('did-fail-load', {}, -2, 'test subresource', '', false)
assert.equal((await state()).outputOpen, true)
assert.notEqual(
output.webContents.getOSProcessId(),
control.webContents.getOSProcessId(),
'output renderer must be isolated from control',
)
const rendererPid = output.webContents.getOSProcessId()
assert.ok(rendererPid > 0 && rendererPid !== process.pid)
// SIGKILL даёт настоящий render-process-gone без зависания сборщика crash dump.
process.kill(rendererPid, 'SIGKILL')
await stopped('renderer crashed')
output = await open()
await start()
output.webContents.emit('did-fail-load', {}, -2, 'test main document', '', true)
await stopped('main document failed')
output = await open()
await start()
output.emit('unresponsive')
await stopped('window hung')
// Обычная кнопка закрытия не меняет прежнюю политику таймера.
await open()
await start()
await evaluate('window.screenBridge.closeOutput()')
assert.equal((await state()).timer.status, 'running')
powerMonitor.emit('suspend')
assert.equal((await state()).timer.status, 'paused')
powerMonitor.emit('resume')
await open()
control.setBounds({
x: work.x + 30,
y: work.y + 30,
width: Math.min(900, work.width - 30),
height: Math.min(650, work.height - 30),
})
app.emit('before-quit', {})
assert.equal(powerBlocks.active.size, 0)
assert.equal((await host()).sleepBlocked, false)
const saved = JSON.parse(readFileSync(join(app.getPath('userData'), 'windows.json'), 'utf8'))
assert.equal(saved.display.id, targetDisplay.id)
assert.equal(saved.display.label, targetDisplay.label)
assert.deepEqual(saved.control.bounds, control.getNormalBounds())
assert.equal(powerBlocks.starts, powerBlocks.stops)
console.log(
'SCREEN_VENUE_SMOKE_PASS',
JSON.stringify({
blockerStarts: powerBlocks.starts,
blockerStops: powerBlocks.stops,
rendererCrash: true,
incidents: 10,
}),
)
} finally {
screen.getAllDisplays = getDisplays
screen.getPrimaryDisplay = getPrimary
}
}
+23 -2
View File
@@ -30,7 +30,12 @@ const message = useMessage()
const bridge = window.screenBridge const bridge = window.screenBridge
const state = ref<State | null>(null) const state = ref<State | null>(null)
const packages = ref<PackageSummary[]>([]) const packages = ref<PackageSummary[]>([])
const host = ref<HostInfo>({ displays: [] }) const host = ref<HostInfo>({
displays: [],
notice: '',
sleepBlocked: false,
preferredDisplayId: null,
})
const display = ref<number | null>(null) const display = ref<number | null>(null)
const positions = ref<Record<Channel, number | null>>({ main: 10, music: 10 }) const positions = ref<Record<Channel, number | null>>({ main: 10, music: 10 })
const isBusy = ref(false) const isBusy = ref(false)
@@ -179,7 +184,10 @@ async function removePackage(key: string) {
}) })
} }
async function refreshHost() { async function refreshHost() {
if (bridge) host.value = await bridge.host() if (!bridge) return
host.value = await bridge.host()
if (display.value === null) display.value = host.value.preferredDisplayId
if (!host.value.displays.some((d) => d.id === display.value)) display.value = null
} }
function metrics(channel: Channel) { function metrics(channel: Channel) {
const values = (state.value?.samples ?? []) const values = (state.value?.samples ?? [])
@@ -254,6 +262,19 @@ onUnmounted(() => {
</n-card> </n-card>
<n-card v-if="bridge" title="Дисплей показа"> <n-card v-if="bridge" title="Дисплей показа">
<n-space vertical> <n-space vertical>
<n-alert v-if="host.notice" type="warning">{{ host.notice }}</n-alert>
<p role="status">
{{
host.sleepBlocked
? 'Автоматический сон заблокирован на время показа'
: 'Блокировка сна выключена'
}}
</p>
<p>
Перед игрой выберите аудиовыход в настройках звука ОС, проверьте громкость и выполните
пробный пуск. После подключения HDMI проверьте выход повторно: система может переключить
звук на проектор.
</p>
<n-select <n-select
v-model:value="display" v-model:value="display"
:options="host.displays.map((d) => ({ label: d.label, value: d.id }))" :options="host.displays.map((d) => ({ label: d.label, value: d.id }))"
+3
View File
@@ -125,6 +125,9 @@ export interface DisplayInfo {
label: string label: string
} }
export interface HostInfo { export interface HostInfo {
notice: string
sleepBlocked: boolean
preferredDisplayId: number | null
displays: DisplayInfo[] displays: DisplayInfo[]
} }
export interface PackageSummary { export interface PackageSummary {