This commit is contained in:
2026-10-17 18:25:04 +07:00
parent 701bca234e
commit d3683ac6cf
15 changed files with 124 additions and 691 deletions
+21 -24
View File
@@ -7,7 +7,8 @@
внешнем дисплее. Аудио, видео, изображения и выбранные музыкальные плейлисты внешнем дисплее. Аудио, видео, изображения и выбранные музыкальные плейлисты
читаются с диска. Пульт переключает показ и управляет воспроизведением локально. читаются с диска. Пульт переключает показ и управляет воспроизведением локально.
После подготовки потеря интернета не мешает локальному показу и таймеру. После подготовки потеря интернета не мешает локальному показу и таймеру.
Полный пульт телефона работает в доверенной локальной сети одновременно с пультом компьютера. Телефон управляет через существующий сервер сайта; приложение экрана получает с сервера
обновления состояния. При потере интернета доступен локальный пульт компьютера.
«Все материалы» в первой версии — все ресурсы, необходимые окну показа: «Все материалы» в первой версии — все ресурсы, необходимые окну показа:
вступление, закрывающее видео, обложка, фон, материалы шагов плана, выбранные вступление, закрывающее видео, обложка, фон, материалы шагов плана, выбранные
@@ -71,23 +72,20 @@ Linux проверяется на обоих указанных дистрибу
5. Сервер проверяет право организатора на скачивание материалов. Существующий 5. Сервер проверяет право организатора на скачивание материалов. Существующий
API используется где достаточно; манифест добавляется минимальным расширением. API используется где достаточно; манифест добавляется минимальным расширением.
Токены не записываются в пакет игры и логи. Токены не записываются в пакет игры и логи.
6. После подготовки основной процесс — единственный источник состояния локального показа. 6. После подготовки основной процесс применяет локальные команды пульта компьютера
Команды пульта компьютера через IPC и телефона через WebSocket обрабатываются через IPC без ожидания сервера. Материалы воспроизводятся с диска.
последовательно; состояние с возрастающей ревизией рассылается обоим пультам. 7. Телефон использует существующий пульт на сайте и отправляет команды серверу.
Серверная синхронизация не задерживает команды. Команды сайта не управляют Приложение экрана подключается к серверу как экранная сессия и получает обновления
desktop-показом; возврат интернета не повторяет старые серверные команды. через существующий API CreateScreenSession/GetScreenState. Текущий браузерный
7. Приложение по явному действию оператора раздаёт мобильный Vue-пульт со встроенного экран опрашивает состояние раз в 1000 мс; desktop использует тот же серверный поток.
HTTP-сервера на выбранном IPv4-интерфейсе. Код сопряжения, подтверждение на HTTP-сервер, WebSocket-сервер, сопряжение и отдельный мобильный пульт в desktop не нужны.
компьютере и временная HttpOnly-cookie ограничивают доступ. Сеансы только в памяти; 8. Подключение игры, авторизация и получение серверных обновлений реализуются в SCREEN-006.
отключение пульта отзывает их. Рабочая сеть — своя Wi-Fi/LAN или точка доступа: Серверные команды проходят через локальный контроллер и используют подготовленные
HTTP не шифрует трафик. Сертификаты и управление через интернет не входят в MVP. файлы. Одну версию команды нельзя применять повторно. При потере сети телефон
В сетевом API нет произвольного доступа к диску, IPC и токенам сайта. не управляет desktop; уже запущенное воспроизведение и локальный пульт продолжают
8. Телефон управляет всеми режимами показа, шагами, таймером, музыкой, громкостью работать. При восстановлении сети необходимо сверить версии и не повторять
и перемоткой. Подготовка игры и дисплея остаётся на компьютере. Команды имеют устаревшую команду поверх локальных действий; правила согласования проверяются
идентификаторы для дедупликации; после потери связи управление блокируется, в SCREEN-006 на существующих ScreenState/ScreenCommand.
показ продолжается. Переподключение получает снимок состояния без повтора команд.
Vue-компоненты и типы пультов общие внутри screen, различается транспорт.
Общий пакет с сайтом заранее не выделяется.
9. Текущие результаты игры загружаются онлайн с отметкой времени; офлайн доступны 9. Текущие результаты игры загружаются онлайн с отметкой времени; офлайн доступны
только последние сохранённые данные с явным предупреждением оператору. только последние сохранённые данные с явным предупреждением оператору.
Серверная игра, ответы команд и вычисление новых результатов офлайн не работают. Серверная игра, ответы команд и вычисление новых результатов офлайн не работают.
@@ -113,15 +111,14 @@ Linux проверяется на обоих указанных дистрибу
## Порядок работ ## Порядок работ
1. SCREEN-001: проверить Electron на всех шести вариантах, два дисплея, локальное 1. SCREEN-001: проверить Electron на всех шести вариантах, два дисплея, локальное
медиа и задержки, техническое подключение телефона по LAN; порог задержки — на M1. медиа и задержки; порог задержки — на M1.
2. SCREEN-002: получить полный и защищённый пакет материалов с сервера. 2. SCREEN-002: получить полный и защищённый пакет материалов с сервера.
3. SCREEN-003: реализовать надёжную загрузку и локальное хранение пакетов. 3. SCREEN-003: реализовать надёжную загрузку и локальное хранение пакетов.
4. SCREEN-004: реализовать локальный проигрыватель и команды, подготовку медиа. 4. SCREEN-004: реализовать локальный проигрыватель и команды, подготовку медиа.
5. SCREEN-005: собрать пульт, план показа и режимы экрана. 5. SCREEN-005: собрать пульт, план показа и режимы экрана.
6. SCREEN-009: полный пульт телефона, сопряжение, общее состояние и восстановление связи. 6. SCREEN-006: добавить подключение игры, обновления экрана с сервера и управление с телефона через сайт.
7. SCREEN-006: добавить подключение игры и согласование с сервером. 7. SCREEN-007: обработать отключение дисплея, звук, сон и восстановление.
8. SCREEN-007: обработать отключение дисплея, звук, сон и восстановление. 8. SCREEN-008: собрать артефакты для всех шести вариантов и провести приёмку на площадке.
9. SCREEN-008: собрать артефакты для всех шести вариантов и провести приёмку на площадке.
После SCREEN-001 — решение о продолжении по результатам всей матрицы платформ. После SCREEN-001 — решение о продолжении по результатам всей матрицы платформ.
Разработка на одной ОС не заменяет аппаратную проверку остальных ОС и архитектур, Разработка на одной ОС не заменяет аппаратную проверку остальных ОС и архитектур,
@@ -139,7 +136,7 @@ Linux проверяется на обоих указанных дистрибу
- В SCREEN-001 для каждой тестовой конфигурации зафиксировать ОС, архитектуру, - В SCREEN-001 для каждой тестовой конфигурации зафиксировать ОС, архитектуру,
CPU/RAM, проектор/переходник, аудиовыход, форматы, разрешение и размер медиа. CPU/RAM, проектор/переходник, аудиовыход, форматы, разрешение и размер медиа.
- Полное управление игрой, локальный Go-сервер/БД, редактирование сценария, - Полное управление игрой, локальный Go-сервер/БД, редактирование сценария,
автообновления и управление desktop через интернет — за пределами MVP. автообновления и прямое управление desktop с телефона по LAN — за пределами MVP.
- Подпись и notarization зависят от наличия Apple Developer; без них не обещать - Подпись и notarization зависят от наличия Apple Developer; без них не обещать
установку без предупреждений Gatekeeper. Для Windows отдельно зафиксировать наличие сертификата подписи и установку без предупреждений Gatekeeper. Для Windows отдельно зафиксировать наличие сертификата подписи и
возможные предупреждения установки. Учётные данные не хранить в репозитории. возможные предупреждения установки. Учётные данные не хранить в репозитории.
+5 -6
View File
@@ -1,7 +1,8 @@
# Вечерний детектив — приложение площадки # Вечерний детектив — приложение площадки
Статус: SCREEN-001 в работе; технический стенд реализован, аппаратная приёмка открыта. Статус: SCREEN-001 в работе; технический стенд реализован, аппаратная приёмка открыта.
Архитектура пересмотрена 17.10.2026: добавлен пульт телефона по доверенной LAN. Управление с телефона идёт через существующий сервер сайта; desktop получает
обновления с сервера. Прямое LAN-подключение не используется.
Цель: ведущий управляет показом на компьютере с macOS, Windows или Linux, а подключённый монитор или Цель: ведущий управляет показом на компьютере с macOS, Windows или Linux, а подключённый монитор или
проектор показывает материалы игрокам. Все материалы показа заранее находятся проектор показывает материалы игрокам. Все материалы показа заранее находятся
@@ -40,11 +41,9 @@ npm run dev
Стенд поддерживает пуск с начала, паузу, остановку и перемотку. Полные игровые Стенд поддерживает пуск с начала, паузу, остановку и перемотку. Полные игровые
режимы, загрузка игр и авторизация пока не реализованы. режимы, загрузка игр и авторизация пока не реализованы.
Для телефона выберите IPv4-адрес доверенной сети и включите LAN-пульт. Откройте Управление телефоном через сервер, подключение игры и авторизация относятся к
показанный адрес в браузере телефона, введите код и подтвердите запрос с тем же SCREEN-006 и в техническом стенде ещё не реализованы. Интернет нужен для получения
коротким идентификатором на компьютере. Отключение LAN отзывает все подключения. команд телефона; локальное воспроизведение и пульт компьютера работают без него.
Если устройства не видят друг друга, проверьте firewall и изоляцию клиентов Wi-Fi;
локальный пульт компьютера продолжает работать.
Проверки: npm run type-check, npm run lint, npm run lint:css, npm test, npm run build. Проверки: npm run type-check, npm run lint, npm run lint:css, npm test, npm run build.
[Сборки и аппаратная приёмка](docs/SCREEN-001_ACCEPTANCE.md). [Сборки и аппаратная приёмка](docs/SCREEN-001_ACCEPTANCE.md).
+32 -55
View File
@@ -1,6 +1,8 @@
# Задачи приложения площадки # Задачи приложения площадки
SCREEN-001 имеет статус IN_PROGRESS; остальные задачи — TODO. План и общие ограничения: SCREEN-001 имеет статус IN_PROGRESS; остальные задачи — TODO.
SCREEN-009 (LAN-пульт) отменена владельцем; ID не переиспользовать.
Управление телефоном через сервер входит в SCREEN-006. План и общие ограничения:
[PLAN.md](PLAN.md). Идентификаторы независимы от ED-* в корневом списке. [PLAN.md](PLAN.md). Идентификаторы независимы от ED-* в корневом списке.
Обновлять статус и результат здесь; завершённые записи переносить в Обновлять статус и результат здесь; завершённые записи переносить в
`COMPLETED_TASKS.md` в этом каталоге после проверки. Коммиты и push не выполнять. `COMPLETED_TASKS.md` в этом каталоге после проверки. Коммиты и push не выполнять.
@@ -14,8 +16,7 @@ SCREEN-001 имеет статус IN_PROGRESS; остальные задачи
матрицу; Linux проверяется на Ubuntu 24.04 LTS и Debian 13. матрицу; Linux проверяется на Ubuntu 24.04 LTS и Debian 13.
- Объём: минимальный Electron + Vue + TypeScript + Vite; пульт и отдельное окно - Объём: минимальный Electron + Vue + TypeScript + Vite; пульт и отдельное окно
показа; выбор дисплея; синтетические локальные аудио/видео; IPC start/pause/stop показа; выбор дисплея; синтетические локальные аудио/видео; IPC start/pause/stop
и seek; безопасный preload и локальная выдача медиа с Range; техническое и seek; безопасный preload и локальная выдача медиа с Range. Подготовить сборки
подключение браузера телефона по LAN с сопряжением и отправкой команды. Подготовить сборки
для всех шести вариантов, инструкцию получения тестовых медиа и аппаратной для всех шести вариантов, инструкцию получения тестовых медиа и аппаратной
проверки. Бинарные тестовые файлы не коммитить. проверки. Бинарные тестовые файлы не коммитить.
- Готовность: запуск на всех шести вариантах; пульт остаётся на основном экране, - Готовность: запуск на всех шести вариантах; пульт остаётся на основном экране,
@@ -28,36 +29,28 @@ SCREEN-001 имеет статус IN_PROGRESS; остальные задачи
звук/кадр и отключённая сеть. Проверить кабель/USB и HDMI. Владелец выполняет звук/кадр и отключённая сеть. Проверить кабель/USB и HDMI. Владелец выполняет
аппаратные проверки по инструкции агента и передаёт результаты. аппаратные проверки по инструкции агента и передаёт результаты.
- Не входит: сервер, авторизация, скачивание, полный пульт, публикация. - Не входит: сервер, авторизация, скачивание, полный пульт, публикация.
- Результат программного этапа (17.10.2026): Electron + Vue/TypeScript/Vite, - Результат программного этапа: Electron + Vue/TypeScript/Vite, два изолированных
два изолированных окна, выбор внешнего дисплея, локальный протокол с Range, окна, выбор внешнего дисплея, локальный протокол с Range, синтетические
синтетические WAV/H.264+AAC, start/pause/stop/seek, подтверждённые статусы и WAV/H.264+AAC, start/pause/stop/seek, подтверждённые статусы и замеры аудио/видео.
отдельные замеры аудио/видео и компьютера/телефона. LAN включается явно: По уточнению владельца LAN-сервер, сопряжение, мобильный UI и зависимости ws
код + подтверждение на компьютере, HttpOnly-сеанс, отзыв при отключении. удалены. Телефон управляет через сервер сайта; desktop получает обновления
Полные игровые функции и сервер не переносились. в SCREEN-006. Полные игровые функции и сервер пока не переносились.
Подготовлены четыре финальных архива Linux/Windows x64/ARM64 в release/ и - Проверка после уточнения: type-check, lint, lint:css, build, 4 теста и GUI smoke —
SHA256SUMS; содержимое app.asar всех четырёх сверено с проверенным кодом, PASS. В smoke проверено отсутствие LAN-панели и методов lan/approve в preload,
медиа присутствуют, тестовые модули исключены из поставки. Linux x64-бинарник локальные play/pause/stop/seek, Range и открытие окна без автоповтора.
отдельно запускается из упакованного каталога; UI/preload и Range — PASS. Frontend-стандарт прочитан; ручное ревью diff выполнено, нарушений не найдено.
macOS x64/ARM64 не собраны здесь: нужны Mac или подготовленный ручной CI workflow. - Сборки: Linux/Windows x64/ARM64 пересобраны без LAN; содержимое всех четырёх
- Проверка: frontend-стандарт прочитан; ручное ревью нового кода выполнено, app.asar сверено с проверенным кодом. LAN-модули, ws и тесты отсутствуют в поставке;
используются Naive UI, типизированный IPC, scoped CSS и палитра проекта. release/SHA256SUMS обновлён. macOS нужно собирать на Mac или ручным workflow.
type-check, lint, lint:css, build и 5 тестов — PASS. GUI smoke — PASS:
фактический запуск/пауза/стоп/seek медиа, Range 206/100 байт, запрет IPC из окна
показа, сопряжение через браузерный UI, оба пульта, отзыв и открытие окна без
автоповтора. Это синтетический дисплей и браузер Electron, не проектор/телефон.
Проверено, что превышение лимита WebSocket закрывает соединение, не сервер.
В sandbox LAN-тесту недоступен bind; PASS получен вне sandbox с временным портом.
Headless в этой среде зависал/завершался SIGSEGV; GUI прошёл с тестовыми
--no-sandbox --mute-audio --disable-gpu. В обычном запуске этих флагов нет.
- Среда smoke: Manjaro Linux x86_64, Intel Core Ultra 7 255H, около 32 ГБ RAM; - Среда smoke: Manjaro Linux x86_64, Intel Core Ultra 7 255H, около 32 ГБ RAM;
Node 26.8.1, Electron 42.11.15. Программные единичные замеры не считаются Node 26.8.1, Electron 42.11.15. Единичные программные замеры не подтверждают
аппаратной приёмкой или подтверждением p95 ≤150 мс. аппаратную приёмку или физический p95 ≤150 мс. Headless зависал/падал; GUI smoke
- Ограничения: npm audit --omit=dev — 0 уязвимостей; полный audit — 20 в цепочках проходил с тестовыми --no-sandbox --mute-audio --disable-gpu.
инструментов разработки (8 moderate, 12 high). Дедупликация стенда хранит последние - Ограничения: дедупликация стенда хранит последние 4096 команд; долговременная
4096 команд; долговременная семантика — SCREEN-004. HTTP только в доверенной сети. семантика — SCREEN-004. Привязка игры и серверное управление — SCREEN-006.
- Следующий шаг: получить аппаратные результаты владельца по - Следующий шаг: получить аппаратные результаты владельца по
docs/SCREEN-001_ACCEPTANCE.md на всех шести вариантах (Linux — на обоих docs/SCREEN-001_ACCEPTANCE.md на всех шести вариантах (Linux — на обоих
дистрибутивах), включая реальные iPhone/Safari и Android/Chrome, проектор, дистрибутивах), включая проектор,
физический звук/кадр и 30 запусков для каждого формата/аудиовыхода. macOS-сборки физический звук/кадр и 30 запусков для каждого формата/аудиовыхода. macOS-сборки
подготовить на Mac или ручным workflow builds.yml. До результатов всей матрицы подготовить на Mac или ручным workflow builds.yml. До результатов всей матрицы
задача остаётся IN_PROGRESS, SCREEN-002 не начинается. задача остаётся IN_PROGRESS, SCREEN-002 не начинается.
@@ -139,22 +132,27 @@ SCREEN-001 имеет статус IN_PROGRESS; остальные задачи
- Цель: выбирать доступную организатору игру онлайн и сохранять автономность показа. - Цель: выбирать доступную организатору игру онлайн и сохранять автономность показа.
- Объём: существующая авторизация, список игр, загрузка пакета, снимок результатов, - Объём: существующая авторизация, список игр, загрузка пакета, снимок результатов,
индикатор связи, проверка новой версии материалов, синхронизация статуса показа. индикатор связи, проверка новой версии материалов, синхронизация статуса показа.
Телефон отправляет команды существующему серверу сайта; desktop получает
обновления через экранную сессию и GetScreenState (существующий polling 1000 мс).
Серверные команды применяются локальным контроллером к подготовленным файлам;
повторная версия не применяется дважды. Прямого подключения телефона к desktop нет.
Уточнить минимальный серверный контракт по фактической реализации. Уточнить минимальный серверный контракт по фактической реализации.
- Готовность: готовая игра открывается после перезапуска офлайн; секреты хранятся - Готовность: готовая игра открывается после перезапуска офлайн; секреты хранятся
средствами ОС, не в пакете; истечение сессии не обрывает уже подготовленный средствами ОС, не в пакете; истечение сессии не обрывает уже подготовленный
локальный показ; обновления активируются явно. Результаты имеют время снимка. локальный показ; обновления активируются явно. Результаты имеют время снимка.
Возврат сети не повторяет старые команды; серверные команды не перехватывают desktop-показ. Возврат сети не повторяет устаревшие команды поверх локальных действий.
При доступном сервере телефон управляет показом через существующий сайт; без сети
воспроизведение и пульт компьютера продолжают работать.
- Проверка: сценарии offline/reconnect/истёкшей сессии/обновления игры; проверки - Проверка: сценарии offline/reconnect/истёкшей сессии/обновления игры; проверки
затронутых репозиториев; существующий браузерный экран продолжает работать. затронутых репозиториев; существующий браузерный экран продолжает работать.
- Не входит: полноценная офлайн-игра и управление desktop через интернет. - Не входит: полноценная офлайн-игра, LAN-сервер и отдельный мобильный пульт desktop.
Телефон по LAN реализуется в SCREEN-009; сайт не перехватывает локальное управление.
- Следующий шаг: сверить auth flow и модель сессий; зафиксировать правила - Следующий шаг: сверить auth flow и модель сессий; зафиксировать правила
локального владения показом и отображения статуса на сервере. локального владения показом и отображения статуса на сервере.
## SCREEN-007 — Устойчивость на площадке ## SCREEN-007 — Устойчивость на площадке
- Статус: `TODO` - Статус: `TODO`
- Зависимости: SCREEN-005, SCREEN-006, SCREEN-009. - Зависимости: SCREEN-005, SCREEN-006.
- Цель: переживать типовые сбои без потери материалов и случайного показа пульта. - Цель: переживать типовые сбои без потери материалов и случайного показа пульта.
- Объём: отключение/возврат дисплея, смена разрешения и fullscreen, проверка - Объём: отключение/возврат дисплея, смена разрешения и fullscreen, проверка
аудиовыхода каждой ОС, предотвращение сна во время показа, восстановление окон и аудиовыхода каждой ОС, предотвращение сна во время показа, восстановление окон и
@@ -191,24 +189,3 @@ SCREEN-001 имеет статус IN_PROGRESS; остальные задачи
- Следующий шаг: выбрать минимальный инструмент упаковки для всей матрицы, - Следующий шаг: выбрать минимальный инструмент упаковки для всей матрицы,
проверить среду сборки и наличие сертификатов, собрать артефакты и подготовить проверить среду сборки и наличие сертификатов, собрать артефакты и подготовить
инструкции для аппаратной приёмки. инструкции для аппаратной приёмки.
## SCREEN-009 — Полный пульт телефона по локальной сети
- Статус: `TODO`
- Зависимость: SCREEN-005; использовать техническую основу SCREEN-001.
- Цель: управлять показом с компьютера и телефона одновременно без интернета.
- Объём: общий Vue-пульт и типы команд внутри screen, HTTP и WebSocket на выбранном
IPv4-интерфейсе; код сопряжения и подтверждение на компьютере, отзыв сеансов;
все режимы, шаги, таймер, музыка, громкость и перемотка. Загрузка игры и дисплей —
только на компьютере. Доверенная Wi-Fi/LAN или точка доступа; HTTP без шифрования.
- Готовность: основной процесс последовательно применяет команды обоих пультов,
рассылает актуальное состояние; повтор команды не применяется дважды. Потеря
связи блокирует телефон, но не показ; переподключение не повторяет команды.
Доступ без сопряжения запрещён; отключение пульта отзывает сеансы.
- Проверка: тесты валидации, сопряжения, отзыва, повторных/одновременных команд и
восстановления; type-check, lint, lint:css, build; Safari iPhone и Chrome Android,
блокировка телефона, потеря Wi-Fi, работа без интернета. Задержки телефона отдельно.
- Не входит: публичная сеть площадки, сертификаты, управление через интернет,
скачивание пакетов и настройка дисплеев с телефона.
- Следующий шаг: после SCREEN-005 подключить полный интерфейс к локальному
контроллеру через общий транспортный контракт и пройти мобильную приёмку.
+14 -21
View File
@@ -41,24 +41,21 @@ $env:ELECTRON_BUILDER_COMPRESSION_LEVEL='1'). Она меняет степень
Имена архивов включают ОС и архитектуру; release/ и тестовые медиа игнорируются Git. Имена архивов включают ОС и архитектуру; release/ и тестовые медиа игнорируются Git.
## Результат программного этапа 17.10.2026 ## Актуальность сборок
Собраны evening-detective-screen-0.1.0-linux-{x64,arm64}.tar.gz и После удаления LAN-пульта архивы Linux/Windows x64/ARM64 пересобраны из текущего
Windows-архивы evening-detective-screen-0.1.0-win-{x64,arm64}.zip в release/. кода. Содержимое app.asar сверено: LAN-модулей, ws и тестов в поставке нет.
Контрольные суммы — release/SHA256SUMS. Содержимое app.asar всех четырёх вариантов Контрольные суммы обновлены в release/SHA256SUMS. Type-check, lint, lint:css,
сверено с проверенным кодом; оба медиафайла присутствуют, тестовые модули исключены. build, 4 теста и GUI smoke — PASS, включая запуск кнопкой локального пульта.
Готовый Linux x64-бинарник проверен на Manjaro: запуск UI/preload и Range — PASS. Сборки macOS и workflow пока не выполнялись; аппаратная приёмка остаётся открытой.
Сборки macOS не выполнялись, workflow не запускался. Совместимость с целевыми ОС,
внешним оборудованием и мобильными браузерами остаётся открытой.
## Автоматический smoke ## Автоматический smoke
После генерации медиа выполните npm run smoke в графической сессии. Проверка После генерации медиа выполните npm run smoke в графической сессии. Проверка
использует синтетический второй дисплей и отдельное браузерное окно вместо телефона: использует синтетический второй дисплей: IPC, Range, запуск/пауза/стоп/seek,
IPC, Range, запуск/пауза/стоп/seek, запрет IPC из окна показа, сопряжение через UI, запрет IPC из окна показа и отсутствие автоповтора при открытии окна. Дополнительно
команды обоих пультов, отзыв доступа и отсутствие автоповтора при открытии окна. проверяется отсутствие LAN-методов в preload и сетевых параметров в данных пульта.
Для тестового браузера используется прямое LAN-соединение без системного прокси. Это не проверка реального проектора. Серверное управление проверяется в SCREEN-006.
Это не проверка реального проектора или мобильного браузера.
В ограниченной среде агента запуск выполнен с тестовыми флагами --no-sandbox В ограниченной среде агента запуск выполнен с тестовыми флагами --no-sandbox
--mute-audio --disable-gpu. В обычном запуске npm run dev этих флагов нет. --mute-audio --disable-gpu. В обычном запуске npm run dev этих флагов нет.
@@ -88,17 +85,13 @@ npm run media:generate использует ffmpeg:
4. Для каждого формата выполнить минимум 30 запусков подготовленного медиа отдельно 4. Для каждого формата выполнить минимум 30 запусков подготовленного медиа отдельно
для кабеля/USB и HDMI. Записать медиану/p95 из пульта для выбранного формата. для кабеля/USB и HDMI. Записать медиану/p95 из пульта для выбранного формата.
Холодный старт учитывать отдельно. Замеры последних 400 запусков хранятся в RAM Холодный старт учитывать отдельно. Замеры последних 400 запусков хранятся в RAM
и разделены по формату и источнику команды. После перезапуска они сбрасываются. и разделены по формату. После перезапуска они сбрасываются.
5. Проверить pause/stop и seek на 10, 30, 59 с как во время воспроизведения, так и 5. Проверить pause/stop и seek на 10, 30, 59 с как во время воспроизведения, так и
на паузе. Несколько раз быстро чередовать аудио/видео, пуск/паузу/стоп: на паузе. Несколько раз быстро чередовать аудио/видео, пуск/паузу/стоп:
звук не должен дублироваться; итоговый статус соответствует проигрывателю. звук не должен дублироваться; итоговый статус соответствует проигрывателю.
6. Отключить интернет; локальное воспроизведение и пульт остаются работоспособными. 6. Отключить интернет; локальное воспроизведение и пульт остаются работоспособными.
7. В своей Wi-Fi/LAN или точке доступа открыть адрес с компьютера на iPhone/Safari
и Android/Chrome. Проверить неверный код, ожидание подтверждения, управление с 7. Отключить дисплей: окно показа закрывается, пульт остаётся доступен. Подключить
обоих пультов, блокировку телефона, обрыв и восстановление Wi-Fi, отзыв доступа.
После восстановления не должна повторяться последняя команда. Снять отдельные
замеры телефона; отображаемая задержка НЕ включает путь телефон → компьютер.
8. Отключить дисплей: окно показа закрывается, пульт остаётся доступен. Подключить
снова, открыть показ явно: старое воспроизведение не должно возобновиться. снова, открыть показ явно: старое воспроизведение не должно возобновиться.
Сценарий выполнить для macOS x64/ARM64 и Windows 11 x64/ARM64; для Linux x64/ARM64 Сценарий выполнить для macOS x64/ARM64 и Windows 11 x64/ARM64; для Linux x64/ARM64
@@ -123,7 +116,7 @@ npm run media:generate использует ffmpeg:
- дисплей/переходник/разрешение; аудиовыход; версии ffmpeg и параметры файлов; - дисплей/переходник/разрешение; аудиовыход; версии ffmpeg и параметры файлов;
- холодный старт; 30 исходных значений каждого формата/выхода; медиана/p95; - холодный старт; 30 исходных значений каждого формата/выхода; медиана/p95;
- отдельно программная и физическая задержка, способ измерения и погрешность; - отдельно программная и физическая задержка, способ измерения и погрешность;
- результаты pause/seek/fullscreen/offline/LAN/reconnect/отзыва; - результаты pause/seek/fullscreen/offline/переподключения дисплея;
- ошибки, ограничения и итог PASS/FAIL. - ошибки, ограничения и итог PASS/FAIL.
Пока не пройдена вся матрица и не получены физические измерения M1, SCREEN-001 Пока не пройдена вся матрица и не получены физические измерения M1, SCREEN-001
+1 -1
View File
@@ -4,7 +4,7 @@ import { Controller, parseCommand } from './controller.js'
import { parseRange } from './range.js' import { parseRange } from './range.js'
const command = { id: 'one', action: 'play', media: 'audio', position: 0 } const command = { id: 'one', action: 'play', media: 'audio', position: 0 }
test('валидирует команды на границе IPC / LAN', () => { test('валидирует команды на границе IPC', () => {
assert.deepEqual(parseCommand(command), command) assert.deepEqual(parseCommand(command), command)
for (const value of [ for (const value of [
null, null,
+1 -4
View File
@@ -30,16 +30,14 @@ export class Controller {
state: State = { revision: 0, command: null, status: 'idle', error: '', samples: [] } state: State = { revision: 0, command: null, status: 'idle', error: '', samples: [] }
private seen = new Set<string>() private seen = new Set<string>()
private startedAt = 0 private startedAt = 0
private origin: 'computer' | 'phone' = 'computer'
constructor(private publish: (state: State) => void) {} constructor(private publish: (state: State) => void) {}
command(input: unknown, origin: 'computer' | 'phone' = 'computer') { command(input: unknown) {
const command = parseCommand(input) const command = parseCommand(input)
if (this.seen.has(command.id)) return if (this.seen.has(command.id)) return
this.seen.add(command.id) this.seen.add(command.id)
// shortcut: помним 4096 команд стенда; долговременная дедупликация — SCREEN-004. // shortcut: помним 4096 команд стенда; долговременная дедупликация — SCREEN-004.
if (this.seen.size > 4096) this.seen.delete(this.seen.values().next().value!) if (this.seen.size > 4096) this.seen.delete(this.seen.values().next().value!)
this.startedAt = performance.now() this.startedAt = performance.now()
this.origin = origin
this.state = { this.state = {
...this.state, ...this.state,
revision: this.state.revision + 1, revision: this.state.revision + 1,
@@ -70,7 +68,6 @@ export class Controller {
...this.state.samples.slice(-399), ...this.state.samples.slice(-399),
{ {
media: this.state.command.media, media: this.state.command.media,
origin: this.origin,
milliseconds: performance.now() - this.startedAt, milliseconds: performance.now() - this.startedAt,
}, },
] ]
-106
View File
@@ -1,106 +0,0 @@
import assert from 'node:assert/strict'
import { once } from 'node:events'
import { mkdtemp, writeFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test } from 'node:test'
import { WebSocket } from 'ws'
import { Controller } from './controller.js'
import { LanServer } from './lan.js'
test('LAN: код, подтверждение, cookie, WebSocket и восстановление без повтора', async () => {
const root = await mkdtemp(join(tmpdir(), 'screen-test-'))
await writeFile(join(root, 'index.html'), '<html>local</html>')
let count = 0
const controller = new Controller((state) => {
count++
server.broadcast(state)
})
const server = new LanServer(
root,
() => controller.state,
(input) => controller.command(input),
)
const sockets: WebSocket[] = []
try {
await server.start('127.0.0.1')
const origin = server.url
assert.equal((await fetch(origin)).status, 200)
assert.equal((await fetch(origin + '/package.json')).status, 404)
assert.equal(
(
await fetch(origin + '/pair', {
method: 'POST',
headers: { Origin: origin },
body: JSON.stringify({ code: 'wrong' }),
})
).status,
403,
)
assert.equal(
(
await fetch(origin + '/pair', {
method: 'POST',
headers: { Origin: 'http://evil.example' },
body: '{}',
})
).status,
403,
)
const denied = new WebSocket(origin.replace('http', 'ws') + '/ws', { origin })
sockets.push(denied)
await new Promise<void>((resolve) => denied.on('error', () => resolve()))
const paired = await fetch(origin + '/pair', {
method: 'POST',
headers: { Origin: origin },
body: JSON.stringify({ code: server.code }),
})
const { id } = (await paired.json()) as { id: string }
assert.equal((await fetch(origin + '/pair/' + id)).status, 202)
server.approve(id)
const approved = await fetch(origin + '/pair/' + id)
assert.equal(approved.status, 200)
const cookie = approved.headers.get('set-cookie')!.split(';')[0]
assert.equal((await fetch(origin + '/pair/' + id)).status, 404)
async function connect() {
const ws = new WebSocket(origin.replace('http', 'ws') + '/ws', {
origin,
headers: { Cookie: cookie },
})
sockets.push(ws)
const [data] = await once(ws, 'message')
return { ws, state: JSON.parse(data.toString()) }
}
const first = await connect()
assert.equal(first.state.revision, 0)
let received = once(first.ws, 'message')
const command = { id: 'phone-1', action: 'play', media: 'audio', position: 0 }
first.ws.send(JSON.stringify(command))
await received
first.ws.send(JSON.stringify(command))
// A fresh command is a barrier proving that the duplicate was processed first.
received = once(first.ws, 'message')
first.ws.send(JSON.stringify({ ...command, id: 'phone-2', action: 'pause' }))
await received
assert.equal(count, 2)
first.ws.close()
await once(first.ws, 'close')
const restored = await connect()
assert.equal(restored.state.revision, 2)
assert.equal(count, 2)
const closed = once(restored.ws, 'close')
restored.ws.send(JSON.stringify({ action: 'delete' }))
await closed
assert.equal(count, 2)
const oversized = await connect()
const oversizedClosed = once(oversized.ws, 'close')
oversized.ws.send('x'.repeat(3000))
await oversizedClosed
assert.equal(count, 2)
assert.equal((await fetch(origin)).status, 200)
} finally {
for (const ws of sockets) ws.terminate()
await server.close()
await rm(root, { recursive: true, force: true })
}
})
-155
View File
@@ -1,155 +0,0 @@
import { randomBytes, randomInt, timingSafeEqual } from 'node:crypto'
import { createReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
import { extname, resolve, sep } from 'node:path'
import { WebSocket, WebSocketServer } from 'ws'
import type { State } from '../src/types/screen.js'
const mime: Record<string, string> = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript',
'.css': 'text/css',
}
export class LanServer {
url = ''
code = String(randomInt(100000, 1000000))
pending = new Map<string, { approved: boolean; expires: number }>()
private tokens = new Set<string>()
private attempts = 0
private attemptWindow = 0
private sockets = new WebSocketServer({ noServer: true, maxPayload: 2048 })
private server = createServer((req, res) => {
void this.handle(req, res).catch(() => {
if (!res.headersSent) res.writeHead(400)
res.end()
})
})
constructor(
private root: string,
private getState: () => State,
private command: (input: unknown) => void,
) {
this.server.on('upgrade', (req, socket, head) => {
if (req.url !== '/ws' || req.headers.origin !== this.url || !this.authorized(req)) {
socket.destroy()
return
}
this.sockets.handleUpgrade(req, socket, head, (ws) => {
ws.on('error', (error) => {
console.error('Ошибка WebSocket пульта:', error.message)
ws.terminate()
})
ws.send(JSON.stringify(this.getState()))
ws.on('message', (data) => {
try {
this.command(JSON.parse(data.toString()))
} catch {
ws.close(1008, 'Некорректная команда')
}
})
})
})
}
private authorized(req: IncomingMessage) {
const token = /(?:^|;\s*)screen=([a-f0-9]{64})(?:;|$)/.exec(req.headers.cookie ?? '')?.[1]
return !!token && this.tokens.has(token)
}
async start(address: string) {
await new Promise<void>((accept, reject) => {
this.server.once('error', reject)
this.server.listen(0, address, () => {
this.server.off('error', reject)
accept()
})
})
const info = this.server.address()
if (!info || typeof info === 'string') throw new Error('Не удалось открыть пульт')
this.url = `http://${address}:${info.port}`
}
approve(id: string) {
const pending = this.pending.get(id)
if (!pending || pending.expires < Date.now()) throw new Error('Запрос подключения истёк')
pending.approved = true
}
broadcast(state: State) {
for (const ws of this.sockets.clients)
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(state))
}
async close() {
for (const ws of this.sockets.clients) ws.terminate()
this.sockets.close()
this.tokens.clear()
this.pending.clear()
await new Promise<void>((done) => {
this.server.close(() => done())
this.server.closeAllConnections()
})
}
private json(res: ServerResponse, status: number, value: unknown) {
res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' })
res.end(JSON.stringify(value))
}
private async handle(req: IncomingMessage, res: ServerResponse) {
res.setHeader('X-Content-Type-Options', 'nosniff')
res.setHeader('Referrer-Policy', 'no-referrer')
res.setHeader(
'Content-Security-Policy',
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'",
)
if (req.headers.host !== new URL(this.url).host) return this.json(res, 403, {})
const url = new URL(req.url ?? '/', this.url)
for (const [id, pending] of this.pending)
if (pending.expires < Date.now()) this.pending.delete(id)
if (req.method === 'POST' && url.pathname === '/pair') {
if (req.headers.origin !== this.url) return this.json(res, 403, {})
if (Date.now() - this.attemptWindow > 60000) {
this.attemptWindow = Date.now()
this.attempts = 0
}
if (++this.attempts > 10 || this.pending.size >= 10) return this.json(res, 429, {})
let body = ''
for await (const chunk of req) {
body += chunk.toString()
if (body.length > 256) return this.json(res, 413, {})
}
const code: unknown = JSON.parse(body).code
if (
typeof code !== 'string' ||
code.length !== this.code.length ||
!timingSafeEqual(Buffer.from(code), Buffer.from(this.code))
)
return this.json(res, 403, {})
const id = randomBytes(24).toString('hex')
this.pending.set(id, { approved: false, expires: Date.now() + 120000 })
return this.json(res, 200, { id })
}
if (req.method === 'GET' && url.pathname.startsWith('/pair/')) {
const id = url.pathname.slice(6)
const pending = this.pending.get(id)
if (!pending) return this.json(res, 404, {})
if (!pending.approved) return this.json(res, 202, {})
const token = randomBytes(32).toString('hex')
this.tokens.add(token)
this.pending.delete(id)
res.setHeader('Set-Cookie', `screen=${token}; HttpOnly; SameSite=Strict; Path=/`)
return this.json(res, 200, {})
}
if (req.method !== 'GET' || (url.pathname !== '/' && !url.pathname.startsWith('/assets/')))
return this.json(res, 404, {})
const filename = resolve(
this.root,
'.' + decodeURIComponent(url.pathname === '/' ? '/index.html' : url.pathname),
)
if (!filename.startsWith(resolve(this.root) + sep)) return this.json(res, 403, {})
const info = await stat(filename)
if (!info.isFile()) return this.json(res, 404, {})
res.writeHead(200, {
'Content-Type': mime[extname(filename)] ?? 'application/octet-stream',
'Content-Length': info.size,
})
createReadStream(filename)
.on('error', () => res.destroy())
.pipe(res)
}
}
-51
View File
@@ -1,11 +1,9 @@
import { app, BrowserWindow, ipcMain, protocol, screen, session } from 'electron' import { app, BrowserWindow, ipcMain, protocol, screen, session } 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 { networkInterfaces } from 'node:os'
import { join } from 'node:path' import { join } from 'node:path'
import { Readable } from 'node:stream' import { Readable } from 'node:stream'
import { Controller } from './controller.js' import { Controller } from './controller.js'
import { LanServer } from './lan.js'
import { parseRange } from './range.js' import { parseRange } from './range.js'
import type { HostInfo } from '../src/types/screen.js' import type { HostInfo } from '../src/types/screen.js'
@@ -18,20 +16,12 @@ protocol.registerSchemesAsPrivileged([
]) ])
let control: BrowserWindow | null = null let control: BrowserWindow | null = null
let output: BrowserWindow | null = null let output: BrowserWindow | null = null
let lan: LanServer | null = null
let selectedDisplay: number | null = null let selectedDisplay: number | null = null
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)
lan?.broadcast(state)
}) })
const rendererRoot = () => join(app.getAppPath(), 'dist') const rendererRoot = () => join(app.getAppPath(), 'dist')
function addresses() {
return Object.values(networkInterfaces())
.flatMap((items) => items ?? [])
.filter((item) => !item.internal && item.family === 'IPv4')
.map((item) => ({ address: item.address, family: item.family }))
}
function host(): HostInfo { function host(): HostInfo {
const primary = screen.getPrimaryDisplay().id const primary = screen.getPrimaryDisplay().id
return { return {
@@ -42,12 +32,6 @@ function host(): HostInfo {
id: d.id, id: d.id,
label: `${d.label || 'Дисплей'} — ${d.size.width}×${d.size.height}`, label: `${d.label || 'Дисплей'} — ${d.size.width}×${d.size.height}`,
})), })),
lan: addresses(),
url: lan?.url ?? '',
code: lan?.code ?? '',
pending: [...(lan?.pending.entries() ?? [])]
.filter(([, p]) => !p.approved && p.expires > Date.now())
.map(([id]) => id),
} }
} }
function windowOptions() { function windowOptions() {
@@ -207,38 +191,6 @@ app
console.error('Ошибка отчёта проигрывателя', error) console.error('Ошибка отчёта проигрывателя', error)
} }
}) })
ipcMain.handle('lan', async (event, address: unknown) => {
requireControl(event)
if (
address !== null &&
(typeof address !== 'string' || !addresses().some((a) => a.address === address))
)
throw new Error('Некорректный сетевой интерфейс')
await lan?.close()
lan = null
if (address !== null) {
const server = new LanServer(
rendererRoot(),
() => controller.state,
(command) => {
if (!output) throw new Error('Окно показа закрыто')
controller.command(command, 'phone')
},
)
try {
await server.start(address)
lan = server
} catch (error) {
await server.close()
throw error
}
}
})
ipcMain.handle('approve', (event, id: unknown) => {
requireControl(event)
if (typeof id !== 'string' || !lan) throw new Error('Пульт отключён')
lan.approve(id)
})
screen.on('display-removed', (_event, display) => { screen.on('display-removed', (_event, display) => {
if (display.id === selectedDisplay) output?.close() if (display.id === selectedDisplay) output?.close()
}) })
@@ -252,6 +204,3 @@ app
app.quit() app.quit()
}) })
app.on('window-all-closed', () => app.quit()) app.on('window-all-closed', () => app.quit())
app.on('before-quit', () => {
void lan?.close()
})
-2
View File
@@ -12,6 +12,4 @@ contextBridge.exposeInMainWorld('screenBridge', {
}, },
host: () => ipcRenderer.invoke('host'), host: () => ipcRenderer.invoke('host'),
show: (id: number) => ipcRenderer.invoke('show', id), show: (id: number) => ipcRenderer.invoke('show', id),
lan: (address: string | null) => ipcRenderer.invoke('lan', address),
approve: (id: string) => ipcRenderer.invoke('approve', id),
}) })
+1 -34
View File
@@ -9,12 +9,10 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"naive-ui": "^2.44.0", "naive-ui": "^2.44.0",
"vue": "^3.5.0", "vue": "^3.5.0"
"ws": "^8.18.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
"@types/ws": "^8.18.0",
"@vitejs/plugin-vue": "^6.0.0", "@vitejs/plugin-vue": "^6.0.0",
"@vue/eslint-config-prettier": "^10.0.0", "@vue/eslint-config-prettier": "^10.0.0",
"@vue/eslint-config-typescript": "^14.0.0", "@vue/eslint-config-typescript": "^14.0.0",
@@ -1453,16 +1451,6 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/ws": {
"version": "8.18.2",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz",
"integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.71.2", "version": "8.71.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.2.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.2.tgz",
@@ -7713,27 +7701,6 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/ws": {
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
"integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xml-name-validator": { "node_modules/xml-name-validator": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+5 -6
View File
@@ -10,24 +10,23 @@
"type-check": "vue-tsc --noEmit && tsc -p tsconfig.electron.json --noEmit", "type-check": "vue-tsc --noEmit && tsc -p tsconfig.electron.json --noEmit",
"lint": "eslint .", "lint": "eslint .",
"lint:css": "stylelint \"src/**/*.{css,vue}\"", "lint:css": "stylelint \"src/**/*.{css,vue}\"",
"build": "vite build && tsc -p tsconfig.electron.json", "build": "vite build && npm run build:electron",
"test": "tsc -p tsconfig.electron.json && node --test dist-electron/electron/*.test.js", "test": "npm run build:electron && node --test dist-electron/electron/*.test.js",
"media:generate": "node scripts/generate-media.cjs", "media:generate": "node scripts/generate-media.cjs",
"pack": "npm run build && electron-builder --dir", "pack": "npm run build && electron-builder --dir",
"dist": "npm run build && electron-builder", "dist": "npm run build && electron-builder",
"smoke": "npm run build && electron scripts/smoke.cjs" "smoke": "npm run build && electron scripts/smoke.cjs",
"build:electron": "node -e \"require('node:fs').rmSync('dist-electron', { recursive: true, force: true })\" && tsc -p tsconfig.electron.json"
}, },
"dependencies": { "dependencies": {
"vue": "^3.5.0", "vue": "^3.5.0",
"naive-ui": "^2.44.0", "naive-ui": "^2.44.0"
"ws": "^8.18.0"
}, },
"devDependencies": { "devDependencies": {
"electron": "^42.0.0", "electron": "^42.0.0",
"electron-builder": "^26.0.0", "electron-builder": "^26.0.0",
"typescript": "^6.0.0", "typescript": "^6.0.0",
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
"@types/ws": "^8.18.0",
"@vitejs/plugin-vue": "^6.0.0", "@vitejs/plugin-vue": "^6.0.0",
"vite": "^8.0.0", "vite": "^8.0.0",
"vue-tsc": "^3.3.0", "vue-tsc": "^3.3.0",
+14 -52
View File
@@ -78,6 +78,14 @@ app.whenReady().then(async () => {
return s.status !== 'pending' && s.command.id === id && s return s.status !== 'pending' && s.command.id === id && s
}, id) }, id)
} }
await evaluate(
`Array.from(document.querySelectorAll('button')).find(b => b.textContent.includes('Пуск с начала')).click()`,
)
await poll(async () => {
const state = await evaluate('window.screenBridge.state()')
if (state.status === 'error') throw new Error(state.error)
return state.status === 'playing'
}, 'computer UI play')
assert.equal((await command('audio-play', 'play')).status, 'playing') assert.equal((await command('audio-play', 'play')).status, 'playing')
assert.equal((await command('audio-pause', 'pause')).status, 'paused') assert.equal((await command('audio-pause', 'pause')).status, 'paused')
assert.equal((await command('audio-stop', 'stop')).status, 'stopped') assert.equal((await command('audio-stop', 'stop')).status, 'stopped')
@@ -101,59 +109,13 @@ app.whenReady().then(async () => {
`window.screenBridge.command({id:'forbidden', action:'play', media:'audio', position:0})`, `window.screenBridge.command({id:'forbidden', action:'play', media:'audio', position:0})`,
), ),
) )
const network = (await evaluate('window.screenBridge.host()')).lan[0] assert.equal(
assert.ok(network, 'LAN interface available for browser smoke') await evaluate("document.body.textContent.includes('Телефон по локальной сети')"),
await evaluate(`window.screenBridge.lan(${JSON.stringify(network.address)})`) false,
const host = await evaluate('window.screenBridge.host()')
const phone = new BrowserWindow({
show: false,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
backgroundThrottling: false,
partition: 'screen-smoke-phone',
},
})
assert.equal((await fetch(host.url)).status, 200)
await phone.webContents.session.setProxy({ mode: 'direct' })
await phone.loadURL(host.url)
const phoneEval = (s) => phone.webContents.executeJavaScript(s)
await poll(async () => await phoneEval('!!document.querySelector("input")'), 'phone UI')
assert.equal(await phoneEval('typeof window.screenBridge'), 'undefined')
await phoneEval(
`(() => {const input = document.querySelector('input'); input.value = ${JSON.stringify(host.code)}; input.dispatchEvent(new Event('input', {bubbles:true}));})()`,
) )
await delay(100) assert.deepEqual(Object.keys(await evaluate('window.screenBridge.host()')), ['displays'])
await phoneEval( assert.equal(await evaluate('typeof window.screenBridge.lan'), 'undefined')
`Array.from(document.querySelectorAll('button')).find(b => b.textContent.includes('Подключиться')).click()`, assert.equal(await evaluate('typeof window.screenBridge.approve'), 'undefined')
)
const pending = await poll(
async () => (await evaluate('window.screenBridge.host()')).pending[0],
'pair request from phone UI',
)
await evaluate(`window.screenBridge.approve(${JSON.stringify(pending)})`)
await poll(
async () =>
await phoneEval(
`!Array.from(document.querySelectorAll('button')).find(b => b.textContent.includes('Пуск с начала')).disabled`,
),
'phone connected',
)
await phoneEval(
`Array.from(document.querySelectorAll('button')).find(b => b.textContent.includes('Пуск с начала')).click()`,
)
await poll(async () => {
const state = await evaluate('window.screenBridge.state()')
return state.status === 'playing' && state.samples.some((s) => s.origin === 'phone')
}, 'phone play')
await command('computer-after-phone', 'pause')
await evaluate('window.screenBridge.lan(null)')
await poll(
async () => await phoneEval(`document.body.textContent.includes('Нет связи')`),
'phone revoked',
)
phone.close()
output.close() output.close()
await evaluate('window.screenBridge.show(424242)') await evaluate('window.screenBridge.show(424242)')
const reopened = await poll( const reopened = await poll(
+22 -160
View File
@@ -1,42 +1,25 @@
<script setup lang="ts"> <script setup lang="ts">
import { NAlert, NButton, NCard, NInput, NInputNumber, NSelect, NSpace, useMessage } from 'naive-ui' import { NAlert, NButton, NCard, NInputNumber, NSelect, NSpace, useMessage } from 'naive-ui'
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import type { Action, HostInfo, MediaKind, State } from '../types/screen' import type { Action, HostInfo, MediaKind, State } from '../types/screen'
const message = useMessage() const message = useMessage()
const bridge = window.screenBridge const bridge = window.screenBridge
const isPhone = !bridge
const state = ref<State | null>(null) const state = ref<State | null>(null)
const host = ref<HostInfo>({ displays: [], lan: [], url: '', code: '', pending: [] }) const host = ref<HostInfo>({ displays: [] })
const display = ref<number | null>(null) const display = ref<number | null>(null)
const address = ref<string | null>(null)
const media = ref<MediaKind>('audio') const media = ref<MediaKind>('audio')
const position = ref<number | null>(10) const position = ref<number | null>(10)
const code = ref('')
const pairingId = ref('')
const isConnected = ref(false)
const isBusy = ref(false) const isBusy = ref(false)
const isApproved = ref(false)
const pairLabel = computed(() =>
pairingId.value
? `Ожидание подтверждения ${pairingId.value.slice(0, 8)} на компьютере`
: 'Подключиться',
)
const metrics = computed(() => { const metrics = computed(() => {
return (['computer', 'phone'] as const)
.map((origin) => {
const values = (state.value?.samples ?? []) const values = (state.value?.samples ?? [])
.filter((sample) => sample.media === media.value && sample.origin === origin) .filter((sample) => sample.media === media.value)
.map((sample) => sample.milliseconds) .map((sample) => sample.milliseconds)
.sort((a, b) => a - b) .sort((a, b) => a - b)
const label = origin === 'computer' ? 'Компьютер' : 'Телефон' if (!values.length) return 'Замеров пока нет'
if (!values.length) return `${label}: замеров пока нет`
const middle = Math.floor(values.length / 2) const middle = Math.floor(values.length / 2)
const median = const median = values.length % 2 ? values[middle]! : (values[middle - 1]! + values[middle]!) / 2
values.length % 2 ? values[middle]! : (values[middle - 1]! + values[middle]!) / 2 return `n=${values.length}, медиана ${median.toFixed(1)} мс, p95 ${values[Math.ceil(values.length * 0.95) - 1]!.toFixed(1)} мс`
return `${label}: n=${values.length}, медиана ${median.toFixed(1)} мс, p95 ${values[Math.ceil(values.length * 0.95) - 1]!.toFixed(1)} мс`
})
.join(' · ')
}) })
const statusLabels: Record<State['status'], string> = { const statusLabels: Record<State['status'], string> = {
idle: 'Ожидание', idle: 'Ожидание',
@@ -47,10 +30,8 @@ const statusLabels: Record<State['status'], string> = {
ended: 'Завершено', ended: 'Завершено',
error: 'Ошибка', error: 'Ошибка',
} }
let socket: WebSocket | null = null
let timer: ReturnType<typeof setInterval> | undefined let timer: ReturnType<typeof setInterval> | undefined
let unsubscribe: (() => void) | undefined let unsubscribe: (() => void) | undefined
let isDisposed = false
async function run(action: () => Promise<unknown>) { async function run(action: () => Promise<unknown>) {
isBusy.value = true isBusy.value = true
@@ -69,83 +50,28 @@ async function refreshHost() {
if (bridge) host.value = await bridge.host() if (bridge) host.value = await bridge.host()
} }
async function send(action: Action) { async function send(action: Action) {
const command = { id: cryptoId(), action, media: media.value, position: position.value ?? 0 }
await run(async () => { await run(async () => {
if (bridge) await bridge.command(command) if (!bridge) throw new Error('Откройте пульт в приложении Electron')
else { await bridge.command({
if (!socket || socket.readyState !== WebSocket.OPEN) id: crypto.randomUUID(),
throw new Error('Связь с компьютером потеряна') action,
socket.send(JSON.stringify(command)) media: media.value,
} position: position.value ?? 0,
}) })
}
// HTTP в локальной сети не предоставляет crypto.randomUUID во всех браузерах.
function cryptoId() {
return Array.from(crypto.getRandomValues(new Uint8Array(16)), (v) =>
v.toString(16).padStart(2, '0'),
).join('')
}
function connect() {
if (isDisposed || (socket && socket.readyState !== WebSocket.CLOSED)) return
socket = new WebSocket(`ws://${location.host}/ws`)
socket.onmessage = (event) => {
acceptState(JSON.parse(event.data) as State)
isConnected.value = true
isApproved.value = true
}
socket.onclose = () => {
isConnected.value = false
}
socket.onerror = () => {
isConnected.value = false
}
}
async function pair() {
await run(async () => {
const response = await fetch('/pair', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: code.value }),
}) })
if (!response.ok)
throw new Error('Код неверен или превышен лимит попыток. Повторите через минуту.')
pairingId.value = ((await response.json()) as { id: string }).id
code.value = ''
})
}
async function tick() {
if (bridge) {
await refreshHost()
return
}
if (pairingId.value) {
const response = await fetch(`/pair/${pairingId.value}`)
if (response.status === 200) {
pairingId.value = ''
isApproved.value = true
socket?.close()
socket = null
connect()
} else if (response.status !== 202) {
pairingId.value = ''
throw new Error('Запрос подключения истёк; введите код заново')
}
} else if (isApproved.value && !isConnected.value) connect()
} }
onMounted(async () => { onMounted(async () => {
await run(async () => { await run(async () => {
if (bridge) { if (!bridge) throw new Error('Откройте пульт в приложении Electron')
unsubscribe = bridge.subscribe(acceptState) unsubscribe = bridge.subscribe(acceptState)
acceptState(await bridge.state()) acceptState(await bridge.state())
isConnected.value = true
await refreshHost() await refreshHost()
} else connect()
}) })
let isTicking = false let isTicking = false
timer = setInterval(() => { timer = setInterval(() => {
if (isTicking) return if (isTicking) return
isTicking = true isTicking = true
void tick() void refreshHost()
.catch((error: unknown) => { .catch((error: unknown) => {
message.error(error instanceof Error ? error.message : 'Ошибка связи') message.error(error instanceof Error ? error.message : 'Ошибка связи')
}) })
@@ -155,10 +81,8 @@ onMounted(async () => {
}, 1000) }, 1000)
}) })
onUnmounted(() => { onUnmounted(() => {
isDisposed = true
clearInterval(timer) clearInterval(timer)
unsubscribe?.() unsubscribe?.()
socket?.close()
}) })
</script> </script>
@@ -166,7 +90,7 @@ onUnmounted(() => {
<main class="control"> <main class="control">
<h1>Вечерний детектив · SCREEN-001</h1> <h1>Вечерний детектив · SCREEN-001</h1>
<n-alert type="info" <n-alert type="info"
>Технический стенд: синтетические медиа, два окна и локальная связь.</n-alert >Технический стенд: синтетические медиа, два окна и локальное воспроизведение.</n-alert
> >
<n-card v-if="bridge" title="Дисплей показа"> <n-card v-if="bridge" title="Дисплей показа">
<n-space vertical> <n-space vertical>
@@ -184,69 +108,9 @@ onUnmounted(() => {
> >
</n-space> </n-space>
</n-card> </n-card>
<n-card v-if="bridge" title="Телефон по локальной сети">
<n-space vertical>
<n-select
v-model:value="address"
:options="host.lan.map((a) => ({ label: a.address, value: a.address }))"
placeholder="Адрес доверенной сети / точки доступа"
:input-props="{ 'aria-label': 'Сетевой интерфейс' }"
/>
<n-space>
<n-button
:disabled="!address || isBusy"
@click="
run(async () => {
await bridge!.lan(address)
await refreshHost()
})
"
>Включить / сбросить подключения</n-button
>
<n-button
:disabled="!host.url || isBusy"
@click="
run(async () => {
await bridge!.lan(null)
await refreshHost()
})
"
>Отключить и отозвать доступ</n-button
>
</n-space>
<p v-if="host.url">На телефоне откройте {{ host.url }} · код {{ host.code }}</p>
<p>Только доверенная сеть. HTTP не шифрует команды. Не публикуйте порт в интернет.</p>
<n-button
v-for="id in host.pending"
:key="id"
@click="
run(async () => {
await bridge!.approve(id)
await refreshHost()
})
"
>Подтвердить запрос {{ id.slice(0, 8) }}</n-button
>
</n-space>
</n-card>
<n-card v-if="isPhone && !isConnected" title="Подключение к компьютеру">
<n-space vertical>
<n-input
v-model:value="code"
placeholder="Код с компьютера"
:input-props="{ 'aria-label': 'Код подключения' }"
:maxlength="6"
/>
<n-button :disabled="isBusy || !!pairingId || code.length !== 6" @click="pair">{{
pairLabel
}}</n-button>
</n-space>
</n-card>
<n-card title="Локальное воспроизведение"> <n-card title="Локальное воспроизведение">
<n-space vertical> <n-space vertical>
<n-alert v-if="!isConnected" type="warning" <n-alert v-if="!bridge" type="warning">Откройте пульт в приложении Electron.</n-alert>
>Нет связи. Показ на компьютере продолжается; команды недоступны.</n-alert
>
<n-select <n-select
v-model:value="media" v-model:value="media"
:options="[ :options="[
@@ -256,11 +120,9 @@ onUnmounted(() => {
:input-props="{ 'aria-label': 'Тестовое медиа' }" :input-props="{ 'aria-label': 'Тестовое медиа' }"
/> />
<n-space> <n-space>
<n-button :disabled="!isConnected || isBusy" @click="send('play')" <n-button :disabled="!bridge || isBusy" @click="send('play')">Пуск с начала</n-button>
>Пуск с начала</n-button <n-button :disabled="!bridge || isBusy" @click="send('pause')">Пауза</n-button>
> <n-button :disabled="!bridge || isBusy" @click="send('stop')">Стоп</n-button>
<n-button :disabled="!isConnected || isBusy" @click="send('pause')">Пауза</n-button>
<n-button :disabled="!isConnected || isBusy" @click="send('stop')">Стоп</n-button>
</n-space> </n-space>
<label for="position">Позиция, секунды</label> <label for="position">Позиция, секунды</label>
<n-input-number <n-input-number
@@ -269,7 +131,7 @@ onUnmounted(() => {
:min="0" :min="0"
:max="60" :max="60"
/> />
<n-button :disabled="!isConnected || isBusy" @click="send('seek')">Перемотать</n-button> <n-button :disabled="!bridge || isBusy" @click="send('seek')">Перемотать</n-button>
<p role="status"> <p role="status">
{{ state ? statusLabels[state.status] : 'Нет состояния' }} · ревизия {{ state ? statusLabels[state.status] : 'Нет состояния' }} · ревизия
{{ state?.revision ?? 0 }} {{ state?.revision ?? 0 }}
@@ -277,8 +139,8 @@ onUnmounted(() => {
<n-alert v-if="state?.error" type="error">{{ state.error }}</n-alert> <n-alert v-if="state?.error" type="error">{{ state.error }}</n-alert>
<p>{{ metrics }}</p> <p>{{ metrics }}</p>
<p> <p>
Замеры: приём команды контроллером → подтверждение проигрывателя. Они не измеряют сеть Замеры: приём команды контроллером → подтверждение проигрывателя. Они не измеряют
телефона, физический звук или первый кадр проектора. физический звук или первый кадр проектора.
</p> </p>
</n-space> </n-space>
</n-card> </n-card>
+1 -7
View File
@@ -11,7 +11,7 @@ export interface State {
command: Command | null command: Command | null
status: 'idle' | 'pending' | 'playing' | 'paused' | 'stopped' | 'ended' | 'error' status: 'idle' | 'pending' | 'playing' | 'paused' | 'stopped' | 'ended' | 'error'
error: string error: string
samples: { media: MediaKind; origin: 'computer' | 'phone'; milliseconds: number }[] samples: { media: MediaKind; milliseconds: number }[]
} }
export interface DisplayInfo { export interface DisplayInfo {
id: number id: number
@@ -19,10 +19,6 @@ export interface DisplayInfo {
} }
export interface HostInfo { export interface HostInfo {
displays: DisplayInfo[] displays: DisplayInfo[]
lan: { address: string; family: string }[]
url: string
code: string
pending: string[]
} }
export interface ScreenBridge { export interface ScreenBridge {
command: (command: Command) => Promise<void> command: (command: Command) => Promise<void>
@@ -31,6 +27,4 @@ export interface ScreenBridge {
subscribe: (listener: (state: State) => void) => () => void subscribe: (listener: (state: State) => void) => () => void
host: () => Promise<HostInfo> host: () => Promise<HostInfo>
show: (displayId: number) => Promise<void> show: (displayId: number) => Promise<void>
lan: (address: string | null) => Promise<void>
approve: (id: string) => Promise<void>
} }