up
This commit is contained in:
@@ -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) на всей матрице.
|
||||||
|
|||||||
@@ -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 — Сборки всех платформ и приёмка
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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 {}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }))"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user