up
This commit is contained in:
@@ -7,7 +7,8 @@
|
||||
внешнем дисплее. Аудио, видео, изображения и выбранные музыкальные плейлисты
|
||||
читаются с диска. Пульт переключает показ и управляет воспроизведением локально.
|
||||
После подготовки потеря интернета не мешает локальному показу и таймеру.
|
||||
Полный пульт телефона работает в доверенной локальной сети одновременно с пультом компьютера.
|
||||
Телефон управляет через существующий сервер сайта; приложение экрана получает с сервера
|
||||
обновления состояния. При потере интернета доступен локальный пульт компьютера.
|
||||
|
||||
«Все материалы» в первой версии — все ресурсы, необходимые окну показа:
|
||||
вступление, закрывающее видео, обложка, фон, материалы шагов плана, выбранные
|
||||
@@ -71,23 +72,20 @@ Linux проверяется на обоих указанных дистрибу
|
||||
5. Сервер проверяет право организатора на скачивание материалов. Существующий
|
||||
API используется где достаточно; манифест добавляется минимальным расширением.
|
||||
Токены не записываются в пакет игры и логи.
|
||||
6. После подготовки основной процесс — единственный источник состояния локального показа.
|
||||
Команды пульта компьютера через IPC и телефона через WebSocket обрабатываются
|
||||
последовательно; состояние с возрастающей ревизией рассылается обоим пультам.
|
||||
Серверная синхронизация не задерживает команды. Команды сайта не управляют
|
||||
desktop-показом; возврат интернета не повторяет старые серверные команды.
|
||||
7. Приложение по явному действию оператора раздаёт мобильный Vue-пульт со встроенного
|
||||
HTTP-сервера на выбранном IPv4-интерфейсе. Код сопряжения, подтверждение на
|
||||
компьютере и временная HttpOnly-cookie ограничивают доступ. Сеансы только в памяти;
|
||||
отключение пульта отзывает их. Рабочая сеть — своя Wi-Fi/LAN или точка доступа:
|
||||
HTTP не шифрует трафик. Сертификаты и управление через интернет не входят в MVP.
|
||||
В сетевом API нет произвольного доступа к диску, IPC и токенам сайта.
|
||||
8. Телефон управляет всеми режимами показа, шагами, таймером, музыкой, громкостью
|
||||
и перемоткой. Подготовка игры и дисплея остаётся на компьютере. Команды имеют
|
||||
идентификаторы для дедупликации; после потери связи управление блокируется,
|
||||
показ продолжается. Переподключение получает снимок состояния без повтора команд.
|
||||
Vue-компоненты и типы пультов общие внутри screen, различается транспорт.
|
||||
Общий пакет с сайтом заранее не выделяется.
|
||||
6. После подготовки основной процесс применяет локальные команды пульта компьютера
|
||||
через IPC без ожидания сервера. Материалы воспроизводятся с диска.
|
||||
7. Телефон использует существующий пульт на сайте и отправляет команды серверу.
|
||||
Приложение экрана подключается к серверу как экранная сессия и получает обновления
|
||||
через существующий API CreateScreenSession/GetScreenState. Текущий браузерный
|
||||
экран опрашивает состояние раз в 1000 мс; desktop использует тот же серверный поток.
|
||||
HTTP-сервер, WebSocket-сервер, сопряжение и отдельный мобильный пульт в desktop не нужны.
|
||||
8. Подключение игры, авторизация и получение серверных обновлений реализуются в SCREEN-006.
|
||||
Серверные команды проходят через локальный контроллер и используют подготовленные
|
||||
файлы. Одну версию команды нельзя применять повторно. При потере сети телефон
|
||||
не управляет desktop; уже запущенное воспроизведение и локальный пульт продолжают
|
||||
работать. При восстановлении сети необходимо сверить версии и не повторять
|
||||
устаревшую команду поверх локальных действий; правила согласования проверяются
|
||||
в SCREEN-006 на существующих ScreenState/ScreenCommand.
|
||||
9. Текущие результаты игры загружаются онлайн с отметкой времени; офлайн доступны
|
||||
только последние сохранённые данные с явным предупреждением оператору.
|
||||
Серверная игра, ответы команд и вычисление новых результатов офлайн не работают.
|
||||
@@ -113,15 +111,14 @@ Linux проверяется на обоих указанных дистрибу
|
||||
## Порядок работ
|
||||
|
||||
1. SCREEN-001: проверить Electron на всех шести вариантах, два дисплея, локальное
|
||||
медиа и задержки, техническое подключение телефона по LAN; порог задержки — на M1.
|
||||
медиа и задержки; порог задержки — на M1.
|
||||
2. SCREEN-002: получить полный и защищённый пакет материалов с сервера.
|
||||
3. SCREEN-003: реализовать надёжную загрузку и локальное хранение пакетов.
|
||||
4. SCREEN-004: реализовать локальный проигрыватель и команды, подготовку медиа.
|
||||
5. SCREEN-005: собрать пульт, план показа и режимы экрана.
|
||||
6. SCREEN-009: полный пульт телефона, сопряжение, общее состояние и восстановление связи.
|
||||
7. SCREEN-006: добавить подключение игры и согласование с сервером.
|
||||
8. SCREEN-007: обработать отключение дисплея, звук, сон и восстановление.
|
||||
9. SCREEN-008: собрать артефакты для всех шести вариантов и провести приёмку на площадке.
|
||||
6. SCREEN-006: добавить подключение игры, обновления экрана с сервера и управление с телефона через сайт.
|
||||
7. SCREEN-007: обработать отключение дисплея, звук, сон и восстановление.
|
||||
8. SCREEN-008: собрать артефакты для всех шести вариантов и провести приёмку на площадке.
|
||||
|
||||
После SCREEN-001 — решение о продолжении по результатам всей матрицы платформ.
|
||||
Разработка на одной ОС не заменяет аппаратную проверку остальных ОС и архитектур,
|
||||
@@ -139,7 +136,7 @@ Linux проверяется на обоих указанных дистрибу
|
||||
- В SCREEN-001 для каждой тестовой конфигурации зафиксировать ОС, архитектуру,
|
||||
CPU/RAM, проектор/переходник, аудиовыход, форматы, разрешение и размер медиа.
|
||||
- Полное управление игрой, локальный Go-сервер/БД, редактирование сценария,
|
||||
автообновления и управление desktop через интернет — за пределами MVP.
|
||||
автообновления и прямое управление desktop с телефона по LAN — за пределами MVP.
|
||||
- Подпись и notarization зависят от наличия Apple Developer; без них не обещать
|
||||
установку без предупреждений Gatekeeper. Для Windows отдельно зафиксировать наличие сертификата подписи и
|
||||
возможные предупреждения установки. Учётные данные не хранить в репозитории.
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# Вечерний детектив — приложение площадки
|
||||
|
||||
Статус: SCREEN-001 в работе; технический стенд реализован, аппаратная приёмка открыта.
|
||||
Архитектура пересмотрена 17.10.2026: добавлен пульт телефона по доверенной LAN.
|
||||
Управление с телефона идёт через существующий сервер сайта; desktop получает
|
||||
обновления с сервера. Прямое LAN-подключение не используется.
|
||||
|
||||
Цель: ведущий управляет показом на компьютере с macOS, Windows или Linux, а подключённый монитор или
|
||||
проектор показывает материалы игрокам. Все материалы показа заранее находятся
|
||||
@@ -40,11 +41,9 @@ npm run dev
|
||||
Стенд поддерживает пуск с начала, паузу, остановку и перемотку. Полные игровые
|
||||
режимы, загрузка игр и авторизация пока не реализованы.
|
||||
|
||||
Для телефона выберите IPv4-адрес доверенной сети и включите LAN-пульт. Откройте
|
||||
показанный адрес в браузере телефона, введите код и подтвердите запрос с тем же
|
||||
коротким идентификатором на компьютере. Отключение LAN отзывает все подключения.
|
||||
Если устройства не видят друг друга, проверьте firewall и изоляцию клиентов Wi-Fi;
|
||||
локальный пульт компьютера продолжает работать.
|
||||
Управление телефоном через сервер, подключение игры и авторизация относятся к
|
||||
SCREEN-006 и в техническом стенде ещё не реализованы. Интернет нужен для получения
|
||||
команд телефона; локальное воспроизведение и пульт компьютера работают без него.
|
||||
|
||||
Проверки: npm run type-check, npm run lint, npm run lint:css, npm test, npm run build.
|
||||
[Сборки и аппаратная приёмка](docs/SCREEN-001_ACCEPTANCE.md).
|
||||
|
||||
@@ -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-* в корневом списке.
|
||||
Обновлять статус и результат здесь; завершённые записи переносить в
|
||||
`COMPLETED_TASKS.md` в этом каталоге после проверки. Коммиты и push не выполнять.
|
||||
@@ -14,8 +16,7 @@ SCREEN-001 имеет статус IN_PROGRESS; остальные задачи
|
||||
матрицу; Linux проверяется на Ubuntu 24.04 LTS и Debian 13.
|
||||
- Объём: минимальный Electron + Vue + TypeScript + Vite; пульт и отдельное окно
|
||||
показа; выбор дисплея; синтетические локальные аудио/видео; IPC start/pause/stop
|
||||
и seek; безопасный preload и локальная выдача медиа с Range; техническое
|
||||
подключение браузера телефона по LAN с сопряжением и отправкой команды. Подготовить сборки
|
||||
и seek; безопасный preload и локальная выдача медиа с Range. Подготовить сборки
|
||||
для всех шести вариантов, инструкцию получения тестовых медиа и аппаратной
|
||||
проверки. Бинарные тестовые файлы не коммитить.
|
||||
- Готовность: запуск на всех шести вариантах; пульт остаётся на основном экране,
|
||||
@@ -28,36 +29,28 @@ SCREEN-001 имеет статус IN_PROGRESS; остальные задачи
|
||||
звук/кадр и отключённая сеть. Проверить кабель/USB и HDMI. Владелец выполняет
|
||||
аппаратные проверки по инструкции агента и передаёт результаты.
|
||||
- Не входит: сервер, авторизация, скачивание, полный пульт, публикация.
|
||||
- Результат программного этапа (17.10.2026): Electron + Vue/TypeScript/Vite,
|
||||
два изолированных окна, выбор внешнего дисплея, локальный протокол с Range,
|
||||
синтетические WAV/H.264+AAC, start/pause/stop/seek, подтверждённые статусы и
|
||||
отдельные замеры аудио/видео и компьютера/телефона. LAN включается явно:
|
||||
код + подтверждение на компьютере, HttpOnly-сеанс, отзыв при отключении.
|
||||
Полные игровые функции и сервер не переносились.
|
||||
Подготовлены четыре финальных архива Linux/Windows x64/ARM64 в release/ и
|
||||
SHA256SUMS; содержимое app.asar всех четырёх сверено с проверенным кодом,
|
||||
медиа присутствуют, тестовые модули исключены из поставки. Linux x64-бинарник
|
||||
отдельно запускается из упакованного каталога; UI/preload и Range — PASS.
|
||||
macOS x64/ARM64 не собраны здесь: нужны Mac или подготовленный ручной CI workflow.
|
||||
- Проверка: frontend-стандарт прочитан; ручное ревью нового кода выполнено,
|
||||
используются Naive UI, типизированный IPC, scoped CSS и палитра проекта.
|
||||
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. В обычном запуске этих флагов нет.
|
||||
- Результат программного этапа: Electron + Vue/TypeScript/Vite, два изолированных
|
||||
окна, выбор внешнего дисплея, локальный протокол с Range, синтетические
|
||||
WAV/H.264+AAC, start/pause/stop/seek, подтверждённые статусы и замеры аудио/видео.
|
||||
По уточнению владельца LAN-сервер, сопряжение, мобильный UI и зависимости ws
|
||||
удалены. Телефон управляет через сервер сайта; desktop получает обновления
|
||||
в SCREEN-006. Полные игровые функции и сервер пока не переносились.
|
||||
- Проверка после уточнения: type-check, lint, lint:css, build, 4 теста и GUI smoke —
|
||||
PASS. В smoke проверено отсутствие LAN-панели и методов lan/approve в preload,
|
||||
локальные play/pause/stop/seek, Range и открытие окна без автоповтора.
|
||||
Frontend-стандарт прочитан; ручное ревью diff выполнено, нарушений не найдено.
|
||||
- Сборки: Linux/Windows x64/ARM64 пересобраны без LAN; содержимое всех четырёх
|
||||
app.asar сверено с проверенным кодом. LAN-модули, ws и тесты отсутствуют в поставке;
|
||||
release/SHA256SUMS обновлён. macOS нужно собирать на Mac или ручным workflow.
|
||||
- Среда smoke: Manjaro Linux x86_64, Intel Core Ultra 7 255H, около 32 ГБ RAM;
|
||||
Node 26.8.1, Electron 42.11.15. Программные единичные замеры не считаются
|
||||
аппаратной приёмкой или подтверждением p95 ≤150 мс.
|
||||
- Ограничения: npm audit --omit=dev — 0 уязвимостей; полный audit — 20 в цепочках
|
||||
инструментов разработки (8 moderate, 12 high). Дедупликация стенда хранит последние
|
||||
4096 команд; долговременная семантика — SCREEN-004. HTTP только в доверенной сети.
|
||||
Node 26.8.1, Electron 42.11.15. Единичные программные замеры не подтверждают
|
||||
аппаратную приёмку или физический p95 ≤150 мс. Headless зависал/падал; GUI smoke
|
||||
проходил с тестовыми --no-sandbox --mute-audio --disable-gpu.
|
||||
- Ограничения: дедупликация стенда хранит последние 4096 команд; долговременная
|
||||
семантика — SCREEN-004. Привязка игры и серверное управление — SCREEN-006.
|
||||
- Следующий шаг: получить аппаратные результаты владельца по
|
||||
docs/SCREEN-001_ACCEPTANCE.md на всех шести вариантах (Linux — на обоих
|
||||
дистрибутивах), включая реальные iPhone/Safari и Android/Chrome, проектор,
|
||||
дистрибутивах), включая проектор,
|
||||
физический звук/кадр и 30 запусков для каждого формата/аудиовыхода. macOS-сборки
|
||||
подготовить на Mac или ручным workflow builds.yml. До результатов всей матрицы
|
||||
задача остаётся IN_PROGRESS, SCREEN-002 не начинается.
|
||||
@@ -139,22 +132,27 @@ SCREEN-001 имеет статус IN_PROGRESS; остальные задачи
|
||||
- Цель: выбирать доступную организатору игру онлайн и сохранять автономность показа.
|
||||
- Объём: существующая авторизация, список игр, загрузка пакета, снимок результатов,
|
||||
индикатор связи, проверка новой версии материалов, синхронизация статуса показа.
|
||||
Телефон отправляет команды существующему серверу сайта; desktop получает
|
||||
обновления через экранную сессию и GetScreenState (существующий polling 1000 мс).
|
||||
Серверные команды применяются локальным контроллером к подготовленным файлам;
|
||||
повторная версия не применяется дважды. Прямого подключения телефона к desktop нет.
|
||||
Уточнить минимальный серверный контракт по фактической реализации.
|
||||
- Готовность: готовая игра открывается после перезапуска офлайн; секреты хранятся
|
||||
средствами ОС, не в пакете; истечение сессии не обрывает уже подготовленный
|
||||
локальный показ; обновления активируются явно. Результаты имеют время снимка.
|
||||
Возврат сети не повторяет старые команды; серверные команды не перехватывают desktop-показ.
|
||||
Возврат сети не повторяет устаревшие команды поверх локальных действий.
|
||||
При доступном сервере телефон управляет показом через существующий сайт; без сети
|
||||
воспроизведение и пульт компьютера продолжают работать.
|
||||
- Проверка: сценарии offline/reconnect/истёкшей сессии/обновления игры; проверки
|
||||
затронутых репозиториев; существующий браузерный экран продолжает работать.
|
||||
- Не входит: полноценная офлайн-игра и управление desktop через интернет.
|
||||
Телефон по LAN реализуется в SCREEN-009; сайт не перехватывает локальное управление.
|
||||
- Не входит: полноценная офлайн-игра, LAN-сервер и отдельный мобильный пульт desktop.
|
||||
- Следующий шаг: сверить auth flow и модель сессий; зафиксировать правила
|
||||
локального владения показом и отображения статуса на сервере.
|
||||
|
||||
## SCREEN-007 — Устойчивость на площадке
|
||||
|
||||
- Статус: `TODO`
|
||||
- Зависимости: SCREEN-005, SCREEN-006, SCREEN-009.
|
||||
- Зависимости: SCREEN-005, SCREEN-006.
|
||||
- Цель: переживать типовые сбои без потери материалов и случайного показа пульта.
|
||||
- Объём: отключение/возврат дисплея, смена разрешения и 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 подключить полный интерфейс к локальному
|
||||
контроллеру через общий транспортный контракт и пройти мобильную приёмку.
|
||||
|
||||
@@ -41,24 +41,21 @@ $env:ELECTRON_BUILDER_COMPRESSION_LEVEL='1'). Она меняет степень
|
||||
|
||||
Имена архивов включают ОС и архитектуру; release/ и тестовые медиа игнорируются Git.
|
||||
|
||||
## Результат программного этапа 17.10.2026
|
||||
## Актуальность сборок
|
||||
|
||||
Собраны evening-detective-screen-0.1.0-linux-{x64,arm64}.tar.gz и
|
||||
Windows-архивы evening-detective-screen-0.1.0-win-{x64,arm64}.zip в release/.
|
||||
Контрольные суммы — release/SHA256SUMS. Содержимое app.asar всех четырёх вариантов
|
||||
сверено с проверенным кодом; оба медиафайла присутствуют, тестовые модули исключены.
|
||||
Готовый Linux x64-бинарник проверен на Manjaro: запуск UI/preload и Range — PASS.
|
||||
Сборки macOS не выполнялись, workflow не запускался. Совместимость с целевыми ОС,
|
||||
внешним оборудованием и мобильными браузерами остаётся открытой.
|
||||
После удаления LAN-пульта архивы Linux/Windows x64/ARM64 пересобраны из текущего
|
||||
кода. Содержимое app.asar сверено: LAN-модулей, ws и тестов в поставке нет.
|
||||
Контрольные суммы обновлены в release/SHA256SUMS. Type-check, lint, lint:css,
|
||||
build, 4 теста и GUI smoke — PASS, включая запуск кнопкой локального пульта.
|
||||
Сборки macOS и workflow пока не выполнялись; аппаратная приёмка остаётся открытой.
|
||||
|
||||
## Автоматический smoke
|
||||
|
||||
После генерации медиа выполните npm run smoke в графической сессии. Проверка
|
||||
использует синтетический второй дисплей и отдельное браузерное окно вместо телефона:
|
||||
IPC, Range, запуск/пауза/стоп/seek, запрет IPC из окна показа, сопряжение через UI,
|
||||
команды обоих пультов, отзыв доступа и отсутствие автоповтора при открытии окна.
|
||||
Для тестового браузера используется прямое LAN-соединение без системного прокси.
|
||||
Это не проверка реального проектора или мобильного браузера.
|
||||
использует синтетический второй дисплей: IPC, Range, запуск/пауза/стоп/seek,
|
||||
запрет IPC из окна показа и отсутствие автоповтора при открытии окна. Дополнительно
|
||||
проверяется отсутствие LAN-методов в preload и сетевых параметров в данных пульта.
|
||||
Это не проверка реального проектора. Серверное управление проверяется в SCREEN-006.
|
||||
|
||||
В ограниченной среде агента запуск выполнен с тестовыми флагами --no-sandbox
|
||||
--mute-audio --disable-gpu. В обычном запуске npm run dev этих флагов нет.
|
||||
@@ -88,17 +85,13 @@ npm run media:generate использует ffmpeg:
|
||||
4. Для каждого формата выполнить минимум 30 запусков подготовленного медиа отдельно
|
||||
для кабеля/USB и HDMI. Записать медиану/p95 из пульта для выбранного формата.
|
||||
Холодный старт учитывать отдельно. Замеры последних 400 запусков хранятся в RAM
|
||||
и разделены по формату и источнику команды. После перезапуска они сбрасываются.
|
||||
и разделены по формату. После перезапуска они сбрасываются.
|
||||
5. Проверить pause/stop и seek на 10, 30, 59 с как во время воспроизведения, так и
|
||||
на паузе. Несколько раз быстро чередовать аудио/видео, пуск/паузу/стоп:
|
||||
звук не должен дублироваться; итоговый статус соответствует проигрывателю.
|
||||
6. Отключить интернет; локальное воспроизведение и пульт остаются работоспособными.
|
||||
7. В своей Wi-Fi/LAN или точке доступа открыть адрес с компьютера на iPhone/Safari
|
||||
и Android/Chrome. Проверить неверный код, ожидание подтверждения, управление с
|
||||
обоих пультов, блокировку телефона, обрыв и восстановление Wi-Fi, отзыв доступа.
|
||||
После восстановления не должна повторяться последняя команда. Снять отдельные
|
||||
замеры телефона; отображаемая задержка НЕ включает путь телефон → компьютер.
|
||||
8. Отключить дисплей: окно показа закрывается, пульт остаётся доступен. Подключить
|
||||
|
||||
7. Отключить дисплей: окно показа закрывается, пульт остаётся доступен. Подключить
|
||||
снова, открыть показ явно: старое воспроизведение не должно возобновиться.
|
||||
|
||||
Сценарий выполнить для macOS x64/ARM64 и Windows 11 x64/ARM64; для Linux x64/ARM64
|
||||
@@ -123,7 +116,7 @@ npm run media:generate использует ffmpeg:
|
||||
- дисплей/переходник/разрешение; аудиовыход; версии ffmpeg и параметры файлов;
|
||||
- холодный старт; 30 исходных значений каждого формата/выхода; медиана/p95;
|
||||
- отдельно программная и физическая задержка, способ измерения и погрешность;
|
||||
- результаты pause/seek/fullscreen/offline/LAN/reconnect/отзыва;
|
||||
- результаты pause/seek/fullscreen/offline/переподключения дисплея;
|
||||
- ошибки, ограничения и итог PASS/FAIL.
|
||||
|
||||
Пока не пройдена вся матрица и не получены физические измерения M1, SCREEN-001
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Controller, parseCommand } from './controller.js'
|
||||
import { parseRange } from './range.js'
|
||||
|
||||
const command = { id: 'one', action: 'play', media: 'audio', position: 0 }
|
||||
test('валидирует команды на границе IPC / LAN', () => {
|
||||
test('валидирует команды на границе IPC', () => {
|
||||
assert.deepEqual(parseCommand(command), command)
|
||||
for (const value of [
|
||||
null,
|
||||
|
||||
@@ -30,16 +30,14 @@ export class Controller {
|
||||
state: State = { revision: 0, command: null, status: 'idle', error: '', samples: [] }
|
||||
private seen = new Set<string>()
|
||||
private startedAt = 0
|
||||
private origin: 'computer' | 'phone' = 'computer'
|
||||
constructor(private publish: (state: State) => void) {}
|
||||
command(input: unknown, origin: 'computer' | 'phone' = 'computer') {
|
||||
command(input: unknown) {
|
||||
const command = parseCommand(input)
|
||||
if (this.seen.has(command.id)) return
|
||||
this.seen.add(command.id)
|
||||
// shortcut: помним 4096 команд стенда; долговременная дедупликация — SCREEN-004.
|
||||
if (this.seen.size > 4096) this.seen.delete(this.seen.values().next().value!)
|
||||
this.startedAt = performance.now()
|
||||
this.origin = origin
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
@@ -70,7 +68,6 @@ export class Controller {
|
||||
...this.state.samples.slice(-399),
|
||||
{
|
||||
media: this.state.command.media,
|
||||
origin: this.origin,
|
||||
milliseconds: performance.now() - this.startedAt,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
import { app, BrowserWindow, ipcMain, protocol, screen, session } from 'electron'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { networkInterfaces } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Readable } from 'node:stream'
|
||||
import { Controller } from './controller.js'
|
||||
import { LanServer } from './lan.js'
|
||||
import { parseRange } from './range.js'
|
||||
import type { HostInfo } from '../src/types/screen.js'
|
||||
|
||||
@@ -18,20 +16,12 @@ protocol.registerSchemesAsPrivileged([
|
||||
])
|
||||
let control: BrowserWindow | null = null
|
||||
let output: BrowserWindow | null = null
|
||||
let lan: LanServer | null = null
|
||||
let selectedDisplay: number | null = null
|
||||
const controller = new Controller((state) => {
|
||||
for (const window of [control, output])
|
||||
if (window && !window.isDestroyed()) window.webContents.send('state', state)
|
||||
lan?.broadcast(state)
|
||||
})
|
||||
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 {
|
||||
const primary = screen.getPrimaryDisplay().id
|
||||
return {
|
||||
@@ -42,12 +32,6 @@ function host(): HostInfo {
|
||||
id: d.id,
|
||||
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() {
|
||||
@@ -207,38 +191,6 @@ app
|
||||
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) => {
|
||||
if (display.id === selectedDisplay) output?.close()
|
||||
})
|
||||
@@ -252,6 +204,3 @@ app
|
||||
app.quit()
|
||||
})
|
||||
app.on('window-all-closed', () => app.quit())
|
||||
app.on('before-quit', () => {
|
||||
void lan?.close()
|
||||
})
|
||||
|
||||
@@ -12,6 +12,4 @@ contextBridge.exposeInMainWorld('screenBridge', {
|
||||
},
|
||||
host: () => ipcRenderer.invoke('host'),
|
||||
show: (id: number) => ipcRenderer.invoke('show', id),
|
||||
lan: (address: string | null) => ipcRenderer.invoke('lan', address),
|
||||
approve: (id: string) => ipcRenderer.invoke('approve', id),
|
||||
})
|
||||
|
||||
Generated
+1
-34
@@ -9,12 +9,10 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"naive-ui": "^2.44.0",
|
||||
"vue": "^3.5.0",
|
||||
"ws": "^8.18.0"
|
||||
"vue": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/ws": "^8.18.0",
|
||||
"@vitejs/plugin-vue": "^6.0.0",
|
||||
"@vue/eslint-config-prettier": "^10.0.0",
|
||||
"@vue/eslint-config-typescript": "^14.0.0",
|
||||
@@ -1453,16 +1451,6 @@
|
||||
"@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": {
|
||||
"version": "8.71.2",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||
|
||||
+5
-6
@@ -10,24 +10,23 @@
|
||||
"type-check": "vue-tsc --noEmit && tsc -p tsconfig.electron.json --noEmit",
|
||||
"lint": "eslint .",
|
||||
"lint:css": "stylelint \"src/**/*.{css,vue}\"",
|
||||
"build": "vite build && tsc -p tsconfig.electron.json",
|
||||
"test": "tsc -p tsconfig.electron.json && node --test dist-electron/electron/*.test.js",
|
||||
"build": "vite build && npm run build:electron",
|
||||
"test": "npm run build:electron && node --test dist-electron/electron/*.test.js",
|
||||
"media:generate": "node scripts/generate-media.cjs",
|
||||
"pack": "npm run build && electron-builder --dir",
|
||||
"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": {
|
||||
"vue": "^3.5.0",
|
||||
"naive-ui": "^2.44.0",
|
||||
"ws": "^8.18.0"
|
||||
"naive-ui": "^2.44.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^42.0.0",
|
||||
"electron-builder": "^26.0.0",
|
||||
"typescript": "^6.0.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/ws": "^8.18.0",
|
||||
"@vitejs/plugin-vue": "^6.0.0",
|
||||
"vite": "^8.0.0",
|
||||
"vue-tsc": "^3.3.0",
|
||||
|
||||
+14
-52
@@ -78,6 +78,14 @@ app.whenReady().then(async () => {
|
||||
return s.status !== 'pending' && s.command.id === id && s
|
||||
}, 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-pause', 'pause')).status, 'paused')
|
||||
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})`,
|
||||
),
|
||||
)
|
||||
const network = (await evaluate('window.screenBridge.host()')).lan[0]
|
||||
assert.ok(network, 'LAN interface available for browser smoke')
|
||||
await evaluate(`window.screenBridge.lan(${JSON.stringify(network.address)})`)
|
||||
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}));})()`,
|
||||
assert.equal(
|
||||
await evaluate("document.body.textContent.includes('Телефон по локальной сети')"),
|
||||
false,
|
||||
)
|
||||
await delay(100)
|
||||
await phoneEval(
|
||||
`Array.from(document.querySelectorAll('button')).find(b => b.textContent.includes('Подключиться')).click()`,
|
||||
)
|
||||
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()
|
||||
assert.deepEqual(Object.keys(await evaluate('window.screenBridge.host()')), ['displays'])
|
||||
assert.equal(await evaluate('typeof window.screenBridge.lan'), 'undefined')
|
||||
assert.equal(await evaluate('typeof window.screenBridge.approve'), 'undefined')
|
||||
output.close()
|
||||
await evaluate('window.screenBridge.show(424242)')
|
||||
const reopened = await poll(
|
||||
|
||||
+29
-167
@@ -1,42 +1,25 @@
|
||||
<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 type { Action, HostInfo, MediaKind, State } from '../types/screen'
|
||||
|
||||
const message = useMessage()
|
||||
const bridge = window.screenBridge
|
||||
const isPhone = !bridge
|
||||
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 address = ref<string | null>(null)
|
||||
const media = ref<MediaKind>('audio')
|
||||
const position = ref<number | null>(10)
|
||||
const code = ref('')
|
||||
const pairingId = ref('')
|
||||
const isConnected = ref(false)
|
||||
const isBusy = ref(false)
|
||||
const isApproved = ref(false)
|
||||
const pairLabel = computed(() =>
|
||||
pairingId.value
|
||||
? `Ожидание подтверждения ${pairingId.value.slice(0, 8)} на компьютере`
|
||||
: 'Подключиться',
|
||||
)
|
||||
const metrics = computed(() => {
|
||||
return (['computer', 'phone'] as const)
|
||||
.map((origin) => {
|
||||
const values = (state.value?.samples ?? [])
|
||||
.filter((sample) => sample.media === media.value && sample.origin === origin)
|
||||
.map((sample) => sample.milliseconds)
|
||||
.sort((a, b) => a - b)
|
||||
const label = origin === 'computer' ? 'Компьютер' : 'Телефон'
|
||||
if (!values.length) return `${label}: замеров пока нет`
|
||||
const middle = Math.floor(values.length / 2)
|
||||
const median =
|
||||
values.length % 2 ? values[middle]! : (values[middle - 1]! + values[middle]!) / 2
|
||||
return `${label}: n=${values.length}, медиана ${median.toFixed(1)} мс, p95 ${values[Math.ceil(values.length * 0.95) - 1]!.toFixed(1)} мс`
|
||||
})
|
||||
.join(' · ')
|
||||
const values = (state.value?.samples ?? [])
|
||||
.filter((sample) => sample.media === media.value)
|
||||
.map((sample) => sample.milliseconds)
|
||||
.sort((a, b) => a - b)
|
||||
if (!values.length) return 'Замеров пока нет'
|
||||
const middle = Math.floor(values.length / 2)
|
||||
const median = 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)} мс`
|
||||
})
|
||||
const statusLabels: Record<State['status'], string> = {
|
||||
idle: 'Ожидание',
|
||||
@@ -47,10 +30,8 @@ const statusLabels: Record<State['status'], string> = {
|
||||
ended: 'Завершено',
|
||||
error: 'Ошибка',
|
||||
}
|
||||
let socket: WebSocket | null = null
|
||||
let timer: ReturnType<typeof setInterval> | undefined
|
||||
let unsubscribe: (() => void) | undefined
|
||||
let isDisposed = false
|
||||
|
||||
async function run(action: () => Promise<unknown>) {
|
||||
isBusy.value = true
|
||||
@@ -69,83 +50,28 @@ async function refreshHost() {
|
||||
if (bridge) host.value = await bridge.host()
|
||||
}
|
||||
async function send(action: Action) {
|
||||
const command = { id: cryptoId(), action, media: media.value, position: position.value ?? 0 }
|
||||
await run(async () => {
|
||||
if (bridge) await bridge.command(command)
|
||||
else {
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN)
|
||||
throw new Error('Связь с компьютером потеряна')
|
||||
socket.send(JSON.stringify(command))
|
||||
}
|
||||
})
|
||||
}
|
||||
// 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 (!bridge) throw new Error('Откройте пульт в приложении Electron')
|
||||
await bridge.command({
|
||||
id: crypto.randomUUID(),
|
||||
action,
|
||||
media: media.value,
|
||||
position: position.value ?? 0,
|
||||
})
|
||||
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 () => {
|
||||
await run(async () => {
|
||||
if (bridge) {
|
||||
unsubscribe = bridge.subscribe(acceptState)
|
||||
acceptState(await bridge.state())
|
||||
isConnected.value = true
|
||||
await refreshHost()
|
||||
} else connect()
|
||||
if (!bridge) throw new Error('Откройте пульт в приложении Electron')
|
||||
unsubscribe = bridge.subscribe(acceptState)
|
||||
acceptState(await bridge.state())
|
||||
await refreshHost()
|
||||
})
|
||||
let isTicking = false
|
||||
timer = setInterval(() => {
|
||||
if (isTicking) return
|
||||
isTicking = true
|
||||
void tick()
|
||||
void refreshHost()
|
||||
.catch((error: unknown) => {
|
||||
message.error(error instanceof Error ? error.message : 'Ошибка связи')
|
||||
})
|
||||
@@ -155,10 +81,8 @@ onMounted(async () => {
|
||||
}, 1000)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
isDisposed = true
|
||||
clearInterval(timer)
|
||||
unsubscribe?.()
|
||||
socket?.close()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -166,7 +90,7 @@ onUnmounted(() => {
|
||||
<main class="control">
|
||||
<h1>Вечерний детектив · SCREEN-001</h1>
|
||||
<n-alert type="info"
|
||||
>Технический стенд: синтетические медиа, два окна и локальная связь.</n-alert
|
||||
>Технический стенд: синтетические медиа, два окна и локальное воспроизведение.</n-alert
|
||||
>
|
||||
<n-card v-if="bridge" title="Дисплей показа">
|
||||
<n-space vertical>
|
||||
@@ -184,69 +108,9 @@ onUnmounted(() => {
|
||||
>
|
||||
</n-space>
|
||||
</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-space vertical>
|
||||
<n-alert v-if="!isConnected" type="warning"
|
||||
>Нет связи. Показ на компьютере продолжается; команды недоступны.</n-alert
|
||||
>
|
||||
<n-alert v-if="!bridge" type="warning">Откройте пульт в приложении Electron.</n-alert>
|
||||
<n-select
|
||||
v-model:value="media"
|
||||
:options="[
|
||||
@@ -256,11 +120,9 @@ onUnmounted(() => {
|
||||
:input-props="{ 'aria-label': 'Тестовое медиа' }"
|
||||
/>
|
||||
<n-space>
|
||||
<n-button :disabled="!isConnected || isBusy" @click="send('play')"
|
||||
>Пуск с начала</n-button
|
||||
>
|
||||
<n-button :disabled="!isConnected || isBusy" @click="send('pause')">Пауза</n-button>
|
||||
<n-button :disabled="!isConnected || isBusy" @click="send('stop')">Стоп</n-button>
|
||||
<n-button :disabled="!bridge || isBusy" @click="send('play')">Пуск с начала</n-button>
|
||||
<n-button :disabled="!bridge || isBusy" @click="send('pause')">Пауза</n-button>
|
||||
<n-button :disabled="!bridge || isBusy" @click="send('stop')">Стоп</n-button>
|
||||
</n-space>
|
||||
<label for="position">Позиция, секунды</label>
|
||||
<n-input-number
|
||||
@@ -269,7 +131,7 @@ onUnmounted(() => {
|
||||
:min="0"
|
||||
: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">
|
||||
{{ state ? statusLabels[state.status] : 'Нет состояния' }} · ревизия
|
||||
{{ state?.revision ?? 0 }}
|
||||
@@ -277,8 +139,8 @@ onUnmounted(() => {
|
||||
<n-alert v-if="state?.error" type="error">{{ state.error }}</n-alert>
|
||||
<p>{{ metrics }}</p>
|
||||
<p>
|
||||
Замеры: приём команды контроллером → подтверждение проигрывателя. Они не измеряют сеть
|
||||
телефона, физический звук или первый кадр проектора.
|
||||
Замеры: приём команды контроллером → подтверждение проигрывателя. Они не измеряют
|
||||
физический звук или первый кадр проектора.
|
||||
</p>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
+1
-7
@@ -11,7 +11,7 @@ export interface State {
|
||||
command: Command | null
|
||||
status: 'idle' | 'pending' | 'playing' | 'paused' | 'stopped' | 'ended' | 'error'
|
||||
error: string
|
||||
samples: { media: MediaKind; origin: 'computer' | 'phone'; milliseconds: number }[]
|
||||
samples: { media: MediaKind; milliseconds: number }[]
|
||||
}
|
||||
export interface DisplayInfo {
|
||||
id: number
|
||||
@@ -19,10 +19,6 @@ export interface DisplayInfo {
|
||||
}
|
||||
export interface HostInfo {
|
||||
displays: DisplayInfo[]
|
||||
lan: { address: string; family: string }[]
|
||||
url: string
|
||||
code: string
|
||||
pending: string[]
|
||||
}
|
||||
export interface ScreenBridge {
|
||||
command: (command: Command) => Promise<void>
|
||||
@@ -31,6 +27,4 @@ export interface ScreenBridge {
|
||||
subscribe: (listener: (state: State) => void) => () => void
|
||||
host: () => Promise<HostInfo>
|
||||
show: (displayId: number) => Promise<void>
|
||||
lan: (address: string | null) => Promise<void>
|
||||
approve: (id: string) => Promise<void>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user