From 1d4028b18382983196da94d7f2a5762d23d0150f Mon Sep 17 00:00:00 2001 From: Fedorov Vladimir Date: Sat, 17 Oct 2026 20:57:16 +0700 Subject: [PATCH] up --- README.md | 34 +++- TASKS.md | 43 +++++- docs/SCREEN-007_ACCEPTANCE.md | 91 +++++++++++ electron/controller.ts | 10 +- electron/main.ts | 257 +++++++++++++++++++++++++++---- electron/presentation.test.ts | 28 ++++ electron/window-settings.test.ts | 80 ++++++++++ electron/window-settings.ts | 100 ++++++++++++ scripts/online-smoke.cjs | 15 ++ scripts/smoke.cjs | 39 ++++- scripts/venue-smoke.cjs | 172 +++++++++++++++++++++ src/components/ControlPage.vue | 25 ++- src/types/screen.ts | 3 + 13 files changed, 849 insertions(+), 48 deletions(-) create mode 100644 docs/SCREEN-007_ACCEPTANCE.md create mode 100644 electron/window-settings.test.ts create mode 100644 electron/window-settings.ts create mode 100644 scripts/venue-smoke.cjs diff --git a/README.md b/README.md index 4746fb6..a235cf1 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,10 @@ macOS Intel/Apple Silicon, Windows x64/ARM64 и Linux x64/ARM64. общего правила хранения задач в `../docs/agent`. Не дублировать их там. Идентификаторы `SCREEN-*` относятся только к этому приложению. -Следующий этап: SCREEN-007 — устойчивость на площадке; аппаратная проверка -SCREEN-001/SCREEN-004 остаётся открытой. +Программная часть SCREEN-007 реализована: обработка сбоев показа, пауза таймера, +предотвращение автоматического сна и восстановление пульта. SCREEN-007 вместе +с SCREEN-001/SCREEN-004 остаётся IN_PROGRESS до аппаратной приёмки. +[Инструкция проверки устойчивости](docs/SCREEN-007_ACCEPTANCE.md). Новые задачи: `Новая задача: <описание>`. Commit и push выполняет только пользователь. Вложенные `.git` не изменять. @@ -173,7 +175,7 @@ URL, независимо от исходного имени файла. Лок `selectPackage(null)` включает тестовый режим. Выбор повторно проверяет пакет. Закрытие или пересоздание окна и смена пакета создают новую сессию и останавливают каналы; повторное открытие не возобновляет звук. После перезапуска всего приложения -выбор начинается с тестового режима; сохранение выбора относится к SCREEN-007. +готовый выбранный пакет восстанавливается механизмом SCREEN-006 без автопуска. Для ручной проверки с настоящим внешним дисплеем выполните `npm run acceptance`. Команда использует синтетические fixtures и отдельный временный userData, создаёт @@ -204,8 +206,10 @@ URL, независимо от исходного имени файла. Лок Таймер вычисляется в основном процессе по монотонному deadline; интервалы только обновляют снимок. Пауза сохраняет точный остаток, сброс возвращает полную длительность. Таймер продолжает идти за другой сценой и при закрытом окне; смена пакета сбрасывает -его. По окончании остаётся 00:00, без сигнала и смены режима. Восстановление после -сна/перезапуска относится к SCREEN-007. +его. По окончании остаётся 00:00, без сигнала и смены режима. С SCREEN-007 аварийное +закрытие (дисплей, сон ОС, fullscreen или renderer) ставит работающий таймер на +паузу с точным остатком. После пробуждения требуется вручную открыть показ и +продолжить отсчёт; перезапуск приложения не возобновляет таймер. Обложка и фон читаются через локальный протокол. Новая подготовка использует адрес с идентификатором сцены, исключая устаревший декодированный кеш изображения. @@ -235,3 +239,23 @@ capturedAt, вопросы и публичные строки таблицы (ID изображения, отсутствие второго звука, предел частоты кадров предпросмотра, кириллицу и отрисовку/прокрутку 50 синтетических строк результатов. Эти строки подаются только в тестовый 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) на всей матрице. diff --git a/TASKS.md b/TASKS.md index 367b15c..0d1da19 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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. SCREEN-009 (LAN-пульт) отменена владельцем; ID не переиспользовать. Управление телефоном через сервер входит в SCREEN-006. План и общие ограничения: @@ -111,7 +111,7 @@ SCREEN-009 (LAN-пульт) отменена владельцем; ID не пе ## SCREEN-007 — Устойчивость на площадке -- Статус: `TODO` +- Статус: `IN_PROGRESS` - Зависимости: SCREEN-005, SCREEN-006 (DONE, см. COMPLETED_TASKS.md). - Цель: переживать типовые сбои без потери материалов и случайного показа пульта. - Объём: отключение/возврат дисплея, смена разрешения и fullscreen, проверка @@ -124,8 +124,43 @@ SCREEN-009 (LAN-пульт) отменена владельцем; ID не пе переходником и звуком по кабелю/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 — Сборки всех платформ и приёмка diff --git a/docs/SCREEN-007_ACCEPTANCE.md b/docs/SCREEN-007_ACCEPTANCE.md new file mode 100644 index 0000000..36dcbe6 --- /dev/null +++ b/docs/SCREEN-007_ACCEPTANCE.md @@ -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. diff --git a/electron/controller.ts b/electron/controller.ts index b188f88..b324911 100644 --- a/electron/controller.ts +++ b/electron/controller.ts @@ -208,7 +208,15 @@ export class Controller { 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.state.outputOpen = open this.state.scene.status = open ? 'pending' : 'idle' diff --git a/electron/main.ts b/electron/main.ts index fdf9820..95d5e8a 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -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 { stat } from 'node:fs/promises' import { join } from 'node:path' import { Readable } from 'node:stream' import { Controller, parseCommand } from './controller.js' +import { WindowSettings, fitControlBounds, preferredDisplayId } from './window-settings.js' import { parseRange } from './range.js' import { Online } from './online.js' import type { OnlineState } from '../src/types/online.js' @@ -22,8 +33,17 @@ protocol.registerSchemesAsPrivileged([ ]) let control: 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 online: Online | null = null +let settings: WindowSettings +let sleepBlocker: number | null = null +let hostNotice = '' +let suspended = false +let saveTimer: ReturnType | undefined const controller = new Controller((state) => { for (const window of [control, output]) if (window && !window.isDestroyed()) window.webContents.send('state', state) @@ -32,6 +52,13 @@ const rendererRoot = () => join(app.getAppPath(), 'dist') function host(): HostInfo { const primary = screen.getPrimaryDisplay().id return { + notice: hostNotice, + sleepBlocked: sleepBlocker !== null && powerSaveBlocker.isStarted(sleepBlocker), + preferredDisplayId: preferredDisplayId( + settings?.value.display ?? null, + screen.getAllDisplays(), + primary, + ), displays: screen .getAllDisplays() .filter((d) => d.id !== primary) @@ -41,10 +68,11 @@ function host(): HostInfo { })), } } -function windowOptions() { +function windowOptions(isOutput = false) { return { backgroundColor: '#111111', webPreferences: { + ...(isOutput ? { partition: 'screen-output' } : {}), preload: join(__dirname, 'preload.js'), nodeIntegration: false, contextIsolation: true, @@ -64,31 +92,144 @@ function requireControl(event: Electron.IpcMainInvokeEvent) { ) throw new Error('Недоступно') } -function show(displayId: number) { - 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('Выберите внешний дисплей; включите расширенный рабочий стол') - if (output) output.destroy() - selectedDisplay = display.id - output = new BrowserWindow({ ...windowOptions(), ...display.bounds, show: false }) - secure(output) - controller.output(true) +function releaseSleepBlocker() { + if (sleepBlocker === null) return + powerSaveBlocker.stop(sleepBlocker) + sleepBlocker = null +} +function closeOutput(reason = '', destroy = true) { + if (reason) outputEpoch++ const window = output - output.on('closed', () => { - if (output !== window) return - output = null - selectedDisplay = null - controller.output(false) - }) - output.once('ready-to-show', () => { - if (!window.isDestroyed()) { - window.show() - window.setBounds(display.bounds) - window.setFullScreen(true) + if (!window && (!reason || controller.state.timer.status !== 'running')) return + output = null + if (window) closingWindow = window + selectedDisplay = null + if (reason) + hostNotice = `${reason}. Откройте показ заново; медиа остановлены, работающий таймер поставлен на паузу` + controller.output(false, !!reason) + releaseSleepBlocker() + if (destroy && window) { + // Не уничтожать native-окно повторно внутри его fullscreen/close callback. + 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((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 = 'Не удалось заблокировать автоматический сон. Проверьте настройки питания ОС' } - }) - void output.loadURL('screen-app://app/index.html?output=1') + window.on('close', () => { + 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) @@ -100,6 +241,12 @@ app.on('second-instance', () => { app .whenReady() .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')) await packages.initialize() online = new Online(join(app.getPath('userData'), 'connection'), packages, controller, { @@ -116,16 +263,16 @@ app }, 1000) const timerTick = setInterval(() => controller.tick(), 250) app.on('before-quit', () => { + quitting = true + saveWindowSettings() + closeOutput() + releaseSleepBlocker() clearInterval(timerTick) clearInterval(onlineTick) online?.close() packages.cancel() }) - session.defaultSession.setPermissionRequestHandler((_contents, _permission, callback) => - callback(false), - ) - session.defaultSession.setPermissionCheckHandler(() => false) - protocol.handle('screen-app', async (request) => { + const handleAppRequest: Parameters[1] = async (request) => { const url = new URL(request.url) const headers = { 'Content-Security-Policy': @@ -189,8 +336,32 @@ app } catch { 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) ipcMain.handle('state', (event) => { if ( @@ -211,7 +382,7 @@ app }) ipcMain.handle('close-output', (event) => { requireControl(event) - output?.close() + closeOutput() }) ipcMain.handle('host', (event) => { requireControl(event) @@ -221,7 +392,7 @@ app requireControl(event) if (selectingPackage) throw new Error('Подождите проверки пакета') if (typeof id !== 'number') throw new Error('Некорректный дисплей') - show(id) + return show(id) }) let selectingPackage = false ipcMain.handle('packages', (event) => { @@ -330,7 +501,25 @@ app } }) 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', () => { app.quit() diff --git a/electron/presentation.test.ts b/electron/presentation.test.ts index 4766a30..00fdacf 100644 --- a/electron/presentation.test.ts +++ b/electron/presentation.test.ts @@ -282,3 +282,31 @@ test('не принимает повреждённые снимки, повто f.c.tick() 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) +}) diff --git a/electron/window-settings.test.ts b/electron/window-settings.test.ts new file mode 100644 index 0000000..e8e2af5 --- /dev/null +++ b/electron/window-settings.test.ts @@ -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 }) +}) diff --git a/electron/window-settings.ts b/electron/window-settings.ts new file mode 100644 index 0000000..cd763b0 --- /dev/null +++ b/electron/window-settings.ts @@ -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 { + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw new Error('Некорректные настройки окон') + return value as Record +} +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) + } +} diff --git a/scripts/online-smoke.cjs b/scripts/online-smoke.cjs index efc8ca1..2f55e9c 100644 --- a/scripts/online-smoke.cjs +++ b/scripts/online-smoke.cjs @@ -148,6 +148,21 @@ module.exports = async function onlineSmoke({ assert.equal((await state()).channels.main.desired, 'playing') version++ 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' }) assert.equal((await state()).results.teams[0].name, 'Детективы') assert.equal(JSON.stringify(await state()).includes('PRIVATE'), false) diff --git a/scripts/smoke.cjs b/scripts/smoke.cjs index 76e2f47..f7d45b9 100644 --- a/scripts/smoke.cjs +++ b/scripts/smoke.cjs @@ -1,5 +1,5 @@ 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 { mkdtempSync, readFileSync, writeFileSync } = require('node:fs') const root = resolve(__dirname, '..') @@ -11,8 +11,26 @@ let targetDisplay let isSyntheticDisplay = false const boundsBeforeFullscreen = new Map() 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 = [] 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) window.webContents.capturePage = (...args) => { 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) window.setFullScreen = (flag) => { 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) } }) @@ -562,6 +587,16 @@ app.whenReady().then(async () => { displayId: targetDisplay.id, root, }) + await require('./venue-smoke.cjs')({ + control, + evaluate, + state, + command, + poll, + targetDisplay, + offlinePackage, + powerBlocks, + }) console.log( 'SCREEN_SMOKE_PASS', JSON.stringify({ @@ -587,7 +622,7 @@ app.whenReady().then(async () => { 'SMOKE_DIAGNOSTIC', window.webContents.getURL(), 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 {} diff --git a/scripts/venue-smoke.cjs b/scripts/venue-smoke.cjs new file mode 100644 index 0000000..0a8eba6 --- /dev/null +++ b/scripts/venue-smoke.cjs @@ -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 + } +} diff --git a/src/components/ControlPage.vue b/src/components/ControlPage.vue index 18fabf7..d57952b 100644 --- a/src/components/ControlPage.vue +++ b/src/components/ControlPage.vue @@ -30,7 +30,12 @@ const message = useMessage() const bridge = window.screenBridge const state = ref(null) const packages = ref([]) -const host = ref({ displays: [] }) +const host = ref({ + displays: [], + notice: '', + sleepBlocked: false, + preferredDisplayId: null, +}) const display = ref(null) const positions = ref>({ main: 10, music: 10 }) const isBusy = ref(false) @@ -179,7 +184,10 @@ async function removePackage(key: string) { }) } 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) { const values = (state.value?.samples ?? []) @@ -254,6 +262,19 @@ onUnmounted(() => { + {{ host.notice }} +

+ {{ + host.sleepBlocked + ? 'Автоматический сон заблокирован на время показа' + : 'Блокировка сна выключена' + }} +

+

+ Перед игрой выберите аудиовыход в настройках звука ОС, проверьте громкость и выполните + пробный пуск. После подключения HDMI проверьте выход повторно: система может переключить + звук на проектор. +