From d3683ac6cf9cc969115d2e9e0bbef1d19c49e43f Mon Sep 17 00:00:00 2001 From: Fedorov Vladimir Date: Sat, 17 Oct 2026 18:25:04 +0700 Subject: [PATCH] up --- PLAN.md | 45 ++++---- README.md | 11 +- TASKS.md | 87 ++++++--------- docs/SCREEN-001_ACCEPTANCE.md | 35 +++--- electron/controller.test.ts | 2 +- electron/controller.ts | 5 +- electron/lan.test.ts | 106 ------------------ electron/lan.ts | 155 -------------------------- electron/main.ts | 51 --------- electron/preload.ts | 2 - package-lock.json | 35 +----- package.json | 11 +- scripts/smoke.cjs | 66 +++-------- src/components/ControlPage.vue | 196 +++++---------------------------- src/types/screen.ts | 8 +- 15 files changed, 124 insertions(+), 691 deletions(-) delete mode 100644 electron/lan.test.ts delete mode 100644 electron/lan.ts diff --git a/PLAN.md b/PLAN.md index e200802..3a68c4b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 отдельно зафиксировать наличие сертификата подписи и возможные предупреждения установки. Учётные данные не хранить в репозитории. diff --git a/README.md b/README.md index dda0f95..8c22905 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/TASKS.md b/TASKS.md index 154817b..7a48005 100644 --- a/TASKS.md +++ b/TASKS.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 подключить полный интерфейс к локальному - контроллеру через общий транспортный контракт и пройти мобильную приёмку. diff --git a/docs/SCREEN-001_ACCEPTANCE.md b/docs/SCREEN-001_ACCEPTANCE.md index 5f1956f..33be0c1 100644 --- a/docs/SCREEN-001_ACCEPTANCE.md +++ b/docs/SCREEN-001_ACCEPTANCE.md @@ -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 diff --git a/electron/controller.test.ts b/electron/controller.test.ts index d06a40f..dae5cb2 100644 --- a/electron/controller.test.ts +++ b/electron/controller.test.ts @@ -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, diff --git a/electron/controller.ts b/electron/controller.ts index bbd9254..ae1f7fb 100644 --- a/electron/controller.ts +++ b/electron/controller.ts @@ -30,16 +30,14 @@ export class Controller { state: State = { revision: 0, command: null, status: 'idle', error: '', samples: [] } private seen = new Set() 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, }, ] diff --git a/electron/lan.test.ts b/electron/lan.test.ts deleted file mode 100644 index a0895cb..0000000 --- a/electron/lan.test.ts +++ /dev/null @@ -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'), 'local') - 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((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 }) - } -}) diff --git a/electron/lan.ts b/electron/lan.ts deleted file mode 100644 index 043d144..0000000 --- a/electron/lan.ts +++ /dev/null @@ -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 = { - '.html': 'text/html; charset=utf-8', - '.js': 'text/javascript', - '.css': 'text/css', -} -export class LanServer { - url = '' - code = String(randomInt(100000, 1000000)) - pending = new Map() - private tokens = new Set() - 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((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((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) - } -} diff --git a/electron/main.ts b/electron/main.ts index 62486a4..08ebe56 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -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() -}) diff --git a/electron/preload.ts b/electron/preload.ts index 44a1c6c..4993c1e 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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), }) diff --git a/package-lock.json b/package-lock.json index 4320e64..d6d5a81 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 9c2a539..7f1b179 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/smoke.cjs b/scripts/smoke.cjs index cfb0275..d6d9912 100644 --- a/scripts/smoke.cjs +++ b/scripts/smoke.cjs @@ -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( diff --git a/src/components/ControlPage.vue b/src/components/ControlPage.vue index 9080769..9ff2140 100644 --- a/src/components/ControlPage.vue +++ b/src/components/ControlPage.vue @@ -1,42 +1,25 @@ @@ -166,7 +90,7 @@ onUnmounted(() => {

Вечерний детектив · SCREEN-001

Технический стенд: синтетические медиа, два окна и локальная связь.Технический стенд: синтетические медиа, два окна и локальное воспроизведение. @@ -184,69 +108,9 @@ onUnmounted(() => { > - - - - - Включить / сбросить подключения - Отключить и отозвать доступ - -

На телефоне откройте {{ host.url }} · код {{ host.code }}

-

Только доверенная сеть. HTTP не шифрует команды. Не публикуйте порт в интернет.

- Подтвердить запрос {{ id.slice(0, 8) }} -
-
- - - - {{ - pairLabel - }} - - - Нет связи. Показ на компьютере продолжается; команды недоступны. + Откройте пульт в приложении Electron. - Пуск с начала - Пауза - Стоп + Пуск с начала + Пауза + Стоп { :min="0" :max="60" /> - Перемотать + Перемотать

{{ state ? statusLabels[state.status] : 'Нет состояния' }} · ревизия {{ state?.revision ?? 0 }} @@ -277,8 +139,8 @@ onUnmounted(() => { {{ state.error }}

{{ metrics }}

- Замеры: приём команды контроллером → подтверждение проигрывателя. Они не измеряют сеть - телефона, физический звук или первый кадр проектора. + Замеры: приём команды контроллером → подтверждение проигрывателя. Они не измеряют + физический звук или первый кадр проектора.

diff --git a/src/types/screen.ts b/src/types/screen.ts index d6f85e7..4579ad5 100644 --- a/src/types/screen.ts +++ b/src/types/screen.ts @@ -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 @@ -31,6 +27,4 @@ export interface ScreenBridge { subscribe: (listener: (state: State) => void) => () => void host: () => Promise show: (displayId: number) => Promise - lan: (address: string | null) => Promise - approve: (id: string) => Promise }