up
This commit is contained in:
@@ -0,0 +1,71 @@
|
|||||||
|
name: SCREEN-001 builds
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- runner: macos-15-intel
|
||||||
|
target: mac
|
||||||
|
arch: x64
|
||||||
|
- runner: macos-15
|
||||||
|
target: mac
|
||||||
|
arch: arm64
|
||||||
|
- runner: windows-2025
|
||||||
|
target: win
|
||||||
|
arch: x64
|
||||||
|
- runner: windows-11-arm
|
||||||
|
target: win
|
||||||
|
arch: arm64
|
||||||
|
- runner: ubuntu-24.04
|
||||||
|
target: linux
|
||||||
|
arch: x64
|
||||||
|
- runner: ubuntu-24.04-arm
|
||||||
|
target: linux
|
||||||
|
arch: arm64
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
env:
|
||||||
|
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
|
||||||
|
ELECTRON_BUILDER_COMPRESSION_LEVEL: '1'
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '24.15.0'
|
||||||
|
cache: npm
|
||||||
|
- name: Install ffmpeg on macOS
|
||||||
|
if: runner.os == 'macOS'
|
||||||
|
run: brew install ffmpeg
|
||||||
|
- name: Install ffmpeg on Linux
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
run: sudo apt-get update && sudo apt-get install -y ffmpeg
|
||||||
|
- name: Install ffmpeg on Windows
|
||||||
|
if: runner.os == 'Windows'
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
choco install ffmpeg -y --no-progress
|
||||||
|
"C:\ProgramData\chocolatey\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||||
|
- run: npm ci
|
||||||
|
- run: npx --no-install install-electron --no
|
||||||
|
- run: npm run type-check
|
||||||
|
- run: npm run lint
|
||||||
|
- run: npm run lint:css
|
||||||
|
- run: npm test
|
||||||
|
- run: npm run media:generate
|
||||||
|
- name: Package Linux archive
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
run: npm run dist -- --linux --${{ matrix.arch }} --publish never
|
||||||
|
- name: Package macOS or Windows archive
|
||||||
|
if: runner.os != 'Linux'
|
||||||
|
run: npm run dist -- --${{ matrix.target }} --${{ matrix.arch }} --publish never
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: screen-${{ matrix.target }}-${{ matrix.arch }}
|
||||||
|
path: |
|
||||||
|
release/*.zip
|
||||||
|
release/*.tar.gz
|
||||||
|
if-no-files-found: error
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
dist-electron/
|
||||||
|
release/
|
||||||
|
fixtures/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"semi":false,"singleQuote":true,"printWidth":100}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
внешнем дисплее. Аудио, видео, изображения и выбранные музыкальные плейлисты
|
внешнем дисплее. Аудио, видео, изображения и выбранные музыкальные плейлисты
|
||||||
читаются с диска. Пульт переключает показ и управляет воспроизведением локально.
|
читаются с диска. Пульт переключает показ и управляет воспроизведением локально.
|
||||||
После подготовки потеря интернета не мешает локальному показу и таймеру.
|
После подготовки потеря интернета не мешает локальному показу и таймеру.
|
||||||
|
Полный пульт телефона работает в доверенной локальной сети одновременно с пультом компьютера.
|
||||||
|
|
||||||
«Все материалы» в первой версии — все ресурсы, необходимые окну показа:
|
«Все материалы» в первой версии — все ресурсы, необходимые окну показа:
|
||||||
вступление, закрывающее видео, обложка, фон, материалы шагов плана, выбранные
|
вступление, закрывающее видео, обложка, фон, материалы шагов плана, выбранные
|
||||||
@@ -70,11 +71,24 @@ Linux проверяется на обоих указанных дистрибу
|
|||||||
5. Сервер проверяет право организатора на скачивание материалов. Существующий
|
5. Сервер проверяет право организатора на скачивание материалов. Существующий
|
||||||
API используется где достаточно; манифест добавляется минимальным расширением.
|
API используется где достаточно; манифест добавляется минимальным расширением.
|
||||||
Токены не записываются в пакет игры и логи.
|
Токены не записываются в пакет игры и логи.
|
||||||
6. После подготовки основной процесс — источник состояния локального показа.
|
6. После подготовки основной процесс — единственный источник состояния локального показа.
|
||||||
Серверная синхронизация не задерживает команды. В MVP удалённые команды с
|
Команды пульта компьютера через IPC и телефона через WebSocket обрабатываются
|
||||||
телефона не управляют desktop-показом: параллельные источники исключены.
|
последовательно; состояние с возрастающей ревизией рассылается обоим пультам.
|
||||||
Возврат сети не должен перезапускать старую серверную команду поверх локальной.
|
Серверная синхронизация не задерживает команды. Команды сайта не управляют
|
||||||
7. Текущие результаты игры загружаются онлайн с отметкой времени; офлайн доступны
|
desktop-показом; возврат интернета не повторяет старые серверные команды.
|
||||||
|
7. Приложение по явному действию оператора раздаёт мобильный Vue-пульт со встроенного
|
||||||
|
HTTP-сервера на выбранном IPv4-интерфейсе. Код сопряжения, подтверждение на
|
||||||
|
компьютере и временная HttpOnly-cookie ограничивают доступ. Сеансы только в памяти;
|
||||||
|
отключение пульта отзывает их. Рабочая сеть — своя Wi-Fi/LAN или точка доступа:
|
||||||
|
HTTP не шифрует трафик. Сертификаты и управление через интернет не входят в MVP.
|
||||||
|
В сетевом API нет произвольного доступа к диску, IPC и токенам сайта.
|
||||||
|
8. Телефон управляет всеми режимами показа, шагами, таймером, музыкой, громкостью
|
||||||
|
и перемоткой. Подготовка игры и дисплея остаётся на компьютере. Команды имеют
|
||||||
|
идентификаторы для дедупликации; после потери связи управление блокируется,
|
||||||
|
показ продолжается. Переподключение получает снимок состояния без повтора команд.
|
||||||
|
Vue-компоненты и типы пультов общие внутри screen, различается транспорт.
|
||||||
|
Общий пакет с сайтом заранее не выделяется.
|
||||||
|
9. Текущие результаты игры загружаются онлайн с отметкой времени; офлайн доступны
|
||||||
только последние сохранённые данные с явным предупреждением оператору.
|
только последние сохранённые данные с явным предупреждением оператору.
|
||||||
Серверная игра, ответы команд и вычисление новых результатов офлайн не работают.
|
Серверная игра, ответы команд и вычисление новых результатов офлайн не работают.
|
||||||
|
|
||||||
@@ -99,14 +113,15 @@ Linux проверяется на обоих указанных дистрибу
|
|||||||
## Порядок работ
|
## Порядок работ
|
||||||
|
|
||||||
1. SCREEN-001: проверить Electron на всех шести вариантах, два дисплея, локальное
|
1. SCREEN-001: проверить Electron на всех шести вариантах, два дисплея, локальное
|
||||||
медиа и задержки; порог задержки — на M1.
|
медиа и задержки, техническое подключение телефона по LAN; порог задержки — на M1.
|
||||||
2. SCREEN-002: получить полный и защищённый пакет материалов с сервера.
|
2. SCREEN-002: получить полный и защищённый пакет материалов с сервера.
|
||||||
3. SCREEN-003: реализовать надёжную загрузку и локальное хранение пакетов.
|
3. SCREEN-003: реализовать надёжную загрузку и локальное хранение пакетов.
|
||||||
4. SCREEN-004: реализовать локальный проигрыватель и команды, подготовку медиа.
|
4. SCREEN-004: реализовать локальный проигрыватель и команды, подготовку медиа.
|
||||||
5. SCREEN-005: собрать пульт, план показа и режимы экрана.
|
5. SCREEN-005: собрать пульт, план показа и режимы экрана.
|
||||||
6. SCREEN-006: добавить подключение игры и согласование с сервером.
|
6. SCREEN-009: полный пульт телефона, сопряжение, общее состояние и восстановление связи.
|
||||||
7. SCREEN-007: обработать отключение дисплея, звук, сон и восстановление.
|
7. SCREEN-006: добавить подключение игры и согласование с сервером.
|
||||||
8. SCREEN-008: собрать артефакты для всех шести вариантов и провести приёмку на площадке.
|
8. SCREEN-007: обработать отключение дисплея, звук, сон и восстановление.
|
||||||
|
9. SCREEN-008: собрать артефакты для всех шести вариантов и провести приёмку на площадке.
|
||||||
|
|
||||||
После SCREEN-001 — решение о продолжении по результатам всей матрицы платформ.
|
После SCREEN-001 — решение о продолжении по результатам всей матрицы платформ.
|
||||||
Разработка на одной ОС не заменяет аппаратную проверку остальных ОС и архитектур,
|
Разработка на одной ОС не заменяет аппаратную проверку остальных ОС и архитектур,
|
||||||
@@ -124,7 +139,7 @@ Linux проверяется на обоих указанных дистрибу
|
|||||||
- В SCREEN-001 для каждой тестовой конфигурации зафиксировать ОС, архитектуру,
|
- В SCREEN-001 для каждой тестовой конфигурации зафиксировать ОС, архитектуру,
|
||||||
CPU/RAM, проектор/переходник, аудиовыход, форматы, разрешение и размер медиа.
|
CPU/RAM, проектор/переходник, аудиовыход, форматы, разрешение и размер медиа.
|
||||||
- Полное управление игрой, локальный Go-сервер/БД, редактирование сценария,
|
- Полное управление игрой, локальный Go-сервер/БД, редактирование сценария,
|
||||||
автообновления и дистанционное управление desktop — за пределами MVP.
|
автообновления и управление desktop через интернет — за пределами MVP.
|
||||||
- Подпись и notarization зависят от наличия Apple Developer; без них не обещать
|
- Подпись и notarization зависят от наличия Apple Developer; без них не обещать
|
||||||
установку без предупреждений Gatekeeper. Для Windows отдельно зафиксировать наличие сертификата подписи и
|
установку без предупреждений Gatekeeper. Для Windows отдельно зафиксировать наличие сертификата подписи и
|
||||||
возможные предупреждения установки. Учётные данные не хранить в репозитории.
|
возможные предупреждения установки. Учётные данные не хранить в репозитории.
|
||||||
@@ -133,6 +148,10 @@ Linux проверяется на обоих указанных дистрибу
|
|||||||
|
|
||||||
Для нового приложения настроить type-check, lint, lint:css и build; покрывать
|
Для нового приложения настроить type-check, lint, lint:css и build; покрывать
|
||||||
тестами нетривиальные переходы, IPC, проверку пакетов и восстановление загрузок.
|
тестами нетривиальные переходы, IPC, проверку пакетов и восстановление загрузок.
|
||||||
|
Для Electron использовать sandbox, contextIsolation и отключённый nodeIntegration.
|
||||||
|
Мобильный пульт проверять в Safari на iPhone и Chrome на Android. Замеры для
|
||||||
|
телефона вести отдельно: порог 150 мс относится к пульту компьютера.
|
||||||
|
|
||||||
Перед изменениями Vue изучить `../evening_detective_frontend/docs/FRONTEND_CODE_STANDARD.md`
|
Перед изменениями Vue изучить `../evening_detective_frontend/docs/FRONTEND_CODE_STANDARD.md`
|
||||||
и применять подходящие правила. При изменениях соседних репозиториев обязательны
|
и применять подходящие правила. При изменениях соседних репозиториев обязательны
|
||||||
их стандарты и проверки из корневого AGENTS.md; при изменении protobuf — штатная
|
их стандарты и проверки из корневого AGENTS.md; при изменении protobuf — штатная
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# Вечерний детектив — приложение площадки
|
# Вечерний детектив — приложение площадки
|
||||||
|
|
||||||
Статус: планирование, реализация ещё не начата. Решения зафиксированы 17.10.2026.
|
Статус: SCREEN-001 в работе; технический стенд реализован, аппаратная приёмка открыта.
|
||||||
|
Архитектура пересмотрена 17.10.2026: добавлен пульт телефона по доверенной LAN.
|
||||||
|
|
||||||
Цель: ведущий управляет показом на компьютере с macOS, Windows или Linux, а подключённый монитор или
|
Цель: ведущий управляет показом на компьютере с macOS, Windows или Linux, а подключённый монитор или
|
||||||
проектор показывает материалы игрокам. Все материалы показа заранее находятся
|
проектор показывает материалы игрокам. Все материалы показа заранее находятся
|
||||||
@@ -20,3 +21,30 @@ macOS Intel/Apple Silicon, Windows x64/ARM64 и Linux x64/ARM64.
|
|||||||
Для начала: `Выполнить задачу: SCREEN-001`.
|
Для начала: `Выполнить задачу: SCREEN-001`.
|
||||||
Новые задачи: `Новая задача: <описание>`.
|
Новые задачи: `Новая задача: <описание>`.
|
||||||
Commit и push выполняет только пользователь. Вложенные `.git` не изменять.
|
Commit и push выполняет только пользователь. Вложенные `.git` не изменять.
|
||||||
|
|
||||||
|
## Запуск стенда SCREEN-001
|
||||||
|
|
||||||
|
Нужны Node.js 24.15+ и ffmpeg с libx264 в PATH.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm ci
|
||||||
|
npx --no-install install-electron --no
|
||||||
|
npm run media:generate
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
До запуска подключите второй дисплей в режиме расширенного рабочего стола
|
||||||
|
и назначьте экран компьютера основным. В пульте выберите
|
||||||
|
его и откройте показ. Аудио и видео — синтетические локальные файлы длительностью
|
||||||
|
60 секунд; бинарники находятся в игнорируемом fixtures/ и не входят в Git.
|
||||||
|
Стенд поддерживает пуск с начала, паузу, остановку и перемотку. Полные игровые
|
||||||
|
режимы, загрузка игр и авторизация пока не реализованы.
|
||||||
|
|
||||||
|
Для телефона выберите IPv4-адрес доверенной сети и включите LAN-пульт. Откройте
|
||||||
|
показанный адрес в браузере телефона, введите код и подтвердите запрос с тем же
|
||||||
|
коротким идентификатором на компьютере. Отключение LAN отзывает все подключения.
|
||||||
|
Если устройства не видят друг друга, проверьте firewall и изоляцию клиентов Wi-Fi;
|
||||||
|
локальный пульт компьютера продолжает работать.
|
||||||
|
|
||||||
|
Проверки: npm run type-check, npm run lint, npm run lint:css, npm test, npm run build.
|
||||||
|
[Сборки и аппаратная приёмка](docs/SCREEN-001_ACCEPTANCE.md).
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
# Задачи приложения площадки
|
# Задачи приложения площадки
|
||||||
|
|
||||||
Все задачи имеют статус TODO; выполнение ещё не начато. План и общие ограничения:
|
SCREEN-001 имеет статус IN_PROGRESS; остальные задачи — TODO. План и общие ограничения:
|
||||||
[PLAN.md](PLAN.md). Идентификаторы независимы от ED-* в корневом списке.
|
[PLAN.md](PLAN.md). Идентификаторы независимы от ED-* в корневом списке.
|
||||||
Обновлять статус и результат здесь; завершённые записи переносить в
|
Обновлять статус и результат здесь; завершённые записи переносить в
|
||||||
`COMPLETED_TASKS.md` в этом каталоге после проверки. Коммиты и push не выполнять.
|
`COMPLETED_TASKS.md` в этом каталоге после проверки. Коммиты и push не выполнять.
|
||||||
|
|
||||||
## SCREEN-001 — Проверить основу Electron на macOS, Windows и Linux
|
## SCREEN-001 — Проверить основу Electron на macOS, Windows и Linux
|
||||||
|
|
||||||
- Статус: `TODO`
|
- Статус: `IN_PROGRESS`
|
||||||
- Зависимости: нет; первая задача.
|
- Зависимости: нет; первая задача.
|
||||||
- Цель: подтвердить выбор технологии на всех шести вариантах ОС/архитектуры
|
- Цель: подтвердить выбор технологии на всех шести вариантах ОС/архитектуры
|
||||||
из PLAN.md до переноса функций проекта. Все задачи приложения учитывают эту
|
из PLAN.md до переноса функций проекта. Все задачи приложения учитывают эту
|
||||||
матрицу; Linux проверяется на Ubuntu 24.04 LTS и Debian 13.
|
матрицу; Linux проверяется на Ubuntu 24.04 LTS и Debian 13.
|
||||||
- Объём: минимальный Electron + Vue + TypeScript + Vite; пульт и отдельное окно
|
- Объём: минимальный Electron + Vue + TypeScript + Vite; пульт и отдельное окно
|
||||||
показа; выбор дисплея; синтетические локальные аудио/видео; IPC start/pause/stop
|
показа; выбор дисплея; синтетические локальные аудио/видео; IPC start/pause/stop
|
||||||
и seek; безопасный preload и локальная выдача медиа с Range. Подготовить сборки
|
и seek; безопасный preload и локальная выдача медиа с Range; техническое
|
||||||
|
подключение браузера телефона по LAN с сопряжением и отправкой команды. Подготовить сборки
|
||||||
для всех шести вариантов, инструкцию получения тестовых медиа и аппаратной
|
для всех шести вариантов, инструкцию получения тестовых медиа и аппаратной
|
||||||
проверки. Бинарные тестовые файлы не коммитить.
|
проверки. Бинарные тестовые файлы не коммитить.
|
||||||
- Готовность: запуск на всех шести вариантах; пульт остаётся на основном экране,
|
- Готовность: запуск на всех шести вариантах; пульт остаётся на основном экране,
|
||||||
@@ -27,10 +28,39 @@
|
|||||||
звук/кадр и отключённая сеть. Проверить кабель/USB и HDMI. Владелец выполняет
|
звук/кадр и отключённая сеть. Проверить кабель/USB и HDMI. Владелец выполняет
|
||||||
аппаратные проверки по инструкции агента и передаёт результаты.
|
аппаратные проверки по инструкции агента и передаёт результаты.
|
||||||
- Не входит: сервер, авторизация, скачивание, полный пульт, публикация.
|
- Не входит: сервер, авторизация, скачивание, полный пульт, публикация.
|
||||||
- Следующий шаг: прочитать frontend-стандарт, создать минимальный проект с учётом
|
- Результат программного этапа (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. В обычном запуске этих флагов нет.
|
||||||
|
- Среда 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 только в доверенной сети.
|
||||||
|
- Следующий шаг: получить аппаратные результаты владельца по
|
||||||
|
docs/SCREEN-001_ACCEPTANCE.md на всех шести вариантах (Linux — на обоих
|
||||||
|
дистрибутивах), включая реальные iPhone/Safari и Android/Chrome, проектор,
|
||||||
|
физический звук/кадр и 30 запусков для каждого формата/аудиовыхода. macOS-сборки
|
||||||
|
подготовить на Mac или ручным workflow builds.yml. До результатов всей матрицы
|
||||||
|
задача остаётся IN_PROGRESS, SCREEN-002 не начинается.
|
||||||
|
|
||||||
## SCREEN-002 — Выдать версионированный пакет показа
|
## SCREEN-002 — Выдать версионированный пакет показа
|
||||||
|
|
||||||
@@ -113,17 +143,18 @@
|
|||||||
- Готовность: готовая игра открывается после перезапуска офлайн; секреты хранятся
|
- Готовность: готовая игра открывается после перезапуска офлайн; секреты хранятся
|
||||||
средствами ОС, не в пакете; истечение сессии не обрывает уже подготовленный
|
средствами ОС, не в пакете; истечение сессии не обрывает уже подготовленный
|
||||||
локальный показ; обновления активируются явно. Результаты имеют время снимка.
|
локальный показ; обновления активируются явно. Результаты имеют время снимка.
|
||||||
Возврат сети не повторяет старые команды; телефон не перехватывает desktop-показ.
|
Возврат сети не повторяет старые команды; серверные команды не перехватывают desktop-показ.
|
||||||
- Проверка: сценарии offline/reconnect/истёкшей сессии/обновления игры; проверки
|
- Проверка: сценарии offline/reconnect/истёкшей сессии/обновления игры; проверки
|
||||||
затронутых репозиториев; существующий браузерный экран продолжает работать.
|
затронутых репозиториев; существующий браузерный экран продолжает работать.
|
||||||
- Не входит: полноценная офлайн-игра и удалённое управление desktop с телефона.
|
- Не входит: полноценная офлайн-игра и управление desktop через интернет.
|
||||||
|
Телефон по LAN реализуется в SCREEN-009; сайт не перехватывает локальное управление.
|
||||||
- Следующий шаг: сверить auth flow и модель сессий; зафиксировать правила
|
- Следующий шаг: сверить auth flow и модель сессий; зафиксировать правила
|
||||||
локального владения показом и отображения статуса на сервере.
|
локального владения показом и отображения статуса на сервере.
|
||||||
|
|
||||||
## SCREEN-007 — Устойчивость на площадке
|
## SCREEN-007 — Устойчивость на площадке
|
||||||
|
|
||||||
- Статус: `TODO`
|
- Статус: `TODO`
|
||||||
- Зависимости: SCREEN-005, SCREEN-006.
|
- Зависимости: SCREEN-005, SCREEN-006, SCREEN-009.
|
||||||
- Цель: переживать типовые сбои без потери материалов и случайного показа пульта.
|
- Цель: переживать типовые сбои без потери материалов и случайного показа пульта.
|
||||||
- Объём: отключение/возврат дисплея, смена разрешения и fullscreen, проверка
|
- Объём: отключение/возврат дисплея, смена разрешения и fullscreen, проверка
|
||||||
аудиовыхода каждой ОС, предотвращение сна во время показа, восстановление окон и
|
аудиовыхода каждой ОС, предотвращение сна во время показа, восстановление окон и
|
||||||
@@ -160,3 +191,24 @@
|
|||||||
- Следующий шаг: выбрать минимальный инструмент упаковки для всей матрицы,
|
- Следующий шаг: выбрать минимальный инструмент упаковки для всей матрицы,
|
||||||
проверить среду сборки и наличие сертификатов, собрать артефакты и подготовить
|
проверить среду сборки и наличие сертификатов, собрать артефакты и подготовить
|
||||||
инструкции для аппаратной приёмки.
|
инструкции для аппаратной приёмки.
|
||||||
|
|
||||||
|
## 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 подключить полный интерфейс к локальному
|
||||||
|
контроллеру через общий транспортный контракт и пройти мобильную приёмку.
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
# SCREEN-001 — сборки и аппаратная проверка
|
||||||
|
|
||||||
|
Статус: аппаратная приёмка открыта. Сборка не доказывает звук, первый кадр,
|
||||||
|
совместимость с проектором или выполнение порога задержки.
|
||||||
|
|
||||||
|
## Сборки
|
||||||
|
|
||||||
|
На каждой целевой ОС установите Node.js 24.15+ и ffmpeg с libx264, выполните команды
|
||||||
|
запуска из README. Зависимости зафиксированы package-lock.json; используйте npm ci.
|
||||||
|
Electron загружается отдельной командой install-electron. Генерация медиа обязательна
|
||||||
|
до упаковки. Пакеты без доверенной подписи/notarization, публикации и автообновления нет.
|
||||||
|
|
||||||
|
| Среда сборки | Команда после подготовки | Артефакт |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| macOS Intel | npm run dist -- --mac --x64 | release/*.zip |
|
||||||
|
| macOS Apple Silicon | npm run dist -- --mac --arm64 | release/*.zip |
|
||||||
|
| Windows x64 | npm run dist -- --win --x64 | release/*.zip |
|
||||||
|
| Windows ARM64 | npm run dist -- --win --arm64 | release/*.zip |
|
||||||
|
| Linux x64 | npm run dist -- --linux --x64 | release/*.tar.gz |
|
||||||
|
| Linux ARM64 | npm run dist -- --linux --arm64 | release/*.tar.gz |
|
||||||
|
|
||||||
|
Сборки macOS выполняйте на Mac. На Windows ARM используйте ARM-версию Node.js.
|
||||||
|
Linux tar.gz распаковать с сохранением прав файлов; запуск — исполняемый файл
|
||||||
|
в распакованном каталоге. Архивы Windows распаковать целиком, macOS .app перенести в Applications.
|
||||||
|
Gatekeeper и SmartScreen могут предупреждать о неподписанном приложении; следуйте
|
||||||
|
политике своего компьютера, не отключайте защиту ОС глобально.
|
||||||
|
|
||||||
|
Ручной GitHub Actions workflow builds.yml подготавливает шесть вариантов на
|
||||||
|
соответствующих runners и публикует только workflow-артефакты. Агент workflow
|
||||||
|
не запускает, не коммитит и не отправляет в remote. Labels runners сверены с
|
||||||
|
[документацией GitHub](https://docs.github.com/en/actions/reference/runners/github-hosted-runners).
|
||||||
|
CI не заменяет проверку macOS 13, Windows 11 и обоих дистрибутивов Linux.
|
||||||
|
|
||||||
|
Windows-стенд собирается с signAndEditExecutable=false: без редактирования
|
||||||
|
иконки/метаданных EXE и без подписи. Брендинг и подписание относятся к SCREEN-008.
|
||||||
|
В workflow выбран ELECTRON_BUILDER_COMPRESSION_LEVEL=1 для быстрой упаковки.
|
||||||
|
Локально при необходимости задайте ту же переменную окружения перед командой
|
||||||
|
сборки (bash: export ELECTRON_BUILDER_COMPRESSION_LEVEL=1; PowerShell:
|
||||||
|
$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 не запускался. Совместимость с целевыми ОС,
|
||||||
|
внешним оборудованием и мобильными браузерами остаётся открытой.
|
||||||
|
|
||||||
|
## Автоматический smoke
|
||||||
|
|
||||||
|
После генерации медиа выполните npm run smoke в графической сессии. Проверка
|
||||||
|
использует синтетический второй дисплей и отдельное браузерное окно вместо телефона:
|
||||||
|
IPC, Range, запуск/пауза/стоп/seek, запрет IPC из окна показа, сопряжение через UI,
|
||||||
|
команды обоих пультов, отзыв доступа и отсутствие автоповтора при открытии окна.
|
||||||
|
Для тестового браузера используется прямое LAN-соединение без системного прокси.
|
||||||
|
Это не проверка реального проектора или мобильного браузера.
|
||||||
|
|
||||||
|
В ограниченной среде агента запуск выполнен с тестовыми флагами --no-sandbox
|
||||||
|
--mute-audio --disable-gpu. В обычном запуске npm run dev этих флагов нет.
|
||||||
|
Headless-запуск в данной среде зависал/завершался SIGSEGV; GUI smoke прошёл.
|
||||||
|
|
||||||
|
## Тестовые файлы
|
||||||
|
|
||||||
|
npm run media:generate использует ffmpeg:
|
||||||
|
|
||||||
|
- audio.wav: 60 с, PCM s16le, mono, 48 кГц, синус 1000 Гц с фильтром volume=0.1.
|
||||||
|
- video.mp4: 60 с, H.264/libx264, yuv420p, 1280×720, 30 fps; AAC mono 48 кГц,
|
||||||
|
синус 1000 Гц с фильтром volume=0.1; faststart.
|
||||||
|
|
||||||
|
Окно показа устанавливает громкость элемента 35%. Начинайте с умеренной системной
|
||||||
|
громкости. Фиксируйте ffmpeg -version, размеры и SHA-256 фактических файлов:
|
||||||
|
битовый результат зависит от версии кодировщика.
|
||||||
|
|
||||||
|
## Сценарий
|
||||||
|
|
||||||
|
1. До запуска назначить экран компьютера основным в настройках ОС. Записать ОС/версию, архитектуру, CPU/RAM, версию приложения/Electron, размеры
|
||||||
|
медиа, дисплей, разрешение, масштаб, переходник, тип кабеля и аудиовыход.
|
||||||
|
2. Включить расширенный рабочий стол, выбрать внешний дисплей, открыть показ.
|
||||||
|
Проверить, что пульт и технические сообщения не видны игрокам. На macOS проверить
|
||||||
|
fullscreen/Spaces и перемещение фокуса между пультом и показом.
|
||||||
|
3. После холодного старта запустить аудио без клика в окне показа. Повторить для видео.
|
||||||
|
Проверить физический звук/изображение и отсутствие дублирования звука в пульте.
|
||||||
|
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. Отключить дисплей: окно показа закрывается, пульт остаётся доступен. Подключить
|
||||||
|
снова, открыть показ явно: старое воспроизведение не должно возобновиться.
|
||||||
|
|
||||||
|
Сценарий выполнить для macOS x64/ARM64 и Windows 11 x64/ARM64; для Linux x64/ARM64
|
||||||
|
проверить Ubuntu 24.04 LTS и Debian 13. Bluetooth в матрицу не входит.
|
||||||
|
|
||||||
|
## Физическая задержка
|
||||||
|
|
||||||
|
Числа в UI измеряют только приём команды основным процессом → завершение play(),
|
||||||
|
а не нажатие → звук/кадр. Для физического измерения снимите пульт и фактический
|
||||||
|
проектор камерой не менее 240 fps со звуком, отмечая момент нажатия и начало
|
||||||
|
тестового сигнала/движения изображения (не уже подготовленного неподвижного кадра). Запишите метод, частоту съёмки и погрешность; при
|
||||||
|
неоднозначном моменте нажатия используйте внешний регистратор кнопки/звука/кадра.
|
||||||
|
Для 30 отсортированных значений медиана — среднее 15-го и 16-го, p95 — 29-е.
|
||||||
|
На M1 16 ГБ целевой p95 от нажатия пульта компьютера до физического вывода ≤150 мс.
|
||||||
|
На остальных конфигурациях фиксируем медиану/p95 и стабильность без этого порога.
|
||||||
|
|
||||||
|
## Протокол результата
|
||||||
|
|
||||||
|
Для каждой конфигурации приложить:
|
||||||
|
|
||||||
|
- дата; ОС/архитектура; CPU/RAM; версия приложения/Electron;
|
||||||
|
- дисплей/переходник/разрешение; аудиовыход; версии ffmpeg и параметры файлов;
|
||||||
|
- холодный старт; 30 исходных значений каждого формата/выхода; медиана/p95;
|
||||||
|
- отдельно программная и физическая задержка, способ измерения и погрешность;
|
||||||
|
- результаты pause/seek/fullscreen/offline/LAN/reconnect/отзыва;
|
||||||
|
- ошибки, ограничения и итог PASS/FAIL.
|
||||||
|
|
||||||
|
Пока не пройдена вся матрица и не получены физические измерения M1, SCREEN-001
|
||||||
|
остаётся IN_PROGRESS, SCREEN-002 не начинается. Результаты владельца фиксируются
|
||||||
|
в TASKS.md; неподтверждённые пункты не объявляются успешными.
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { test } from 'node:test'
|
||||||
|
import { Controller, parseCommand } from './controller.js'
|
||||||
|
import { parseRange } from './range.js'
|
||||||
|
|
||||||
|
const command = { id: 'one', action: 'play', media: 'audio', position: 0 }
|
||||||
|
test('валидирует команды на границе IPC / LAN', () => {
|
||||||
|
assert.deepEqual(parseCommand(command), command)
|
||||||
|
for (const value of [
|
||||||
|
null,
|
||||||
|
{},
|
||||||
|
{ ...command, id: '../file' },
|
||||||
|
{ ...command, action: ['play'] },
|
||||||
|
{ ...command, media: ['audio'] },
|
||||||
|
{ ...command, position: NaN },
|
||||||
|
{ ...command, position: -1 },
|
||||||
|
{ ...command, action: 'delete' },
|
||||||
|
{ ...command, media: '/etc/passwd' },
|
||||||
|
]) {
|
||||||
|
assert.throws(() => parseCommand(value))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
test('повтор не применяется; устаревшее подтверждение не меняет новую команду', () => {
|
||||||
|
let broadcasts = 0
|
||||||
|
const c = new Controller(() => broadcasts++)
|
||||||
|
c.command(command)
|
||||||
|
c.command(command)
|
||||||
|
assert.equal(broadcasts, 1)
|
||||||
|
c.command({ ...command, id: 'two', action: 'pause' })
|
||||||
|
c.report({ id: 'one', status: 'playing' })
|
||||||
|
assert.equal(c.state.status, 'pending')
|
||||||
|
c.report({ id: 'two', status: 'paused' })
|
||||||
|
assert.equal(c.state.status, 'paused')
|
||||||
|
assert.equal(c.state.revision, 3)
|
||||||
|
})
|
||||||
|
test('playing подтверждается один раз, ошибки сохраняются', () => {
|
||||||
|
const c = new Controller(() => {})
|
||||||
|
c.command(command)
|
||||||
|
c.report({ id: 'one', status: 'playing' })
|
||||||
|
c.report({ id: 'one', status: 'playing' })
|
||||||
|
assert.equal(c.state.samples.length, 1)
|
||||||
|
c.report({ id: 'one', status: 'error', error: 'Нет файла' })
|
||||||
|
assert.equal(c.state.error, 'Нет файла')
|
||||||
|
assert.throws(() => c.report({ id: 'one', status: 'anything' }))
|
||||||
|
})
|
||||||
|
test('Range: полный файл, границы, суффикс и отказ для недопустимых диапазонов', () => {
|
||||||
|
assert.equal(parseRange(null, 0), null)
|
||||||
|
assert.deepEqual(parseRange(null, 100), { start: 0, end: 99 })
|
||||||
|
assert.deepEqual(parseRange('bytes=20-29', 100), { start: 20, end: 29 })
|
||||||
|
assert.deepEqual(parseRange('bytes=90-', 100), { start: 90, end: 99 })
|
||||||
|
assert.deepEqual(parseRange('bytes=-10', 100), { start: 90, end: 99 })
|
||||||
|
assert.deepEqual(parseRange('bytes=90-999', 100), { start: 90, end: 99 })
|
||||||
|
for (const value of ['bytes=100-', 'bytes=20-10', 'bytes=-0', 'bytes=0-1,3-5', 'bytes=-', 'abc'])
|
||||||
|
assert.equal(parseRange(value, 100), null)
|
||||||
|
})
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { performance } from 'node:perf_hooks'
|
||||||
|
import type { Command, State } from '../src/types/screen.js'
|
||||||
|
|
||||||
|
export function parseCommand(value: unknown): Command {
|
||||||
|
if (!value || typeof value !== 'object') throw new Error('Некорректная команда')
|
||||||
|
const c = value as Record<string, unknown>
|
||||||
|
if (
|
||||||
|
typeof c.id !== 'string' ||
|
||||||
|
!/^[a-zA-Z0-9-]{1,80}$/.test(c.id) ||
|
||||||
|
typeof c.action !== 'string' ||
|
||||||
|
!['play', 'pause', 'stop', 'seek'].includes(c.action) ||
|
||||||
|
typeof c.media !== 'string' ||
|
||||||
|
!['audio', 'video'].includes(c.media) ||
|
||||||
|
typeof c.position !== 'number' ||
|
||||||
|
!Number.isFinite(c.position) ||
|
||||||
|
c.position < 0 ||
|
||||||
|
c.position > 60
|
||||||
|
) {
|
||||||
|
throw new Error('Некорректная команда')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: c.id,
|
||||||
|
action: c.action as Command['action'],
|
||||||
|
media: c.media as Command['media'],
|
||||||
|
position: c.position,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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') {
|
||||||
|
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,
|
||||||
|
command,
|
||||||
|
status: 'pending',
|
||||||
|
error: '',
|
||||||
|
}
|
||||||
|
this.publish(this.state)
|
||||||
|
}
|
||||||
|
report(input: unknown) {
|
||||||
|
if (!input || typeof input !== 'object') throw new Error('Некорректный отчёт')
|
||||||
|
const r = input as Record<string, unknown>
|
||||||
|
if (
|
||||||
|
typeof r.status !== 'string' ||
|
||||||
|
!['playing', 'paused', 'stopped', 'ended', 'error'].includes(r.status) ||
|
||||||
|
typeof r.id !== 'string' ||
|
||||||
|
(r.error !== undefined && typeof r.error !== 'string')
|
||||||
|
)
|
||||||
|
throw new Error('Некорректный отчёт')
|
||||||
|
if (
|
||||||
|
r.id !== this.state.command?.id ||
|
||||||
|
(this.state.status !== 'pending' && r.status !== 'ended' && r.status !== 'error')
|
||||||
|
)
|
||||||
|
return
|
||||||
|
const samples =
|
||||||
|
r.status === 'playing' && this.state.command.action === 'play'
|
||||||
|
? [
|
||||||
|
...this.state.samples.slice(-399),
|
||||||
|
{
|
||||||
|
media: this.state.command.media,
|
||||||
|
origin: this.origin,
|
||||||
|
milliseconds: performance.now() - this.startedAt,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: this.state.samples
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
status: r.status as State['status'],
|
||||||
|
error: String(r.error ?? '').slice(0, 500),
|
||||||
|
samples,
|
||||||
|
}
|
||||||
|
this.publish(this.state)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
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
@@ -0,0 +1,155 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required')
|
||||||
|
protocol.registerSchemesAsPrivileged([
|
||||||
|
{
|
||||||
|
scheme: 'screen-app',
|
||||||
|
privileges: { standard: true, secure: true, stream: true, supportFetchAPI: true },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
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 {
|
||||||
|
displays: screen
|
||||||
|
.getAllDisplays()
|
||||||
|
.filter((d) => d.id !== primary)
|
||||||
|
.map((d) => ({
|
||||||
|
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() {
|
||||||
|
return {
|
||||||
|
backgroundColor: '#111111',
|
||||||
|
webPreferences: {
|
||||||
|
preload: join(__dirname, 'preload.js'),
|
||||||
|
nodeIntegration: false,
|
||||||
|
contextIsolation: true,
|
||||||
|
sandbox: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function secure(window: BrowserWindow) {
|
||||||
|
window.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
|
||||||
|
window.webContents.on('will-navigate', (event) => event.preventDefault())
|
||||||
|
}
|
||||||
|
function requireControl(event: Electron.IpcMainInvokeEvent) {
|
||||||
|
if (
|
||||||
|
!control ||
|
||||||
|
event.sender !== control.webContents ||
|
||||||
|
event.senderFrame !== control.webContents.mainFrame
|
||||||
|
)
|
||||||
|
throw new Error('Недоступно')
|
||||||
|
}
|
||||||
|
function show(displayId: number) {
|
||||||
|
const display = screen.getAllDisplays().find((d) => d.id === displayId)
|
||||||
|
if (!display || display.id === screen.getPrimaryDisplay().id)
|
||||||
|
throw new Error('Выберите внешний дисплей; включите расширенный рабочий стол')
|
||||||
|
if (output) output.destroy()
|
||||||
|
selectedDisplay = display.id
|
||||||
|
output = new BrowserWindow({ ...windowOptions(), ...display.bounds, show: false })
|
||||||
|
secure(output)
|
||||||
|
const window = output
|
||||||
|
output.on('closed', () => {
|
||||||
|
if (output !== window) return
|
||||||
|
output = null
|
||||||
|
selectedDisplay = null
|
||||||
|
controller.report({
|
||||||
|
id: controller.state.command?.id ?? '',
|
||||||
|
status: 'error',
|
||||||
|
error: 'Окно показа закрыто',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
output.once('ready-to-show', () => {
|
||||||
|
if (!window.isDestroyed()) {
|
||||||
|
window.show()
|
||||||
|
window.setFullScreen(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
void output.loadURL('screen-app://app/index.html?output=1')
|
||||||
|
}
|
||||||
|
app
|
||||||
|
.whenReady()
|
||||||
|
.then(async () => {
|
||||||
|
session.defaultSession.setPermissionRequestHandler((_contents, _permission, callback) =>
|
||||||
|
callback(false),
|
||||||
|
)
|
||||||
|
session.defaultSession.setPermissionCheckHandler(() => false)
|
||||||
|
protocol.handle('screen-app', async (request) => {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
const headers = {
|
||||||
|
'Content-Security-Policy':
|
||||||
|
"default-src 'self' screen-app:; script-src 'self'; style-src 'self' 'unsafe-inline'; media-src screen-app:; connect-src 'self'; frame-ancestors 'none'",
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
|
}
|
||||||
|
if (url.host !== 'app' || request.method !== 'GET') return new Response(null, { status: 404 })
|
||||||
|
if (url.pathname.startsWith('/media/')) {
|
||||||
|
const media =
|
||||||
|
url.pathname === '/media/audio'
|
||||||
|
? ['audio.wav', 'audio/wav']
|
||||||
|
: url.pathname === '/media/video'
|
||||||
|
? ['video.mp4', 'video/mp4']
|
||||||
|
: null
|
||||||
|
if (!media) return new Response(null, { status: 404 })
|
||||||
|
const filename = join(
|
||||||
|
app.isPackaged ? process.resourcesPath : app.getAppPath(),
|
||||||
|
'fixtures',
|
||||||
|
media[0],
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
const info = await stat(filename)
|
||||||
|
const rangeHeader = request.headers.get('range')
|
||||||
|
const range = parseRange(rangeHeader, info.size)
|
||||||
|
if (!range)
|
||||||
|
return new Response(null, {
|
||||||
|
status: 416,
|
||||||
|
headers: { 'Content-Range': `bytes */${info.size}` },
|
||||||
|
})
|
||||||
|
const stream = createReadStream(filename, range)
|
||||||
|
request.signal.addEventListener('abort', () => stream.destroy(), { once: true })
|
||||||
|
const responseHeaders: Record<string, string> = {
|
||||||
|
...headers,
|
||||||
|
'Content-Type': media[1],
|
||||||
|
'Accept-Ranges': 'bytes',
|
||||||
|
'Content-Length': String(range.end - range.start + 1),
|
||||||
|
}
|
||||||
|
if (rangeHeader)
|
||||||
|
responseHeaders['Content-Range'] = `bytes ${range.start}-${range.end}/${info.size}`
|
||||||
|
return new Response(Readable.toWeb(stream) as ReadableStream<Uint8Array>, {
|
||||||
|
status: rangeHeader ? 206 : 200,
|
||||||
|
headers: responseHeaders,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return new Response('Сначала выполните npm run media:generate', { status: 404 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const path = url.pathname
|
||||||
|
if (path !== '/index.html' && !/^\/assets\/[a-zA-Z0-9_.-]+$/.test(path))
|
||||||
|
return new Response(null, { status: 404 })
|
||||||
|
try {
|
||||||
|
const { readFile } = await import('node:fs/promises')
|
||||||
|
const content = await readFile(join(rendererRoot(), path.slice(1)))
|
||||||
|
const type = path.endsWith('.js')
|
||||||
|
? 'text/javascript'
|
||||||
|
: path.endsWith('.css')
|
||||||
|
? 'text/css'
|
||||||
|
: 'text/html; charset=utf-8'
|
||||||
|
return new Response(content, { headers: { ...headers, 'Content-Type': type } })
|
||||||
|
} catch {
|
||||||
|
return new Response(null, { status: 404 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
control = new BrowserWindow({ ...windowOptions(), width: 1000, height: 800 })
|
||||||
|
secure(control)
|
||||||
|
ipcMain.handle('state', (event) => {
|
||||||
|
if (
|
||||||
|
![control?.webContents, output?.webContents].includes(event.sender) ||
|
||||||
|
event.senderFrame !== event.sender.mainFrame
|
||||||
|
)
|
||||||
|
throw new Error('Недоступно')
|
||||||
|
return controller.state
|
||||||
|
})
|
||||||
|
ipcMain.handle('host', (event) => {
|
||||||
|
requireControl(event)
|
||||||
|
return host()
|
||||||
|
})
|
||||||
|
ipcMain.handle('show', (event, id: unknown) => {
|
||||||
|
requireControl(event)
|
||||||
|
if (typeof id !== 'number') throw new Error('Некорректный дисплей')
|
||||||
|
show(id)
|
||||||
|
})
|
||||||
|
ipcMain.handle('command', (event, command: unknown) => {
|
||||||
|
requireControl(event)
|
||||||
|
if (!output) throw new Error('Сначала откройте окно показа')
|
||||||
|
controller.command(command)
|
||||||
|
})
|
||||||
|
ipcMain.on('report', (event, report: unknown) => {
|
||||||
|
if (
|
||||||
|
event.sender !== output?.webContents ||
|
||||||
|
event.senderFrame !== output.webContents.mainFrame
|
||||||
|
)
|
||||||
|
return
|
||||||
|
try {
|
||||||
|
controller.report(report)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка отчёта проигрывателя', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
ipcMain.handle('lan', async (event, address: unknown) => {
|
||||||
|
requireControl(event)
|
||||||
|
if (
|
||||||
|
address !== null &&
|
||||||
|
(typeof address !== 'string' || !addresses().some((a) => a.address === address))
|
||||||
|
)
|
||||||
|
throw new Error('Некорректный сетевой интерфейс')
|
||||||
|
await lan?.close()
|
||||||
|
lan = null
|
||||||
|
if (address !== null) {
|
||||||
|
const server = new LanServer(
|
||||||
|
rendererRoot(),
|
||||||
|
() => controller.state,
|
||||||
|
(command) => {
|
||||||
|
if (!output) throw new Error('Окно показа закрыто')
|
||||||
|
controller.command(command, 'phone')
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
await server.start(address)
|
||||||
|
lan = server
|
||||||
|
} catch (error) {
|
||||||
|
await server.close()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
ipcMain.handle('approve', (event, id: unknown) => {
|
||||||
|
requireControl(event)
|
||||||
|
if (typeof id !== 'string' || !lan) throw new Error('Пульт отключён')
|
||||||
|
lan.approve(id)
|
||||||
|
})
|
||||||
|
screen.on('display-removed', (_event, display) => {
|
||||||
|
if (display.id === selectedDisplay) output?.close()
|
||||||
|
})
|
||||||
|
control.on('closed', () => {
|
||||||
|
app.quit()
|
||||||
|
})
|
||||||
|
await control.loadURL('screen-app://app/index.html')
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error)
|
||||||
|
app.quit()
|
||||||
|
})
|
||||||
|
app.on('window-all-closed', () => app.quit())
|
||||||
|
app.on('before-quit', () => {
|
||||||
|
void lan?.close()
|
||||||
|
})
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { contextBridge, ipcRenderer } from 'electron'
|
||||||
|
import type { Command, State } from '../src/types/screen.js'
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('screenBridge', {
|
||||||
|
command: (command: Command) => ipcRenderer.invoke('command', command),
|
||||||
|
report: (report: unknown) => ipcRenderer.send('report', report),
|
||||||
|
state: () => ipcRenderer.invoke('state'),
|
||||||
|
subscribe: (listener: (state: State) => void) => {
|
||||||
|
const handler = (_event: Electron.IpcRendererEvent, state: State) => listener(state)
|
||||||
|
ipcRenderer.on('state', handler)
|
||||||
|
return () => ipcRenderer.removeListener('state', handler)
|
||||||
|
},
|
||||||
|
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),
|
||||||
|
})
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export function parseRange(
|
||||||
|
header: string | null,
|
||||||
|
size: number,
|
||||||
|
): { start: number; end: number } | null {
|
||||||
|
if (!Number.isSafeInteger(size) || size <= 0) return null
|
||||||
|
if (header === null) return { start: 0, end: size - 1 }
|
||||||
|
const match = /^bytes=(\d*)-(\d*)$/.exec(header)
|
||||||
|
if (!match || (!match[1] && !match[2])) return null
|
||||||
|
const start = match[1] ? Number(match[1]) : Math.max(0, size - Number(match[2]))
|
||||||
|
const end = match[1] && match[2] ? Math.min(Number(match[2]), size - 1) : size - 1
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(start) ||
|
||||||
|
!Number.isSafeInteger(end) ||
|
||||||
|
start > end ||
|
||||||
|
start >= size ||
|
||||||
|
end < 0
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
return { start, end }
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import vue from 'eslint-plugin-vue'
|
||||||
|
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
|
||||||
|
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
|
||||||
|
|
||||||
|
export default defineConfigWithVueTs(
|
||||||
|
{ ignores: ['dist/**', 'dist-electron/**', 'release/**', 'scripts/**', '*.mjs'] },
|
||||||
|
vue.configs['flat/essential'],
|
||||||
|
vueTsConfigs.recommended,
|
||||||
|
skipFormatting,
|
||||||
|
)
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Вечерний детектив — стенд</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+7855
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,86 @@
|
|||||||
|
{
|
||||||
|
"name": "evening-detective-screen",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Локальный стенд приложения площадки",
|
||||||
|
"author": "Вечерний детектив",
|
||||||
|
"main": "dist-electron/electron/main.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "npm run build && electron .",
|
||||||
|
"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",
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.5.0",
|
||||||
|
"naive-ui": "^2.44.0",
|
||||||
|
"ws": "^8.18.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",
|
||||||
|
"eslint": "^10.0.0",
|
||||||
|
"eslint-plugin-vue": "^10.0.0",
|
||||||
|
"@vue/eslint-config-typescript": "^14.0.0",
|
||||||
|
"@vue/eslint-config-prettier": "^10.0.0",
|
||||||
|
"stylelint": "^17.0.0",
|
||||||
|
"stylelint-config-standard-scss": "^17.0.0",
|
||||||
|
"stylelint-order": "^8.0.0",
|
||||||
|
"postcss-html": "^1.8.0",
|
||||||
|
"prettier": "^3.0.0"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"appId": "ru.eveningdetective.screen",
|
||||||
|
"productName": "Evening Detective Screen",
|
||||||
|
"directories": {
|
||||||
|
"output": "release"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist/**",
|
||||||
|
"dist-electron/**",
|
||||||
|
"package.json",
|
||||||
|
"!dist-electron/**/*.test.js"
|
||||||
|
],
|
||||||
|
"extraResources": [
|
||||||
|
{
|
||||||
|
"from": "fixtures",
|
||||||
|
"to": "fixtures"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"mac": {
|
||||||
|
"target": [
|
||||||
|
"zip"
|
||||||
|
],
|
||||||
|
"minimumSystemVersion": "13.0",
|
||||||
|
"category": "public.app-category.entertainment"
|
||||||
|
},
|
||||||
|
"win": {
|
||||||
|
"target": [
|
||||||
|
"zip"
|
||||||
|
],
|
||||||
|
"signAndEditExecutable": false
|
||||||
|
},
|
||||||
|
"linux": {
|
||||||
|
"target": [
|
||||||
|
"tar.gz"
|
||||||
|
],
|
||||||
|
"category": "AudioVideo"
|
||||||
|
},
|
||||||
|
"artifactName": "${name}-${version}-${os}-${arch}.${ext}"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=24"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
const { mkdirSync } = require('node:fs')
|
||||||
|
const { spawnSync } = require('node:child_process')
|
||||||
|
mkdirSync('fixtures', { recursive: true })
|
||||||
|
const jobs = [
|
||||||
|
[
|
||||||
|
'-f',
|
||||||
|
'lavfi',
|
||||||
|
'-i',
|
||||||
|
'sine=frequency=1000:sample_rate=48000:duration=60',
|
||||||
|
'-af',
|
||||||
|
'volume=0.1',
|
||||||
|
'-c:a',
|
||||||
|
'pcm_s16le',
|
||||||
|
'fixtures/audio.wav',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'-f',
|
||||||
|
'lavfi',
|
||||||
|
'-i',
|
||||||
|
'testsrc2=size=1280x720:rate=30:duration=60',
|
||||||
|
'-f',
|
||||||
|
'lavfi',
|
||||||
|
'-i',
|
||||||
|
'sine=frequency=1000:sample_rate=48000:duration=60',
|
||||||
|
'-af',
|
||||||
|
'volume=0.1',
|
||||||
|
'-c:v',
|
||||||
|
'libx264',
|
||||||
|
'-preset',
|
||||||
|
'fast',
|
||||||
|
'-pix_fmt',
|
||||||
|
'yuv420p',
|
||||||
|
'-c:a',
|
||||||
|
'aac',
|
||||||
|
'-movflags',
|
||||||
|
'+faststart',
|
||||||
|
'-shortest',
|
||||||
|
'fixtures/video.mp4',
|
||||||
|
],
|
||||||
|
]
|
||||||
|
for (const args of jobs) {
|
||||||
|
const result = spawnSync('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-y', ...args], {
|
||||||
|
stdio: 'inherit',
|
||||||
|
})
|
||||||
|
if (result.error)
|
||||||
|
console.error('Установите ffmpeg с libx264 и добавьте в PATH:', result.error.message)
|
||||||
|
if (result.error || result.status !== 0) process.exit(1)
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const { app, BrowserWindow, screen } = require('electron')
|
||||||
|
const root = require('node:path').resolve(__dirname, '..')
|
||||||
|
app.setAppPath(root)
|
||||||
|
app.setPath(
|
||||||
|
'userData',
|
||||||
|
require('node:fs').mkdtempSync(
|
||||||
|
require('node:path').join(require('node:os').tmpdir(), 'screen-smoke-'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const delay = (ms) => new Promise((r) => setTimeout(r, ms))
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
const original = screen.getAllDisplays.bind(screen)
|
||||||
|
screen.getAllDisplays = () => [
|
||||||
|
...original(),
|
||||||
|
{
|
||||||
|
...original()[0],
|
||||||
|
id: 424242,
|
||||||
|
label: 'Synthetic test display',
|
||||||
|
bounds: { x: 1280, y: 0, width: 1280, height: 720 },
|
||||||
|
size: { width: 1280, height: 720 },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
require(root + '/dist-electron/electron/main.js')
|
||||||
|
async function poll(check, label) {
|
||||||
|
for (let i = 0; i < 150; i++) {
|
||||||
|
const result = await check()
|
||||||
|
if (result) return result
|
||||||
|
await delay(100)
|
||||||
|
}
|
||||||
|
throw new Error('Timeout: ' + label)
|
||||||
|
}
|
||||||
|
app.whenReady().then(async () => {
|
||||||
|
try {
|
||||||
|
const control = await poll(() => BrowserWindow.getAllWindows()[0], 'control')
|
||||||
|
control.webContents.on('console-message', (_event, details) =>
|
||||||
|
console.log('renderer:', details.message),
|
||||||
|
)
|
||||||
|
await poll(
|
||||||
|
async () =>
|
||||||
|
!control.webContents.isLoading() &&
|
||||||
|
(await control.webContents.executeJavaScript(
|
||||||
|
'!!window.screenBridge && !!document.querySelector("h1")',
|
||||||
|
)),
|
||||||
|
'control UI',
|
||||||
|
)
|
||||||
|
const evaluate = (s) => control.webContents.executeJavaScript(s)
|
||||||
|
assert.equal(await evaluate('typeof window.require'), 'undefined')
|
||||||
|
await evaluate('window.screenBridge.show(424242)')
|
||||||
|
const output = await poll(
|
||||||
|
() => BrowserWindow.getAllWindows().find((w) => w !== control),
|
||||||
|
'output',
|
||||||
|
)
|
||||||
|
output.webContents.on('console-message', (_event, details) =>
|
||||||
|
console.log('output:', details.message),
|
||||||
|
)
|
||||||
|
await poll(
|
||||||
|
async () =>
|
||||||
|
!output.webContents.isLoading() &&
|
||||||
|
(await output.webContents.executeJavaScript(
|
||||||
|
'document.querySelector("video")?.readyState >= 1',
|
||||||
|
)),
|
||||||
|
'metadata',
|
||||||
|
)
|
||||||
|
const range = await output.webContents.executeJavaScript(
|
||||||
|
`fetch('screen-app://app/media/video', {headers: {Range: 'bytes=100-199'}}).then(async r => ({status:r.status, size:(await r.arrayBuffer()).byteLength, range:r.headers.get('content-range')}))`,
|
||||||
|
)
|
||||||
|
assert.equal(range.status, 206)
|
||||||
|
assert.equal(range.size, 100)
|
||||||
|
const command = async (id, action, media = 'audio', position = 0) => {
|
||||||
|
await evaluate(
|
||||||
|
`window.screenBridge.command(${JSON.stringify({ id, action, media, position })})`,
|
||||||
|
)
|
||||||
|
return poll(async () => {
|
||||||
|
const s = await evaluate('window.screenBridge.state()')
|
||||||
|
if (s.status === 'error') throw new Error(s.error)
|
||||||
|
return s.status !== 'pending' && s.command.id === id && s
|
||||||
|
}, id)
|
||||||
|
}
|
||||||
|
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')
|
||||||
|
assert.equal((await command('video-play', 'play', 'video')).status, 'playing')
|
||||||
|
await command('video-seek', 'seek', 'video', 30)
|
||||||
|
const position = await output.webContents.executeJavaScript(
|
||||||
|
'document.querySelector("video").currentTime',
|
||||||
|
)
|
||||||
|
assert.ok(position >= 29.9 && position < 35, 'video seek actual position')
|
||||||
|
await command('video-pause', 'pause', 'video')
|
||||||
|
assert.equal(
|
||||||
|
await output.webContents.executeJavaScript('document.querySelector("audio").paused'),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
await output.webContents.executeJavaScript('document.querySelector("video").paused'),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
await assert.rejects(
|
||||||
|
output.webContents.executeJavaScript(
|
||||||
|
`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}));})()`,
|
||||||
|
)
|
||||||
|
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()
|
||||||
|
output.close()
|
||||||
|
await evaluate('window.screenBridge.show(424242)')
|
||||||
|
const reopened = await poll(
|
||||||
|
() => BrowserWindow.getAllWindows().find((w) => w !== control),
|
||||||
|
'reopened output',
|
||||||
|
)
|
||||||
|
await poll(
|
||||||
|
async () =>
|
||||||
|
!reopened.webContents.isLoading() &&
|
||||||
|
(await reopened.webContents.executeJavaScript('!!document.querySelector("video")')),
|
||||||
|
'reopened ready',
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
await reopened.webContents.executeJavaScript('document.querySelector("video").paused'),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
console.log(
|
||||||
|
'SCREEN_SMOKE_PASS',
|
||||||
|
JSON.stringify({
|
||||||
|
range,
|
||||||
|
seek: position,
|
||||||
|
samples: (await evaluate('window.screenBridge.state()')).samples,
|
||||||
|
electron: process.versions.electron,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
app.exit(0)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('SCREEN_SMOKE_FAIL', error)
|
||||||
|
app.exit(1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setTimeout(() => {
|
||||||
|
console.error('Global timeout')
|
||||||
|
app.exit(1)
|
||||||
|
}, 45000)
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { NConfigProvider, NMessageProvider, darkTheme } from 'naive-ui'
|
||||||
|
import ControlPage from './components/ControlPage.vue'
|
||||||
|
import OutputPage from './components/OutputPage.vue'
|
||||||
|
|
||||||
|
const hasBridge = !!window.screenBridge
|
||||||
|
const isOutput = new URLSearchParams(location.search).has('output')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-config-provider :theme="darkTheme">
|
||||||
|
<n-message-provider>
|
||||||
|
<OutputPage v-if="isOutput && hasBridge" />
|
||||||
|
<ControlPage v-else />
|
||||||
|
</n-message-provider>
|
||||||
|
</n-config-provider>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/* Палитра ../evening_detective_frontend/src/assets/base.css. */
|
||||||
|
:root {
|
||||||
|
--brand-background: #111;
|
||||||
|
--brand-text: #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--brand-background);
|
||||||
|
color: var(--brand-text);
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { NAlert, NButton, NCard, NInput, 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 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 statusLabels: Record<State['status'], string> = {
|
||||||
|
idle: 'Ожидание',
|
||||||
|
pending: 'Команда отправлена',
|
||||||
|
playing: 'Воспроизведение подтверждено',
|
||||||
|
paused: 'Пауза',
|
||||||
|
stopped: 'Остановлено',
|
||||||
|
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
|
||||||
|
try {
|
||||||
|
await action()
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : 'Не удалось выполнить действие')
|
||||||
|
} finally {
|
||||||
|
isBusy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function acceptState(value: State) {
|
||||||
|
if (!state.value || value.revision >= state.value.revision) state.value = value
|
||||||
|
}
|
||||||
|
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 (!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()
|
||||||
|
})
|
||||||
|
let isTicking = false
|
||||||
|
timer = setInterval(() => {
|
||||||
|
if (isTicking) return
|
||||||
|
isTicking = true
|
||||||
|
void tick()
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
message.error(error instanceof Error ? error.message : 'Ошибка связи')
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
isTicking = false
|
||||||
|
})
|
||||||
|
}, 1000)
|
||||||
|
})
|
||||||
|
onUnmounted(() => {
|
||||||
|
isDisposed = true
|
||||||
|
clearInterval(timer)
|
||||||
|
unsubscribe?.()
|
||||||
|
socket?.close()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="control">
|
||||||
|
<h1>Вечерний детектив · SCREEN-001</h1>
|
||||||
|
<n-alert type="info"
|
||||||
|
>Технический стенд: синтетические медиа, два окна и локальная связь.</n-alert
|
||||||
|
>
|
||||||
|
<n-card v-if="bridge" title="Дисплей показа">
|
||||||
|
<n-space vertical>
|
||||||
|
<n-select
|
||||||
|
v-model:value="display"
|
||||||
|
:options="host.displays.map((d) => ({ label: d.label, value: d.id }))"
|
||||||
|
placeholder="Выберите внешний дисплей"
|
||||||
|
:input-props="{ 'aria-label': 'Дисплей показа' }"
|
||||||
|
/>
|
||||||
|
<n-alert v-if="!host.displays.length" type="warning"
|
||||||
|
>Подключите внешний дисплей и включите расширенный рабочий стол.</n-alert
|
||||||
|
>
|
||||||
|
<n-button :disabled="display === null || isBusy" @click="run(() => bridge!.show(display!))"
|
||||||
|
>Открыть показ</n-button
|
||||||
|
>
|
||||||
|
</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-select
|
||||||
|
v-model:value="media"
|
||||||
|
:options="[
|
||||||
|
{ label: 'Аудио · WAV PCM 48 кГц', value: 'audio' },
|
||||||
|
{ label: 'Видео · H.264 + AAC, 720p30', value: 'video' },
|
||||||
|
]"
|
||||||
|
: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-space>
|
||||||
|
<label for="position">Позиция, секунды</label>
|
||||||
|
<n-input-number
|
||||||
|
v-model:value="position"
|
||||||
|
:input-props="{ id: 'position' }"
|
||||||
|
:min="0"
|
||||||
|
:max="60"
|
||||||
|
/>
|
||||||
|
<n-button :disabled="!isConnected || isBusy" @click="send('seek')">Перемотать</n-button>
|
||||||
|
<p role="status">
|
||||||
|
{{ state ? statusLabels[state.status] : 'Нет состояния' }} · ревизия
|
||||||
|
{{ state?.revision ?? 0 }}
|
||||||
|
</p>
|
||||||
|
<n-alert v-if="state?.error" type="error">{{ state.error }}</n-alert>
|
||||||
|
<p>{{ metrics }}</p>
|
||||||
|
<p>
|
||||||
|
Замеры: приём команды контроллером → подтверждение проигрывателя. Они не измеряют сеть
|
||||||
|
телефона, физический звук или первый кадр проектора.
|
||||||
|
</p>
|
||||||
|
</n-space>
|
||||||
|
</n-card>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.control {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
margin: auto;
|
||||||
|
max-width: 900px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
import type { Command, MediaKind, State } from '../types/screen'
|
||||||
|
|
||||||
|
const audio = ref<HTMLAudioElement | null>(null)
|
||||||
|
const video = ref<HTMLVideoElement | null>(null)
|
||||||
|
const kind = ref<MediaKind>('audio')
|
||||||
|
const bridge = window.screenBridge!
|
||||||
|
let unsubscribe: (() => void) | undefined
|
||||||
|
let generation = 0
|
||||||
|
let lastRevision = -1
|
||||||
|
let commandId = ''
|
||||||
|
let lastCommand: Command | null = null
|
||||||
|
|
||||||
|
async function metadata(media: HTMLMediaElement) {
|
||||||
|
if (media.readyState >= 1) return
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const finish = (error?: Error) => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
media.removeEventListener('loadedmetadata', ready)
|
||||||
|
media.removeEventListener('error', failed)
|
||||||
|
if (error) reject(error)
|
||||||
|
else resolve()
|
||||||
|
}
|
||||||
|
const ready = () => finish()
|
||||||
|
const failed = () => finish(new Error('Не удалось прочитать тестовое медиа'))
|
||||||
|
const timeout = setTimeout(() => finish(new Error('Медиа не подготовлено за 10 секунд')), 10000)
|
||||||
|
media.addEventListener('loadedmetadata', ready, { once: true })
|
||||||
|
media.addEventListener('error', failed, { once: true })
|
||||||
|
if (media.error) failed()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
async function apply(state: State) {
|
||||||
|
if (state.revision <= lastRevision) return
|
||||||
|
lastRevision = state.revision
|
||||||
|
const command = state.command
|
||||||
|
if (state.status !== 'pending' || !command || command.id === commandId) return
|
||||||
|
commandId = command.id
|
||||||
|
lastCommand = command
|
||||||
|
const current = ++generation
|
||||||
|
const media = command.media === 'audio' ? audio.value : video.value
|
||||||
|
const other = command.media === 'audio' ? video.value : audio.value
|
||||||
|
other?.pause()
|
||||||
|
kind.value = command.media
|
||||||
|
if (!media) return
|
||||||
|
try {
|
||||||
|
if (command.action === 'pause' || command.action === 'stop') media.pause()
|
||||||
|
await metadata(media)
|
||||||
|
if (current !== generation) return
|
||||||
|
if (command.action === 'play' || command.action === 'stop') media.currentTime = 0
|
||||||
|
if (command.action === 'seek') media.currentTime = Math.min(command.position, media.duration)
|
||||||
|
if (command.action === 'play') {
|
||||||
|
await media.play()
|
||||||
|
if (current !== generation) return
|
||||||
|
}
|
||||||
|
const status = command.action === 'stop' ? 'stopped' : media.paused ? 'paused' : 'playing'
|
||||||
|
bridge.report({ id: command.id, status })
|
||||||
|
} catch (error) {
|
||||||
|
if (current === generation)
|
||||||
|
bridge.report({
|
||||||
|
id: command.id,
|
||||||
|
status: 'error',
|
||||||
|
error: error instanceof Error ? error.message : 'Ошибка воспроизведения',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function activeMedia() {
|
||||||
|
return lastCommand?.media === 'audio' ? audio.value : video.value
|
||||||
|
}
|
||||||
|
function ended(event: Event) {
|
||||||
|
const media = activeMedia()
|
||||||
|
if (lastCommand && event.target === media && media?.ended)
|
||||||
|
bridge.report({ id: lastCommand.id, status: 'ended' })
|
||||||
|
}
|
||||||
|
function failed(event: Event) {
|
||||||
|
if (lastCommand && event.target === activeMedia())
|
||||||
|
bridge.report({
|
||||||
|
id: lastCommand.id,
|
||||||
|
status: 'error',
|
||||||
|
error: 'Ошибка локального медиа; проверьте наличие fixtures',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onMounted(async () => {
|
||||||
|
for (const media of [audio.value, video.value]) if (media) media.volume = 0.35
|
||||||
|
unsubscribe = bridge.subscribe((state) => {
|
||||||
|
void apply(state)
|
||||||
|
})
|
||||||
|
await apply(await bridge.state())
|
||||||
|
})
|
||||||
|
onUnmounted(() => {
|
||||||
|
generation++
|
||||||
|
unsubscribe?.()
|
||||||
|
audio.value?.pause()
|
||||||
|
video.value?.pause()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="output">
|
||||||
|
<audio
|
||||||
|
ref="audio"
|
||||||
|
:src="'screen-app://app/media/audio'"
|
||||||
|
preload="auto"
|
||||||
|
@ended="ended"
|
||||||
|
@error="failed"
|
||||||
|
/>
|
||||||
|
<video
|
||||||
|
v-show="kind === 'video'"
|
||||||
|
ref="video"
|
||||||
|
:src="'screen-app://app/media/video'"
|
||||||
|
preload="auto"
|
||||||
|
@ended="ended"
|
||||||
|
@error="failed"
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.output {
|
||||||
|
background: var(--brand-background);
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
video {
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
import type { ScreenBridge } from './types/screen'
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
screenBridge?: ScreenBridge
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import './assets/base.css'
|
||||||
|
|
||||||
|
createApp(App).mount('#app')
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
export type MediaKind = 'audio' | 'video'
|
||||||
|
export type Action = 'play' | 'pause' | 'stop' | 'seek'
|
||||||
|
export interface Command {
|
||||||
|
id: string
|
||||||
|
action: Action
|
||||||
|
media: MediaKind
|
||||||
|
position: number
|
||||||
|
}
|
||||||
|
export interface State {
|
||||||
|
revision: number
|
||||||
|
command: Command | null
|
||||||
|
status: 'idle' | 'pending' | 'playing' | 'paused' | 'stopped' | 'ended' | 'error'
|
||||||
|
error: string
|
||||||
|
samples: { media: MediaKind; origin: 'computer' | 'phone'; milliseconds: number }[]
|
||||||
|
}
|
||||||
|
export interface DisplayInfo {
|
||||||
|
id: number
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
export interface HostInfo {
|
||||||
|
displays: DisplayInfo[]
|
||||||
|
lan: { address: string; family: string }[]
|
||||||
|
url: string
|
||||||
|
code: string
|
||||||
|
pending: string[]
|
||||||
|
}
|
||||||
|
export interface ScreenBridge {
|
||||||
|
command: (command: Command) => Promise<void>
|
||||||
|
report: (report: { id: string; status: State['status']; error?: string }) => void
|
||||||
|
state: () => Promise<State>
|
||||||
|
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>
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: ['stylelint-order'],
|
||||||
|
extends: ['stylelint-config-standard-scss'],
|
||||||
|
overrides: [{ files: ['**/*.vue'], customSyntax: 'postcss-html' }],
|
||||||
|
rules: { 'order/properties-alphabetical-order': true },
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"rootDir": ".",
|
||||||
|
"outDir": "dist-electron",
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["electron/**/*.ts", "src/types/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({ base: './', plugins: [vue()] })
|
||||||
Reference in New Issue
Block a user