From 40456eebf18575c33a34c6359085447d925e7145 Mon Sep 17 00:00:00 2001 From: Fedorov Vladimir Date: Sat, 17 Oct 2026 20:16:24 +0700 Subject: [PATCH] up --- COMPLETED_TASKS.md | 67 +++ README.md | 39 +- TASKS.md | 31 +- docs/SCREEN-006_ACCEPTANCE.md | 81 ++++ electron/controller.ts | 12 +- electron/main.ts | 50 ++- electron/online.test.ts | 543 ++++++++++++++++++++++ electron/online.ts | 697 +++++++++++++++++++++++++++++ electron/preload.ts | 4 + electron/remote-state.ts | 224 +++++++++ scripts/online-smoke.cjs | 167 +++++++ scripts/smoke.cjs | 14 +- src/components/ConnectionPanel.vue | 198 ++++++++ src/components/ControlPage.vue | 26 ++ src/types/online.ts | 19 + src/types/screen.ts | 4 + 16 files changed, 2122 insertions(+), 54 deletions(-) create mode 100644 docs/SCREEN-006_ACCEPTANCE.md create mode 100644 electron/online.test.ts create mode 100644 electron/online.ts create mode 100644 electron/remote-state.ts create mode 100644 scripts/online-smoke.cjs create mode 100644 src/components/ConnectionPanel.vue create mode 100644 src/types/online.ts diff --git a/COMPLETED_TASKS.md b/COMPLETED_TASKS.md index b959bef..a5ac2d4 100644 --- a/COMPLETED_TASKS.md +++ b/COMPLETED_TASKS.md @@ -107,3 +107,70 @@ Инструкция ручной проверки — docs/SCREEN-005_ACCEPTANCE.md, запуск npm run acceptance. - Следующий шаг: SCREEN-006 — существующая авторизация, выбор игры/скачивание через интерфейс, снимок результатов и получение команд существующего сервера. + +## SCREEN-006 — Подключение игры и синхронизация с сервером + +- Статус: `DONE` +- Зависимости: SCREEN-002, SCREEN-005 (DONE, см. COMPLETED_TASKS.md). +- Цель: выбирать доступную организатору игру онлайн и сохранять автономность показа. +- Объём: существующая авторизация, список игр, загрузка пакета, снимок результатов, + индикатор связи, проверка новой версии материалов, синхронизация статуса показа. + Телефон отправляет команды существующему серверу сайта; desktop получает + обновления через экранную сессию и GetScreenState (существующий polling 1000 мс). + Серверные команды применяются локальным контроллером к подготовленным файлам; + повторная версия не применяется дважды. Прямого подключения телефона к desktop нет. + Уточнить минимальный серверный контракт по фактической реализации. +- Готовность: готовая игра открывается после перезапуска офлайн; секреты хранятся + средствами ОС, не в пакете; истечение сессии не обрывает уже подготовленный + локальный показ; обновления активируются явно. Результаты имеют время снимка. + Возврат сети не повторяет устаревшие команды поверх локальных действий. + При доступном сервере телефон управляет показом через существующий сайт; без сети + воспроизведение и пульт компьютера продолжают работать. +- Проверка: сценарии offline/reconnect/истёкшей сессии/обновления игры; проверки + затронутых репозиториев; существующий браузерный экран продолжает работать. +- Не входит: полноценная офлайн-игра, LAN-сервер и отдельный мобильный пульт desktop. +- Дата: 2026-10-17. Реализация только screen; бэкенд, сайт и protobuf не менялись. +- Результат: вход по email/паролю, проверка существующих прав, список игр, + единое обновление JWT для конкурентных запросов, выход без прерывания показа. + Токены и экранная сессия остаются в main; сохранение через safeStorage отдельно + от пакетов. При недоступном шифровании ОС (включая Linux basic_text) вход только + в памяти. Пароль не сохраняется, отказ в правах удаляет сохранённый вход. +- Материалы: загрузка/прогресс/отмена/проверка описания/повторная загрузка/удаление + через существующий PackageStore. Выбранная игра обновляется только при закрытом + показе; на время подготовки запрещены её открытие и смена выбора. Ошибка/отмена + сохраняют прежний пакет; новая версия включается остановленной. Ключ выбранного + пакета сохраняется, после перезапуска проверенная игра доступна офлайн без + автоматического открытия окна и звука. Снимок результатов сохраняется отдельно + по ключу пакета, содержит только поля таблицы и время получения; ошибка API + оставляет последний успешный снимок. Сервер выдаёт результаты после finish. +- Управление: по выбору владельца общий пульт телефона и компьютера. Создание и + привязка экранной сессии существующим API; GetScreenState раз в 1000 мс только + при открытом показе, без параллельных запросов. Первая версия при подключении, + повторном открытии и восстановлении сети — исходная отметка без старых команд. + Локальные действия немедленны и не рассылаются другим экранам. Версии int64 + сравниваются без потери точности, повторы и откат версии не повторяют команды; + устаревшие ответы от другого окна/пакета/сессии отклоняются. Звук учитывается + независимо от версии, первоначальное назначение звука не запускает медиа. + Неподготовленные плейлисты не стримятся; переход музыки локальный, серверный + AdvanceScreenMusic вызывается однократно только для соответствующего трека. +- Контракт: сервер не принимает точные подтверждения проигрывания/ошибки от экрана. + На сайте сохраняется существующий heartbeat connected/disconnected; фактические + подтверждения видны в desktop. Последнее состояние может объединять несколько + команд между опросами. metadataVersion не обнаруживает замену байтов по тому же + URL, для этого нужна повторная загрузка. После неопределённого исхода перехода + музыки повторного POST нет. Права и публичная выдача файлов остались прежними. +- Проверки: type-check, ESLint, Stylelint, 63 теста (17 новых), сборка и GUI smoke — + PASS. GUI с реальным тестовым HTTP-сервером проверил форму входа, загрузку, + привязку, серверный play локального WAV, запрет обновления во время показа, + локальную паузу при повторном опросе, offline/reconnect без старого STOP, + новую команду после reconnect, результаты/выход и запреты IPC для окна показа. + Старые сценарии всех восьми режимов, медиа, Range и preview также прошли. + git diff --check — PASS; новые зависимости не добавлены, commit/push не выполнялись. +- Ограничения: тестовый сервер воспроизводит текущий JSON-контракт; рабочий сервер, + реальные игровые файлы и прежний браузерный экран вручную не проверялись. + GUI выполнен на Linux x64 с синтетическим внешним дисплеем и тестовыми флагами + no-sandbox/mute-audio/disable-gpu. Хранилища секретов остальных ОС, физический + звук/проектор и матрица платформ остаются для ручной приёмки; релизные архивы + не пересобирались. SCREEN-001/SCREEN-004 сохраняют IN_PROGRESS. +- Инструкция: docs/SCREEN-006_ACCEPTANCE.md. Следующий этап: SCREEN-007 — + устойчивость при отключении дисплея, управление сном и восстановление окон. diff --git a/README.md b/README.md index 2c916b1..4746fb6 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,10 @@ # Вечерний детектив — приложение площадки -Статус: SCREEN-005 реализована; SCREEN-001 и SCREEN-004 остаются IN_PROGRESS -до аппаратной приёмки. Режимы показа и пульт работают с локальными пакетами. -SCREEN-002 и SCREEN-003 завершены: описание пакета через существующий API, -загрузка, проверка, восстановление и локальная выдача файлов. -Управление с телефона идёт через существующий сервер сайта; desktop получает -обновления с сервера. Прямое LAN-подключение не используется. +Статус: SCREEN-006 реализована — вход, список игр, загрузка через пульт, +снимки результатов и команды существующего сервера. SCREEN-001 и SCREEN-004 +остаются IN_PROGRESS до аппаратной приёмки. Подготовленная игра восстанавливается +офлайн без автоматического открытия показа и запуска звука. +Управление с телефона идёт через существующий сервер сайта; прямого LAN-подключения нет. Цель: ведущий управляет показом на компьютере с macOS, Windows или Linux, а подключённый монитор или проектор показывает материалы игрокам. Все материалы показа заранее находятся @@ -22,8 +21,8 @@ macOS Intel/Apple Silicon, Windows x64/ARM64 и Linux x64/ARM64. общего правила хранения задач в `../docs/agent`. Не дублировать их там. Идентификаторы `SCREEN-*` относятся только к этому приложению. -Следующий этап: SCREEN-006 — авторизация, загрузка через интерфейс и серверная -синхронизация; аппаратная проверка SCREEN-004 остаётся открытой. +Следующий этап: SCREEN-007 — устойчивость на площадке; аппаратная проверка +SCREEN-001/SCREEN-004 остаётся открытой. Новые задачи: `Новая задача: <описание>`. Commit и push выполняет только пользователь. Вложенные `.git` не изменять. @@ -45,12 +44,20 @@ npm run dev его и откройте показ. Аудио и видео — синтетические локальные файлы длительностью 60 секунд; бинарники находятся в игнорируемом fixtures/ и не входят в Git. Тестовый режим сохраняет отдельный выбор WAV/видео; игровые пакеты выбираются -в разделе «Материалы показа». Интерфейс загрузки игр и авторизация пока -не реализованы; они относятся к SCREEN-006. +в разделе «Материалы показа». В блоке «Игра и сервер» доступны вход, список игр и загрузка материалов. -Управление телефоном через сервер, подключение игры и авторизация относятся к -SCREEN-006 и в техническом стенде ещё не реализованы. Интернет нужен для получения -команд телефона; локальное воспроизведение и пульт компьютера работают без него. +Для команд телефона войдите, подготовьте и выберите игру, подключите управление +и откройте показ. Первое состояние не запускает накопленные команды; +отправьте новую команду на сайте. После обрыва сети локальный пульт продолжает +работать, первое состояние при восстановлении также пропускается. +[Проверка с существующим сервером и телефоном](docs/SCREEN-006_ACCEPTANCE.md). + +Токены хранятся в основном процессе и сохраняются средствами ОС отдельно от +пакетов. Если защищённое хранилище недоступно, вход действует только до закрытия +приложения. Выход не удаляет подготовленные материалы. Обновление выбранной +игры доступно при закрытом показе; проверка описания не замечает замену байтов +по прежнему URL — используйте повторную загрузку. Результаты доступны после +завершения игры; последний успешный снимок сохраняется с датой для офлайн-показа. Проверки: npm run type-check, npm run lint, npm run lint:css, npm test, npm run build. [Сборки и аппаратная приёмка](docs/SCREEN-001_ACCEPTANCE.md). @@ -63,8 +70,8 @@ token, signal? })` для вызова из основного процесса. Авторизация — существующий Bearer JWT; токен используется только в запросах API, не входит в результат. Редиректы API запрещены, ожидание ограничено 30 секундами; поддерживается отмена через AbortSignal. Ответы с ошибками не выдают частичный пакет. -Интерфейс входа и подключение к пульту остаются в SCREEN-006; сейчас это программный -контракт, новых кнопок в стенде нет. Бэкенд, frontend и protobuf не изменены. +Интерфейс входа и загрузки подключён в SCREEN-006; описание используется основным +процессом без передачи токенов в renderer. Бэкенд, frontend и protobuf не изменены. Результат `ScreenPackage` содержит `formatVersion: 1`, `metadataVersion`, адрес сервера, ID/название игры, ссылки на материалы, шаги плана и связанные плейлисты. @@ -88,7 +95,7 @@ token, signal? })` для вызова из основного процесса. чтобы две копии не изменяли хранилище одновременно. При запуске проверяются манифесты, размеры и SHA-256 всех файлов; повреждённые пакеты не становятся готовыми. -Программный интерфейс (для основного процесса; подключение к UI — SCREEN-006): +Программный интерфейс основного процесса (подключён к UI в SCREEN-006): ```ts const store = new PackageStore(packagesDirectory) diff --git a/TASKS.md b/TASKS.md index 8af5324..367b15c 100644 --- a/TASKS.md +++ b/TASKS.md @@ -1,6 +1,6 @@ # Задачи приложения площадки -SCREEN-001 и SCREEN-004 имеют статус IN_PROGRESS; SCREEN-002, SCREEN-003 и SCREEN-005 +SCREEN-001 и SCREEN-004 имеют статус IN_PROGRESS; SCREEN-002, SCREEN-003, SCREEN-005 и SCREEN-006 завершены и перенесены в архив; остальные активные задачи — TODO. SCREEN-009 (LAN-пульт) отменена владельцем; ID не переиспользовать. Управление телефоном через сервер входит в SCREEN-006. План и общие ограничения: @@ -109,37 +109,10 @@ SCREEN-009 (LAN-пульт) отменена владельцем; ID не пе docs/SCREEN-004_ACCEPTANCE.md на всей матрице, получить результаты владельца. До их получения статус остаётся IN_PROGRESS; перенос в архив не выполняется. -## SCREEN-006 — Подключение игры и синхронизация с сервером - -- Статус: `TODO` -- Зависимости: SCREEN-002, SCREEN-005 (DONE, см. COMPLETED_TASKS.md). -- Цель: выбирать доступную организатору игру онлайн и сохранять автономность показа. -- Объём: существующая авторизация, список игр, загрузка пакета, снимок результатов, - индикатор связи, проверка новой версии материалов, синхронизация статуса показа. - Телефон отправляет команды существующему серверу сайта; desktop получает - обновления через экранную сессию и GetScreenState (существующий polling 1000 мс). - Серверные команды применяются локальным контроллером к подготовленным файлам; - повторная версия не применяется дважды. Прямого подключения телефона к desktop нет. - Уточнить минимальный серверный контракт по фактической реализации. -- Готовность: готовая игра открывается после перезапуска офлайн; секреты хранятся - средствами ОС, не в пакете; истечение сессии не обрывает уже подготовленный - локальный показ; обновления активируются явно. Результаты имеют время снимка. - Возврат сети не повторяет устаревшие команды поверх локальных действий. - При доступном сервере телефон управляет показом через существующий сайт; без сети - воспроизведение и пульт компьютера продолжают работать. -- Проверка: сценарии offline/reconnect/истёкшей сессии/обновления игры; проверки - затронутых репозиториев; существующий браузерный экран продолжает работать. -- Не входит: полноценная офлайн-игра, LAN-сервер и отдельный мобильный пульт desktop. -- Следующий шаг: сверить auth flow и модель сессий; зафиксировать правила - локального владения показом и отображения статуса на сервере. Для результатов - использовать Controller.setResults с ResultsSnapshot (только поля таблицы, - packageKey и capturedAt); получение и сохранение снимка реализовать здесь. - Не менять бэкенд/protobuf без нового прямого запроса владельца. - ## SCREEN-007 — Устойчивость на площадке - Статус: `TODO` -- Зависимости: SCREEN-005, SCREEN-006. +- Зависимости: SCREEN-005, SCREEN-006 (DONE, см. COMPLETED_TASKS.md). - Цель: переживать типовые сбои без потери материалов и случайного показа пульта. - Объём: отключение/возврат дисплея, смена разрешения и fullscreen, проверка аудиовыхода каждой ОС, предотвращение сна во время показа, восстановление окон и diff --git a/docs/SCREEN-006_ACCEPTANCE.md b/docs/SCREEN-006_ACCEPTANCE.md new file mode 100644 index 0000000..fd352c3 --- /dev/null +++ b/docs/SCREEN-006_ACCEPTANCE.md @@ -0,0 +1,81 @@ +# SCREEN-006 — Проверка подключения игры + +Программные проверки используют тестовый HTTP-сервер и синтетические файлы. +Эта инструкция проверяет существующий сервер сайта, реальные игровые материалы +и хранилище секретов установленной ОС. Бэкенд и браузерный экран не изменяются. + +## Подготовка + +1. Подключить внешний дисплей в режиме расширенного рабочего стола. +2. В каталоге приложения выполнить `npm ci`, `npm run media:generate`, + `npm run dev`. Для Linux под Wayland нужен XWayland. +3. В блоке «Игра и сервер» указать адрес сервера без пути, email и пароль + организатора, автора или администратора. Нажать «Войти». +4. Выбрать игру и загрузить материалы. Проверить прогресс и возможность отмены. + После успешной загрузки выбрать игру в «Материалах показа». +5. Если приложение сообщает, что хранилище ОС недоступно, вход действует только + до выхода из приложения. На Linux требуется рабочий libsecret/KWallet; + `basic_text` не используется для сохранения секретов. + +## Команды с телефона + +1. Нажать «Подключить управление выбранной игрой», записать номер экрана. + Открыть показ на внешнем дисплее. Дождаться сообщения о связи. +2. На телефоне открыть существующую страницу управления этой игрой. + Убедиться, что экран появился в её списке. Назначить звук этому экрану + через сайт или одноимённую кнопку приложения; это отключает звук других + экранов игры по существующим правилам сервера. +3. Отправить новую команду смены режима, пуска вступления, паузы, продолжения, + перемотки и громкости. Повторить с заключительным видео. +4. Проверить текст, настройку/пуск/паузу/сброс таймера и музыку: выбор трека, + громкость, повтор и автоматический переход. Все используемые ресурсы должны + быть в скачанном пакете; новый плейлист требует подготовки материалов. +5. Чередовать телефон и компьютер. Локальная пауза не должна отменяться повтором + того же серверного состояния. Новая команда телефона должна применяться. + Команды компьютера действуют только на этот показ и не рассылаются другим экранам. +6. Проверить прежний браузерный экран отдельно: его вход, привязка и команды + должны работать как раньше. Общие команды игры по-прежнему относятся ко всем + её подключённым экранам. + +Опрос идёт раз в секунду, только пока окно показа открыто. Первое состояние при +подключении/повторном открытии становится исходной отметкой: старые команды не +запускаются, назначение звука учитывается при первом подключении. Для показа +отправьте новую команду. Сайт показывает наличие связи; точные подтверждения +проигрывателя и ошибки доступны в пульте приложения, поскольку существующий API +не принимает их от экрана. + +## Обрыв связи, обновления и перезапуск + +1. Во время воспроизведения отключить сеть компьютера. Проверить локальные + play/pause/seek, переход музыки и продолжающийся таймер. +2. При отключённой сети изменить показ локально, затем отправить команду + телефоном. Вернуть сеть: накопленное состояние не должно отменить локальное + действие. Следующая новая команда телефона должна примениться. +3. Проверить истечение пользовательского входа и отдельно недействительную + экранную сессию на тестовом сервере. Показ не прерывается; интерфейс предлагает + повторный вход или подключение управления. Не менять рабочую базу ради проверки. +4. Закрыть показ. Изменить материалы игры на сервере и проверить изменения + в приложении. Явно загрузить новую версию. При открытом показе обновление + выбранной игры должно отклоняться. Ошибка и отмена загрузки сохраняют старую копию. +5. Завершить игру и обновить снимок результатов. Проверить таблицу и время снимка. + Ошибка обновления должна сохранять последний успешный снимок. +6. Закрыть приложение, отключить сеть и запустить снова. Выбранная игра и снимок + результатов доступны; окно показа и звук сами не запускаются. Открыть показ + и проверить локальное воспроизведение. +7. Выход из аккаунта не удаляет материалы и не прерывает локальное воспроизведение, + но отключает получение команд сервера. Удалить невыбранный пакет через интерфейс. + +Проверка метаданных не обнаруживает новые байты файла по прежнему URL: +для этого нужна повторная загрузка. Сервер хранит последнее состояние, поэтому +несколько команд между опросами могут объединиться. После неопределённого исхода +запроса перехода музыки приложение не повторяет его автоматически. Секреты +находятся отдельно от пакетов в зашифрованном файле `connection/credentials.bin` +в userData; пароль не сохраняется. Снимки содержат только поля публичной таблицы. + +## Фиксация результата + +Записать ОС/архитектуру, версию Electron/приложения, адрес и версию сервера, +реальный внешний дисплей, форматы файлов, доступность хранилища ОС и результаты +каждого пункта. Не прикладывать токены, пароль или содержимое файла секретов. +Аппаратные измерения звука/кадра остаются в SCREEN-001/SCREEN-004; эта проверка +не заменяет их и не подтверждает p95 на M1. diff --git a/electron/controller.ts b/electron/controller.ts index 040c071..b188f88 100644 --- a/electron/controller.ts +++ b/electron/controller.ts @@ -159,6 +159,7 @@ function initialTimer(): TimerState { } } export class Controller { + onMusicEnded: ((report: Report) => void) | null = null state: State = { session: randomUUID(), sequence: 0, @@ -330,6 +331,11 @@ export class Controller { tick() { if (this.updateTimer()) this.broadcast() } + synchronizeTimer(timer: Omit) { + this.state.timer = { ...timer, revision: this.state.timer.revision + 1 } + this.timerDeadline = timer.status === 'running' ? this.now() + timer.remainingMs : null + this.broadcast() + } setResults(value: unknown) { const key = this.state.catalog.key if (value !== null && !key) throw new Error('Сначала выберите пакет игры') @@ -578,8 +584,10 @@ export class Controller { this.measured.delete(report.channel) } if (report.status === 'ended') { - if (report.channel === 'music') this.advance() - else c.desired = 'paused' + if (report.channel === 'music') { + this.onMusicEnded?.(report) + this.advance() + } else c.desired = 'paused' } this.broadcast() } diff --git a/electron/main.ts b/electron/main.ts index ed97627..fdf9820 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,10 +1,12 @@ -import { app, BrowserWindow, ipcMain, protocol, screen, session } from 'electron' +import { app, BrowserWindow, ipcMain, protocol, screen, session, safeStorage } from 'electron' import { createReadStream } from 'node:fs' import { stat } from 'node:fs/promises' import { join } from 'node:path' import { Readable } from 'node:stream' import { Controller, parseCommand } from './controller.js' import { parseRange } from './range.js' +import { Online } from './online.js' +import type { OnlineState } from '../src/types/online.js' import { PackageStore } from './package-store.js' import { packageMediaResponse } from './package-media.js' import type { HostInfo } from '../src/types/screen.js' @@ -21,6 +23,7 @@ protocol.registerSchemesAsPrivileged([ let control: BrowserWindow | null = null let output: BrowserWindow | null = null let selectedDisplay: number | null = null +let online: Online | null = null const controller = new Controller((state) => { for (const window of [control, output]) if (window && !window.isDestroyed()) window.webContents.send('state', state) @@ -62,6 +65,7 @@ function requireControl(event: Electron.IpcMainInvokeEvent) { throw new Error('Недоступно') } function show(displayId: number) { + if (online?.blocksOutput) throw new Error('Подождите завершения подготовки материалов') const display = screen.getAllDisplays().find((d) => d.id === displayId) if (!display || display.id === screen.getPrimaryDisplay().id) throw new Error('Выберите внешний дисплей; включите расширенный рабочий стол') @@ -98,9 +102,23 @@ app .then(async () => { const packages = new PackageStore(join(app.getPath('userData'), 'packages')) await packages.initialize() + online = new Online(join(app.getPath('userData'), 'connection'), packages, controller, { + available: () => + safeStorage.isEncryptionAvailable() && + (process.platform !== 'linux' || + !['basic_text', 'unknown'].includes(safeStorage.getSelectedStorageBackend())), + encrypt: (value) => safeStorage.encryptString(value), + decrypt: (value) => safeStorage.decryptString(value), + }) + await online.initialize() + const onlineTick = setInterval(() => { + void online?.poll() + }, 1000) const timerTick = setInterval(() => controller.tick(), 250) app.on('before-quit', () => { clearInterval(timerTick) + clearInterval(onlineTick) + online?.close() packages.cancel() }) session.defaultSession.setPermissionRequestHandler((_contents, _permission, callback) => @@ -182,12 +200,26 @@ app throw new Error('Недоступно') return controller.state }) + ipcMain.handle('online-state', (event): OnlineState => { + requireControl(event) + return online!.snapshot() + }) + ipcMain.handle('online-action', async (event, intent: unknown) => { + requireControl(event) + if (selectingPackage) throw new Error('Подождите проверки пакета') + return online!.act(intent) + }) + ipcMain.handle('close-output', (event) => { + requireControl(event) + output?.close() + }) ipcMain.handle('host', (event) => { requireControl(event) return host() }) ipcMain.handle('show', (event, id: unknown) => { requireControl(event) + if (selectingPackage) throw new Error('Подождите проверки пакета') if (typeof id !== 'number') throw new Error('Некорректный дисплей') show(id) }) @@ -204,15 +236,14 @@ app if (key !== null && typeof key !== 'string') throw new Error('Некорректный пакет') selectingPackage = true try { - controller.selectPackage(key === null ? null : await packages.verify(key)) + await online!.select(key) return controller.state - } catch (error) { - if (key === controller.state.catalog.key) controller.selectPackage(null) - throw error } finally { selectingPackage = false } }) + let localSession = '' + let localSequence = 0 ipcMain.handle('command', (event, command: unknown) => { requireControl(event) if (selectingPackage) throw new Error('Подождите проверки пакета') @@ -222,7 +253,14 @@ app ['play', 'pause', 'resume', 'stop', 'replay', 'seek', 'next'].includes(parsed.action) ) throw new Error('Сначала откройте окно показа') - controller.command(parsed) + if (parsed.session !== controller.state.session) return + if (localSession !== parsed.session) { + localSession = parsed.session + localSequence = 0 + } + if (parsed.sequence <= localSequence) return + controller.command({ ...parsed, sequence: controller.state.sequence + 1 }) + localSequence = parsed.sequence }) let capturing = false let lastCapture = -Infinity diff --git a/electron/online.test.ts b/electron/online.test.ts new file mode 100644 index 0000000..823509c --- /dev/null +++ b/electron/online.test.ts @@ -0,0 +1,543 @@ +import assert from 'node:assert/strict' +import { test, type TestContext } from 'node:test' +import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto' +import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { Controller } from './controller.js' +import { Online, type SecretStorage } from './online.js' +import { PackageStore } from './package-store.js' +import { applyRemote, parseRemote, send } from './remote-state.js' + +async function fixture(t: TestContext, secure = true) { + const directory = await mkdtemp(join(tmpdir(), 'screen-online-')) + t.after(() => rm(directory, { recursive: true, force: true })) + const key = randomBytes(32) + const secrets: SecretStorage = { + available: () => secure, + encrypt(value) { + assert.ok(secure) + const iv = randomBytes(12) + const cipher = createCipheriv('aes-256-gcm', key, iv) + const bytes = Buffer.concat([cipher.update(value), cipher.final()]) + return Buffer.concat([iv, cipher.getAuthTag(), bytes]) + }, + decrypt(value) { + const cipher = createDecipheriv('aes-256-gcm', key, value.subarray(0, 12)) + cipher.setAuthTag(value.subarray(12, 28)) + return Buffer.concat([cipher.update(value.subarray(28)), cipher.final()]).toString() + }, + } + const model = { + offline: false, + failMedia: false, + expired: false, + refreshExpired: false, + accessExpired: false, + refreshed: false, + refreshCalls: 0, + advanceCalls: 0, + resultError: false, + title: 'Дело', + roles: ['organizer'], + remote: { + gameID: 7, + mode: 2, + command: 1, + stateVersion: '9007199254740993', + soundEnabled: true, + positionMs: '0', + volume: 80, + } as Record, + stateResponse: null as (() => Promise) | null, + } + const json = (value: unknown, status = 200) => Response.json(value, { status }) + const requests: { path: string; auth: string | null }[] = [] + const fetchAPI: typeof fetch = async (input, init) => { + if (model.offline) throw new Error('network secret-detail') + const url = new URL(String(input)) + const auth = new Headers(init?.headers).get('Authorization') + const path = url.pathname + requests.push({ path, auth }) + assert.equal(init?.credentials, 'omit') + if (url.origin === 'https://media.test') { + assert.equal(auth, null) + if (model.failMedia) return new Response(null, { status: 404 }) + return new Response(Buffer.from('RIFFtest-WAVE'), { + headers: { 'Content-Type': 'audio/wav' }, + }) + } + assert.equal(url.origin, 'https://server.test') + if (path === '/api/auth/login') + return json({ accessToken: 'ACCESS-SECRET', refreshToken: 'REFRESH-SECRET' }) + if (path === '/api/auth/refresh') { + model.refreshCalls++ + await new Promise((resolve) => setTimeout(resolve, 5)) + if (model.refreshExpired) return json({}, 401) + model.refreshed = true + return json({ accessToken: 'ACCESS-ROTATED', refreshToken: 'REFRESH-ROTATED' }) + } + if (path === '/api/screens') return json({ screenID: '12', accessToken: 'SCREEN-SECRET' }) + if (path === '/api/screens/state') { + assert.equal(auth, null) + if (model.stateResponse) return model.stateResponse() + return model.expired ? json({}, 401) : json({ state: model.remote }) + } + if (path === '/api/screens/music/advance') { + model.advanceCalls++ + return json({ state: objectMusic(2, 1) }) + } + if (path === '/api/screens/results') { + if (model.resultError) return json({}, 400) + return json({ + questions: [{ code: 'A', text: 'Вопрос' }], + teams: [ + { + team: { + ID: 'team', + name: 'Команда', + password: 'TEAM-SECRET', + captainEmail: 'private@example.test', + applications: [{ name: 'PRIVATE' }], + }, + place: 1, + totalScore: 5, + actionsCount: 2, + answers: [{ questionCode: 'A', score: 5, answer: 'ANSWER-SECRET' }], + }, + ], + }) + } + if (auth === 'Bearer ACCESS-SECRET' && model.accessExpired && !model.refreshed) + return json({}, 401) + if (model.refreshExpired) return json({}, 401) + if (path === '/api/users/me') return json({ user: { roles: model.roles } }) + if (path === '/api/games') + return json({ + games: [ + { ID: 7, name: model.title, status: 'finish', teams: [{ password: 'TEAM-SECRET' }] }, + ], + }) + if (path === '/api/games/7') + return json({ + game: { + ID: 7, + name: model.title, + scenario: { + story: { + introduction: { audio: 'https://media.test/intro.wav' }, + conclusion: { video: '' }, + }, + }, + }, + }) + if (path === '/api/games/7/screen-plan') return json({ plan: { steps: [{ mode: 2 }] } }) + if (path === '/api/games/7/music') + return json({ + gamePlaylistId: 1, + playlists: [ + { + ID: 1, + name: 'Музыка', + tracks: [0, 1].map((i) => ({ + ID: i + 1, + name: `Трек ${i}`, + filename: `https://media.test/${i}.wav`, + position: i, + })), + }, + ], + }) + if (path.endsWith('/bind') || path.endsWith('/sound')) return json({}) + throw new Error(`Unexpected request ${path}`) + } + const store = new PackageStore(join(directory, 'packages'), { + fetch: fetchAPI, + freeBytes: async () => 1n << 40n, + wait: async () => {}, + }) + await store.initialize() + const controller = new Controller(() => {}) + const online = new Online(join(directory, 'connection'), store, controller, secrets, fetchAPI) + await online.initialize() + const login = () => + online.act({ + action: 'login', + server: 'https://server.test/', + email: 'test@example.test', + password: 'PASSWORD-SECRET', + }) + const prepare = async () => { + await login() + await online.act({ action: 'download', gameId: 7 }) + await online.select(store.list()[0]!.key) + await online.act({ action: 'connect' }) + } + return { + directory, + store, + controller, + online, + secrets, + fetchAPI, + model, + requests, + login, + prepare, + } +} +function objectMusic(version: number, index = 0) { + return { + stateVersion: String(version), + playlist: { ID: 1, tracks: [{ ID: 1 }, { ID: 2 }] }, + trackIndex: index, + playback: 'MUSIC_PLAYBACK_PLAYING', + volume: 40, + loop: true, + } +} + +test('вход, подготовка и зашифрованные секреты; после перезапуска игра и результаты работают офлайн', async (t) => { + const f = await fixture(t) + await f.prepare() + await f.online.act({ action: 'results' }) + const snapshot = f.online.snapshot() + assert.equal(snapshot.authenticated, true) + assert.equal(snapshot.remembered, true) + const files = await readdir(join(f.directory, 'connection')) + for (const file of files) { + const bytes = await readFile(join(f.directory, 'connection', file)) + for (const secret of [ + 'ACCESS-SECRET', + 'REFRESH-SECRET', + 'SCREEN-SECRET', + 'PASSWORD-SECRET', + 'TEAM-SECRET', + 'ANSWER-SECRET', + 'private@example.test', + ]) + assert.equal(bytes.includes(secret), false) + } + assert.equal(JSON.stringify(snapshot).includes('SECRET'), false) + f.model.offline = true + const store = new PackageStore(join(f.directory, 'packages')) + await store.initialize() + const c = new Controller(() => {}) + const online = new Online(join(f.directory, 'connection'), store, c, f.secrets, f.fetchAPI) + await online.initialize() + assert.equal(c.state.catalog.key, f.controller.state.catalog.key) + assert.equal(c.state.results?.teams[0]?.totalScore, 5) + assert.ok(c.state.results?.capturedAt) + assert.equal(c.state.outputOpen, false) + assert.equal(c.state.channels.main.desired, 'stopped') + c.output(true) + send(c, { action: 'play', channel: 'main' }) + await online.poll() + assert.equal(online.state.connection, 'offline') + assert.equal(c.state.channels.main.desired, 'playing') +}) + +test('без хранилища ОС вход только в памяти; выход не останавливает показ', async (t) => { + const f = await fixture(t, false) + await f.prepare() + assert.equal(f.online.state.remembered, false) + await assert.rejects(readFile(join(f.directory, 'connection', 'credentials.bin')), { + code: 'ENOENT', + }) + f.controller.output(true) + send(f.controller, { action: 'play', channel: 'main' }) + await f.online.act({ action: 'logout' }) + assert.equal(f.controller.state.channels.main.desired, 'playing') + assert.equal(f.online.state.screenId, null) +}) + +test('три параллельных запроса описания используют одно обновление токена', async (t) => { + const f = await fixture(t) + await f.login() + f.model.accessExpired = true + await f.online.act({ action: 'download', gameId: 7 }) + assert.equal(f.model.refreshCalls, 1) + assert.equal(f.store.list().length, 1) +}) + +test('обновление запрещено при показе, ошибка сохраняет старый пакет, новая версия включается остановленной', async (t) => { + const f = await fixture(t) + await f.prepare() + const old = f.store.list()[0]! + f.controller.output(true) + await assert.rejects(f.online.act({ action: 'download', gameId: 7 }), /Закройте показ/) + f.controller.output(false) + f.model.title = 'Новое дело' + await f.online.act({ action: 'check', gameId: 7 }) + assert.match(f.online.state.notice, /есть изменения/) + f.model.failMedia = true + await assert.rejects(f.online.act({ action: 'download', gameId: 7 })) + assert.equal(f.store.list()[0]?.version, old.version) + f.model.failMedia = false + await f.online.act({ action: 'download', gameId: 7 }) + assert.notEqual(f.store.list()[0]?.version, old.version) + assert.equal(f.controller.state.catalog.name, 'Новое дело') + assert.equal(f.controller.state.channels.main.desired, 'stopped') + await assert.rejects(f.online.act({ action: 'remove', key: old.key }), /другой пакет/) +}) + +test('пропускает исходную и накопленную версии; повторная версия не отменяет локальную паузу', async (t) => { + const f = await fixture(t) + await f.prepare() + const c = f.controller + c.output(true) + await f.online.poll() + assert.equal(c.state.channels.main.desired, 'stopped') + f.model.remote.stateVersion = '9007199254740994' + await f.online.poll() + assert.equal(c.state.channels.main.desired, 'playing') + send(c, { action: 'pause', channel: 'main' }) + await f.online.poll() + assert.equal(c.state.channels.main.desired, 'paused') + f.model.offline = true + await f.online.poll() + f.model.offline = false + f.model.remote.stateVersion = '9007199254740995' + await f.online.poll() + assert.equal(c.state.channels.main.desired, 'paused') + f.model.remote.stateVersion = '9007199254740996' + await f.online.poll() + assert.equal(c.state.channels.main.desired, 'playing') + c.output(false) + c.output(true) + await f.online.poll() + assert.equal(c.state.channels.main.desired, 'stopped') +}) + +test('недействительная сессия и смена серверной привязки сохраняют локальный показ', async (t) => { + const f = await fixture(t) + await f.prepare() + f.controller.output(true) + send(f.controller, { action: 'play', channel: 'main' }) + f.model.expired = true + await f.online.poll() + assert.equal(f.online.state.connection, 'expired') + assert.equal(f.controller.state.channels.main.desired, 'playing') + f.model.expired = false + await f.online.act({ action: 'connect' }) + f.model.remote.gameID = 8 + await f.online.poll() + assert.equal(f.online.state.screenId, null) + assert.equal(f.controller.state.channels.main.desired, 'playing') +}) + +test('не применяет ответ от предыдущего окна и не допускает параллельный опрос', async (t) => { + const f = await fixture(t) + await f.prepare() + f.controller.output(true) + await f.online.poll() + let release!: (response: Response) => void + f.model.stateResponse = () => + new Promise((resolve) => { + release = resolve + }) + const pending = f.online.poll() + await f.online.poll() + f.controller.output(false) + f.controller.output(true) + release(Response.json({ state: { ...f.model.remote, stateVersion: '9007199254740999' } })) + await pending + assert.equal(f.controller.state.channels.main.desired, 'stopped') +}) + +test('результаты сохраняются при ошибке API и не содержат секретных полей', async (t) => { + const f = await fixture(t) + await f.prepare() + await f.online.act({ action: 'results' }) + const before = structuredClone(f.controller.state.results) + f.model.resultError = true + await assert.rejects(f.online.act({ action: 'results' }), /пока недоступно/) + assert.deepEqual(f.controller.state.results, before) + assert.equal(JSON.stringify(before).includes('SECRET'), false) +}) + +test('таймер синхронизируется один раз на версию; звук меняется без увеличения версии', () => { + let now = 0 + const c = new Controller( + () => {}, + () => now, + ) + const previous = parseRemote({ + gameID: 7, + mode: 7, + stateVersion: '1', + timerDurationSeconds: '60', + }) + const next = parseRemote({ + gameID: 7, + mode: 7, + stateVersion: '2', + soundEnabled: true, + timerDurationSeconds: '60', + timerRemainingSeconds: '40', + timerState: 2, + command: 8, + }) + applyRemote(c, previous, next) + assert.equal(c.state.timer.remainingMs, 40000) + now += 3000 + c.tick() + applyRemote(c, next, next) + assert.equal(c.state.timer.remainingMs, 37000) + send(c, { action: 'mute', enabled: true }) + applyRemote(c, { ...next, sound: false }, next) + assert.equal(c.state.muted, false) + assert.throws(() => parseRemote({ stateVersion: '9223372036854775808' })) + assert.throws(() => parseRemote({ mode: 99 })) +}) + +test('музыкальная громкость не возвращает старый трек; новый серверный трек использует локальные файлы', async (t) => { + const f = await fixture(t) + await f.prepare() + f.controller.output(true) + const previous = parseRemote({ music: objectMusic(1) }) + send(f.controller, { action: 'next', channel: 'music' }) + const next = parseRemote({ music: { ...objectMusic(2), volume: 70 } }) + applyRemote(f.controller, previous, next) + assert.equal(f.controller.state.channels.music.trackIndex, 1) + assert.equal(f.controller.state.channels.music.volume, 70) + applyRemote(f.controller, next, parseRemote({ music: objectMusic(3, 1) })) + assert.equal(f.controller.state.channels.music.desired, 'playing') + assert.match(f.controller.state.channels.music.source!.url, /^screen-app:/) + assert.throws( + () => + applyRemote( + f.controller, + next, + parseRemote({ music: { ...objectMusic(4), playlist: { ID: 99, tracks: [{ ID: 1 }] } } }), + ), + /Музыка изменилась/, + ) +}) + +test('истечение JWT не обрывает показ и оставляет доступным повторный вход', async (t) => { + const f = await fixture(t) + await f.prepare() + f.controller.output(true) + send(f.controller, { action: 'play', channel: 'main' }) + f.model.accessExpired = true + f.model.refreshExpired = true + await assert.rejects(f.online.act({ action: 'games' }), /Сессия истекла/) + assert.equal(f.online.state.authenticated, false) + assert.equal(f.controller.state.channels.main.desired, 'playing') + assert.equal(f.controller.state.catalog.key, f.store.list()[0]?.key) +}) + +test('отмена подготовки не меняет готовую версию и снимает запрет открытия', async (t) => { + const f = await fixture(t) + await f.prepare() + const old = f.store.list()[0]!.version + const original = f.store.download.bind(f.store) + let started!: () => void + const ready = new Promise((resolve) => { + started = resolve + }) + f.store.download = async (description, options) => { + started() + await new Promise((resolve) => + options!.signal!.addEventListener('abort', () => resolve(), { once: true }), + ) + return original(description, options) + } + const pending = f.online.act({ action: 'download', gameId: 7 }) + const failure = assert.rejects(pending) + await ready + assert.equal(f.online.blocksOutput, true) + await assert.rejects(f.online.select(null), /Подождите/) + await f.online.act({ action: 'cancel' }) + await failure + assert.equal(f.online.blocksOutput, false) + assert.equal(f.store.list()[0]?.version, old) +}) + +test('одинаковая серверная сцена с новой версией отменяет локальное переключение', () => { + const c = new Controller(() => {}) + const previous = parseRemote({ mode: 1, stateVersion: '1' }) + send(c, { action: 'mode', mode: 8 }) + applyRemote(c, previous, parseRemote({ mode: 1, stateVersion: '2' })) + assert.equal(c.state.scene.mode, 1) + send(c, { action: 'mode', mode: 8 }) + applyRemote( + c, + parseRemote({ mode: 7, stateVersion: '2' }), + parseRemote({ mode: 7, stateVersion: '3', command: 8, timerState: 2 }), + ) + assert.equal(c.state.scene.mode, 7) + assert.equal(c.state.timer.status, 'running') +}) + +test('откат версии не позволяет повторно выполнить уже полученную команду', async (t) => { + const f = await fixture(t) + await f.prepare() + f.controller.output(true) + await f.online.poll() + f.model.remote.stateVersion = '9007199254740995' + await f.online.poll() + send(f.controller, { action: 'pause', channel: 'main' }) + f.model.remote.stateVersion = '9007199254740994' + await f.online.poll() + f.model.remote.stateVersion = '9007199254740995' + await f.online.poll() + assert.equal(f.controller.state.channels.main.desired, 'paused') +}) + +test('окончание серверного трека обновляет сервер один раз и не перематывает локальный следующий', async (t) => { + const f = await fixture(t) + await f.prepare() + f.controller.output(true) + f.model.remote.music = objectMusic(1) + await f.online.poll() + f.model.remote.music = objectMusic(2) + f.model.remote.stateVersion = '9007199254740994' + await f.online.poll() + send(f.controller, { action: 'play', channel: 'music' }) + const report = { + session: f.controller.state.session, + channel: 'music' as const, + operation: f.controller.state.channels.music.operation, + status: 'ended' as const, + position: 4, + duration: 4, + } + f.controller.report(report) + f.controller.report(report) + await new Promise((resolve) => setTimeout(resolve, 10)) + assert.equal(f.model.advanceCalls, 1) + assert.equal(f.controller.state.channels.music.trackIndex, 1) + send(f.controller, { action: 'seek', channel: 'music', position: 2 }) + f.model.remote.music = objectMusic(2, 1) + await f.online.poll() + assert.equal(f.controller.state.channels.music.position, 2) +}) + +test('protobuf JSON с опущенными нулями сохраняет тишину и завершённый таймер', () => { + const state = parseRemote({ + mode: 7, + timerDurationSeconds: '60', + timerState: 'SCREEN_TIMER_STATE_FINISHED', + music: { stateVersion: '1' }, + }) + assert.equal(state.volume, 0) + assert.equal(state.music?.volume, 0) + assert.equal(state.timer.remainingMs, 0) + assert.equal(state.timer.status, 'finished') + assert.throws(() => parseRemote({ stateVersion: 9007199254740992 })) +}) + +test('вход без прав управления не сохраняет токены даже после их обновления', async (t) => { + const f = await fixture(t) + f.model.accessExpired = true + f.model.roles = ['player'] + await assert.rejects(f.login(), /нужны права/) + assert.equal(f.online.state.authenticated, false) + assert.equal(f.online.state.remembered, false) + assert.equal(f.model.refreshCalls, 1) + await assert.rejects(readFile(join(f.directory, 'connection', 'credentials.bin')), { + code: 'ENOENT', + }) +}) diff --git a/electron/online.ts b/electron/online.ts new file mode 100644 index 0000000..e7b796e --- /dev/null +++ b/electron/online.ts @@ -0,0 +1,697 @@ +import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import type { OnlineIntent, OnlineState } from '../src/types/online.js' +import type { Report, ResultsSnapshot } from '../src/types/screen.js' +import type { Controller } from './controller.js' +import { PackageStore, packageKey } from './package-store.js' +import { prepareScreenPackage } from './screen-package.js' +import { parseResults } from './results.js' +import { + applyRemote, + integer, + object, + parseRemote, + send, + text, + type RemoteState, +} from './remote-state.js' + +export interface SecretStorage { + available(): boolean + encrypt(value: string): Buffer + decrypt(value: Buffer): string +} +interface ScreenSession { + id: string + token: string + gameId: number +} +interface Credentials { + server: string + access: string + refresh: string + screen: ScreenSession | null +} +class ApiError extends Error { + constructor(public status: number) { + super( + status === 401 + ? 'Сессия истекла. Войдите снова' + : status === 403 + ? 'Нет доступа к действию' + : status === 400 + ? 'Действие пока недоступно для этой игры' + : `Ошибка сервера: HTTP ${status}`, + ) + } +} +function serverURL(value: unknown): string { + const url = new URL(text(value)) + if ( + !['http:', 'https:'].includes(url.protocol) || + url.username || + url.password || + url.search || + url.hash || + (url.pathname !== '/' && url.pathname !== '') + ) + throw new Error('Укажите адрес HTTP(S) сервера без пути и параметров') + return url.origin +} +function token(value: unknown): string { + const s = text(value) + if (!s || s.length > 16384 || /[\r\n]/.test(s)) + throw new Error('Сервер не вернул действующие токены') + return s +} +export class Online { + readonly state: OnlineState = { + server: '', + authenticated: false, + remembered: false, + busy: false, + canCancel: false, + games: [], + screenId: null, + connection: 'disabled', + error: '', + notice: '', + progress: null, + packagesRevision: 0, + } + private credentials: Credentials | null = null + private screen: ScreenSession | null = null + private generation = 0 + private refreshPromise: Promise | null = null + private writes: Promise = Promise.resolve() + private cancellation: AbortController | null = null + private downloadKey: string | null = null + private previous: RemoteState | null = null + private pollBusy = false + private pollSession = '' + private initialSound = true + private lastResults = -Infinity + private resultsBusy = false + private advanceBusy = false + constructor( + private directory: string, + private packages: PackageStore, + private controller: Controller, + private secrets: SecretStorage, + private fetchAPI: typeof fetch = fetch, + ) { + controller.onMusicEnded = (report) => { + void this.musicEnded(report) + } + } + get blocksOutput() { + return this.downloadKey !== null && this.downloadKey === this.controller.state.catalog.key + } + get busy() { + return this.state.busy + } + snapshot(): OnlineState { + return structuredClone(this.state) + } + private async atomic(name: string, data: string | Buffer) { + await mkdir(this.directory, { recursive: true, mode: 0o700 }) + const path = join(this.directory, name) + await writeFile(`${path}.tmp`, data, { mode: 0o600 }) + await rename(`${path}.tmp`, path) + } + private persistSecrets() { + const data = this.credentials ? { ...this.credentials, screen: this.screen } : null + this.state.remembered = !!data && this.secrets.available() + let encrypted: Buffer | null = null + try { + if (this.state.remembered) encrypted = this.secrets.encrypt(JSON.stringify(data)) + } catch { + this.state.remembered = false + this.state.notice = + 'Не удалось сохранить вход средствами ОС. Сессия действует до закрытия приложения' + } + const write = this.writes.then(async () => { + if (encrypted) await this.atomic('credentials.bin', encrypted) + else await rm(join(this.directory, 'credentials.bin'), { force: true }) + }) + this.writes = write.catch(() => {}) + return write + } + async initialize() { + try { + const config = object( + JSON.parse(await readFile(join(this.directory, 'selection.json'), 'utf8')), + ) + if (config.server) this.state.server = serverURL(config.server) + if (typeof config.key === 'string' && /^[a-f0-9]{64}$/.test(config.key)) + await this.select(config.key, false) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') + this.state.error = 'Не удалось восстановить выбранные материалы; выберите готовый пакет' + } + if (!this.secrets.available()) return + try { + const raw = object( + JSON.parse(this.secrets.decrypt(await readFile(join(this.directory, 'credentials.bin')))), + ) + const server = serverURL(raw.server) + const c: Credentials = { + server, + access: token(raw.access), + refresh: token(raw.refresh), + screen: null, + } + if (raw.screen) { + const s = object(raw.screen) + c.screen = { + id: String(integer(s.id, 1)), + token: token(s.token), + gameId: integer(s.gameId, 1), + } + } + this.credentials = c + this.state.server = server + this.state.authenticated = true + this.state.remembered = true + const pkg = this.packages.list().find((p) => p.key === this.controller.state.catalog.key) + if (c.screen && pkg?.package.server === server && pkg.package.game.id === c.screen.gameId) { + this.screen = c.screen + this.state.screenId = c.screen.id + this.state.connection = 'waiting' + this.initialSound = true + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') + this.state.error = + 'Сохранённый вход недоступен. Войдите снова; локальные материалы доступны' + } + } + private async saveSelection() { + await this.atomic( + 'selection.json', + JSON.stringify({ server: this.state.server, key: this.controller.state.catalog.key }), + ) + } + async select(key: string | null, persist = true) { + if (this.busy) throw new Error('Подождите завершения подготовки материалов') + let pkg + try { + pkg = key === null ? null : await this.packages.verify(key) + } catch (error) { + if (key === this.controller.state.catalog.key) { + this.detach() + this.controller.selectPackage(null) + await this.saveSelection() + await this.persistSecrets() + this.state.packagesRevision++ + } + throw error + } + this.detach() + this.controller.selectPackage(pkg) + this.lastResults = -Infinity + if (key) await this.restoreResults(key) + if (persist) { + await this.saveSelection() + await this.persistSecrets() + } + } + private detach() { + this.generation++ + this.screen = null + this.previous = null + this.state.screenId = null + this.state.connection = 'disabled' + } + private async raw(server: string, path: string, init: RequestInit = {}): Promise { + try { + return await this.fetchAPI(`${server}${path}`, { + ...init, + redirect: 'error', + credentials: 'omit', + signal: AbortSignal.any([ + AbortSignal.timeout(30_000), + ...(init.signal ? [init.signal] : []), + ]), + }) + } catch { + throw new Error('Сервер недоступен или превышено время ожидания') + } + } + private async json(response: Response): Promise> { + if (!response.ok) throw new ApiError(response.status) + try { + return object(await response.json()) + } catch { + throw new Error('Сервер вернул некорректный JSON') + } + } + private async refresh(failed: string, generation: number) { + if (this.credentials?.access !== failed) return + if (this.refreshPromise) return this.refreshPromise + const c = this.credentials! + this.refreshPromise = (async () => { + const response = await this.raw(c.server, '/api/auth/refresh', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken: c.refresh }), + }) + if (generation !== this.generation) throw new Error('Сессия изменилась') + if (response.status === 401) { + this.credentials = null + this.state.authenticated = false + await this.persistSecrets() + throw new ApiError(401) + } + const result = await this.json(response) + if (generation !== this.generation) throw new Error('Сессия изменилась') + this.credentials = { + ...c, + access: token(result.accessToken), + refresh: token(result.refreshToken), + } + await this.persistSecrets() + })().finally(() => { + this.refreshPromise = null + }) + return this.refreshPromise + } + private async authorized(path: string, init: RequestInit = {}) { + const c = this.credentials + const generation = this.generation + if (!c) throw new ApiError(401) + const request = (access: string) => { + const headers = new Headers(init.headers) + headers.set('Authorization', `Bearer ${access}`) + return this.raw(c.server, path, { ...init, headers }) + } + let response = await request(c.access) + if (generation !== this.generation) throw new Error('Сессия изменилась') + if (response.status === 401) { + await response.body?.cancel() + await this.refresh(c.access, generation) + if (generation !== this.generation || !this.credentials) throw new ApiError(401) + response = await request(this.credentials.access) + if (response.status === 401) { + this.credentials = null + this.state.authenticated = false + await this.persistSecrets() + } + } + return response + } + private async api(path: string, body?: object, authorized = true, signal?: AbortSignal) { + const init: RequestInit = + body === undefined + ? { signal } + : { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal, + } + return this.json( + authorized + ? await this.authorized(path, init) + : await this.raw(this.state.server, path, init), + ) + } + async act(input: unknown): Promise { + const data = object(input) + if (data.action === 'cancel') { + this.cancellation?.abort() + this.packages.cancel() + return this.snapshot() + } + if (this.busy) throw new Error('Подождите завершения текущего действия') + this.state.busy = true + this.state.error = '' + this.state.notice = '' + try { + await this.perform(data) + } catch (error) { + this.state.error = error instanceof Error ? error.message : 'Действие не выполнено' + throw new Error(this.state.error) + } finally { + this.state.busy = false + this.state.progress = null + } + return this.snapshot() + } + private async perform(data: Record) { + const action = data.action as OnlineIntent['action'] + switch (action) { + case 'login': { + const server = serverURL(data.server) + const email = text(data.email).trim() + const password = text(data.password) + if (!email.includes('@') || email.length > 320 || !password || password.length > 4096) + throw new Error('Укажите email и пароль') + this.detach() + this.credentials = null + this.state.authenticated = false + this.state.games = [] + await this.persistSecrets() + this.state.server = server + const response = await this.raw(server, '/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }) + const result = await this.json(response) + this.credentials = { + server, + access: token(result.accessToken), + refresh: token(result.refreshToken), + screen: null, + } + try { + const me = object((await this.api('/api/users/me')).user) + if ( + !Array.isArray(me.roles) || + !me.roles.some((r) => ['admin', 'organizer', 'author'].includes(String(r))) + ) + throw new Error( + 'Для управления экраном нужны права организатора, автора или администратора', + ) + } catch (error) { + this.credentials = null + await this.persistSecrets() + throw error + } + this.state.authenticated = true + await this.persistSecrets() + await this.saveSelection() + await this.loadGames() + return + } + case 'logout': + this.detach() + this.credentials = null + this.state.authenticated = false + this.state.games = [] + await this.persistSecrets() + return + case 'games': + return this.loadGames() + case 'download': + case 'check': { + const gameId = integer(data.gameId, 1) + const key = packageKey(this.state.server, gameId) + if ( + action === 'download' && + key === this.controller.state.catalog.key && + this.controller.state.outputOpen + ) + throw new Error('Закройте показ перед обновлением выбранной игры') + if (!this.credentials) throw new ApiError(401) + this.downloadKey = action === 'download' ? key : null + this.cancellation = new AbortController() + this.state.canCancel = true + try { + const description = await prepareScreenPackage( + { + server: this.state.server, + gameId, + token: this.credentials.access, + signal: this.cancellation.signal, + }, + (url, init) => { + const parsed = new URL(String(url)) + if (parsed.origin !== this.state.server) throw new Error('Недопустимый сервер API') + return this.authorized(parsed.pathname, init) + }, + ) + if (action === 'check') { + const previous = this.packages.list().find((p) => p.key === key) + this.state.notice = !previous + ? 'Игра ещё не загружена' + : previous.package.metadataVersion === description.metadataVersion + ? 'Описание не изменилось. Для проверки замены файлов по прежним адресам загрузите материалы повторно' + : 'На сервере есть изменения. Загрузите материалы при закрытом показе' + return + } + const pkg = await this.packages.download(description, { + signal: this.cancellation.signal, + onProgress: (p) => { + this.state.progress = p + }, + }) + this.state.packagesRevision++ + if (key === this.controller.state.catalog.key) { + this.controller.selectPackage(pkg) + this.previous = null + await this.restoreResults(key) + } + this.state.notice = 'Материалы проверены и готовы к показу' + } finally { + this.cancellation = null + this.state.canCancel = false + this.downloadKey = null + } + return + } + case 'remove': { + const key = text(data.key) + if (!/^[a-f0-9]{64}$/.test(key)) throw new Error('Некорректный пакет') + if (key === this.controller.state.catalog.key) + throw new Error('Сначала выберите другой пакет или тестовый режим') + await this.packages.remove(key) + await rm(join(this.directory, `results-${key}.json`), { force: true }) + this.state.packagesRevision++ + return + } + case 'connect': { + const pkg = this.packages.list().find((p) => p.key === this.controller.state.catalog.key) + if (!pkg || pkg.package.server !== this.state.server) + throw new Error('Выберите готовую игру с этого сервера') + if (!this.credentials) throw new ApiError(401) + this.detach() + const result = await this.api('/api/screens', {}, false) + const s: ScreenSession = { + id: String(integer(result.screenID, 1)), + token: token(result.accessToken), + gameId: pkg.package.game.id, + } + await this.api(`/api/screens/${s.id}/bind`, { + screenID: s.id, + accessToken: s.token, + gameID: s.gameId, + }) + this.screen = s + this.state.screenId = s.id + this.state.connection = 'waiting' + this.initialSound = true + await this.persistSecrets() + return + } + case 'disconnect': + this.detach() + await this.persistSecrets() + return + case 'sound': + if (!this.screen) throw new Error('Сначала подключите управление') + await this.api(`/api/screens/${this.screen.id}/sound`, { + screenID: this.screen.id, + soundEnabled: true, + }) + return + case 'results': + return this.refreshResults() + default: + throw new Error('Неизвестное действие') + } + } + private async loadGames() { + const result = await this.api('/api/games') + if (!Array.isArray(result.games ?? [])) throw new Error('Некорректный список игр') + this.state.games = ((result.games ?? []) as unknown[]).map((value) => { + const g = object(value) + return { id: integer(g.ID, 1), name: text(g.name), status: text(g.status ?? '') } + }) + } + private async restoreResults(key: string) { + try { + this.controller.setResults( + parseResults( + JSON.parse(await readFile(join(this.directory, `results-${key}.json`), 'utf8')), + key, + ), + ) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') + this.state.error = 'Сохранённые результаты недоступны; обновите снимок' + } + } + async refreshResults() { + if (this.resultsBusy) return + const s = this.screen + const key = this.controller.state.catalog.key + if (!s || !key) throw new Error('Подключите экран к выбранной игре для получения результатов') + const generation = this.generation + this.resultsBusy = true + this.lastResults = Date.now() + try { + const result = await this.api( + '/api/screens/results', + { screenID: s.id, accessToken: s.token }, + false, + ) + if (generation !== this.generation || key !== this.controller.state.catalog.key) return + if (!Array.isArray(result.questions ?? []) || !Array.isArray(result.teams ?? [])) + throw new Error('Некорректные результаты') + const snapshot: ResultsSnapshot = parseResults( + { + packageKey: key, + capturedAt: new Date().toISOString(), + questions: result.questions ?? [], + teams: ((result.teams ?? []) as unknown[]).map((value) => { + const row = object(value) + const team = object(row.team) + if (!Array.isArray(row.answers ?? [])) throw new Error('Некорректные ответы') + return { + id: text(team.ID), + name: text(team.name), + place: integer(row.place, 1), + totalScore: integer(row.totalScore ?? 0, -2147483648), + actionsCount: integer(row.actionsCount ?? 0), + answers: ((row.answers ?? []) as unknown[]).map((value) => { + const a = object(value) + return { + questionCode: text(a.questionCode), + score: integer(a.score ?? 0, -2147483648), + } + }), + } + }), + }, + key, + ) + await this.atomic(`results-${key}.json`, JSON.stringify(snapshot)) + if (generation === this.generation && key === this.controller.state.catalog.key) + this.controller.setResults(snapshot) + } finally { + this.resultsBusy = false + } + } + async poll() { + const s = this.screen + const session = this.controller.state.session + if (!s || !this.controller.state.outputOpen) { + this.previous = null + if (s) this.state.connection = 'waiting' + return + } + if (this.pollBusy) return + if (this.pollSession !== session) { + this.previous = null + this.pollSession = session + } + this.pollBusy = true + const generation = this.generation + try { + const result = await this.api( + '/api/screens/state', + { screenID: s.id, accessToken: s.token }, + false, + AbortSignal.timeout(5000), + ) + if ( + generation !== this.generation || + session !== this.controller.state.session || + !this.controller.state.outputOpen + ) + return + let next = parseRemote(result.state) + if (next.gameId !== s.gameId) { + this.detach() + await this.persistSecrets() + this.state.error = + 'Привязка экрана изменилась на сервере. Локальный показ сохранён; подключите управление заново' + return + } + const previous = this.previous + if (previous && next.version < previous.version) + next = { ...previous, sound: next.sound, music: next.music } + if (previous?.music && next.music && next.music.version < previous.music.version) + next = { ...next, music: previous.music } + this.previous = next + if (this.state.connection === 'offline') this.state.error = '' + this.state.connection = 'connected' + if (!previous && this.initialSound) { + this.initialSound = false + send(this.controller, { action: 'mute', enabled: !next.sound }) + } + if (previous) { + try { + applyRemote(this.controller, previous, next) + } catch (error) { + this.state.error = error instanceof Error ? error.message : 'Команда сервера не выполнена' + } + } + if (this.controller.state.scene.mode === 6 && Date.now() - this.lastResults >= 5000) + void this.refreshResults().catch((error) => { + this.state.error = + error instanceof Error ? error.message : 'Не удалось обновить результаты' + }) + } catch (error) { + if (generation !== this.generation || session !== this.controller.state.session) return + this.previous = null + if (error instanceof ApiError && error.status === 401) { + this.detach() + this.state.connection = 'expired' + await this.persistSecrets().catch(() => {}) + } else this.state.connection = 'offline' + this.state.error = + error instanceof Error ? error.message : 'Не удалось получить состояние экрана' + } finally { + this.pollBusy = false + } + } + async musicEnded(report: Report) { + const m = this.previous?.music + const c = this.controller.state.channels.music + if ( + !this.screen || + !m || + this.advanceBusy || + this.state.connection !== 'connected' || + report.channel !== 'music' || + report.status !== 'ended' || + report.session !== this.controller.state.session || + report.operation !== c.operation || + c.desired !== 'playing' || + c.playlistId !== m.playlistId || + c.source?.id !== `track-${m.playlistId}-${m.trackId}` || + m.playback !== 2 + ) + return + this.advanceBusy = true + const generation = this.generation + try { + const result = await this.api( + '/api/screens/music/advance', + { screenID: this.screen.id, accessToken: this.screen.token }, + false, + AbortSignal.timeout(5000), + ) + if (generation === this.generation && this.previous?.music?.version === m.version) { + const music = parseRemote({ music: result.state }).music + if (music) this.previous = { ...this.previous, music } + } + } catch { + if (generation === this.generation) { + this.previous = null + this.state.connection = 'offline' + this.state.error = 'Переход музыки выполнен локально; сервер не подтвердил обновление' + } + } finally { + this.advanceBusy = false + } + } + close() { + this.cancellation?.abort() + this.packages.cancel() + this.detach() + } +} diff --git a/electron/preload.ts b/electron/preload.ts index fec0d2c..406e563 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -1,7 +1,11 @@ +import type { OnlineIntent } from '../src/types/online.js' import { contextBridge, ipcRenderer } from 'electron' import type { Command, Report, SceneReport, State } from '../src/types/screen.js' contextBridge.exposeInMainWorld('screenBridge', { + onlineState: () => ipcRenderer.invoke('online-state'), + onlineAction: (intent: OnlineIntent) => ipcRenderer.invoke('online-action', intent), + closeOutput: () => ipcRenderer.invoke('close-output'), command: (command: Command) => ipcRenderer.invoke('command', command), report: (report: Report) => ipcRenderer.send('report', report), reportScene: (report: SceneReport) => ipcRenderer.send('scene-report', report), diff --git a/electron/remote-state.ts b/electron/remote-state.ts new file mode 100644 index 0000000..172a1fc --- /dev/null +++ b/electron/remote-state.ts @@ -0,0 +1,224 @@ +import type { Controller } from './controller.js' +import type { Intent, ScreenMode } from '../src/types/screen.js' + +export function object(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw new Error('Некорректный ответ сервера') + return value as Record +} +export function integer(value: unknown, min = 0, max = Number.MAX_SAFE_INTEGER): number { + const n = typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : value + if (typeof n !== 'number' || !Number.isSafeInteger(n) || n < min || n > max) + throw new Error('Некорректное число в ответе сервера') + return n +} +export function text(value: unknown): string { + if (typeof value !== 'string') throw new Error('Некорректный текст в ответе сервера') + return value +} +function enumeration(value: unknown, prefix: string, names: string[]): number { + const n = + typeof value === 'string' && value.startsWith(prefix) + ? names.indexOf(value.slice(prefix.length)) + : integer(value ?? 0) + return integer(n, 0, names.length - 1) +} +function version(value: unknown): bigint { + if (typeof value === 'number' && !Number.isSafeInteger(value)) + throw new Error('Некорректная версия состояния') + const v = String(value ?? '0') + if (!/^\d{1,19}$/.test(v) || BigInt(v) > 9223372036854775807n) + throw new Error('Некорректная версия состояния') + return BigInt(v) +} +export interface RemoteState { + gameId: number | null + version: bigint + mode: ScreenMode + command: number + position: number + volume: number + sound: boolean + text: string + timer: { + label: string + durationMs: number + remainingMs: number + status: 'stopped' | 'running' | 'paused' | 'finished' + } + music: { + version: bigint + playlistId: number | null + trackId: number | null + trackIds: number[] + position: number + volume: number + loop: boolean + playback: number + } | null +} +export function parseRemote(value: unknown): RemoteState { + const s = object(value) + const mode = + enumeration(s.mode, 'SCREEN_MODE_', [ + 'UNSPECIFIED', + 'COVER', + 'INTRO', + 'INTRO_DONE', + 'CONCLUSION', + 'CONCLUSION_DONE', + 'RESULTS', + 'TIMER', + 'TEXT', + ]) || 1 + const duration = integer(s.timerDurationSeconds ?? 600, 1, 86400) + const remaining = integer(s.timerRemainingSeconds ?? 0, 0, duration) + const timerState = enumeration(s.timerState, 'SCREEN_TIMER_STATE_', [ + 'UNSPECIFIED', + 'STOPPED', + 'RUNNING', + 'PAUSED', + 'FINISHED', + ]) + let music: RemoteState['music'] = null + if (s.music) { + const m = object(s.music) + const p = m.playlist ? object(m.playlist) : null + const tracks = p?.tracks ?? [] + if (!Array.isArray(tracks)) throw new Error('Некорректный плейлист') + const trackIds = tracks.map((t) => integer(object(t).ID, 1)) + if (new Set(trackIds).size !== trackIds.length) throw new Error('Повтор трека в плейлисте') + const index = integer(m.trackIndex ?? 0) + music = { + version: version(m.stateVersion), + playlistId: p ? integer(p.ID, 1) : null, + trackId: trackIds[index] ?? null, + trackIds, + position: integer(m.positionMs ?? 0) / 1000, + volume: integer(m.volume ?? 0, 0, 100), + loop: m.loop === true, + playback: enumeration(m.playback, 'MUSIC_PLAYBACK_', [ + 'UNSPECIFIED', + 'STOPPED', + 'PLAYING', + 'PAUSED', + ]), + } + } + return { + gameId: s.gameID == null ? null : integer(s.gameID, 1), + version: version(s.stateVersion), + mode: mode as ScreenMode, + command: enumeration(s.command, 'SCREEN_COMMAND_', [ + 'UNSPECIFIED', + 'PLAY', + 'PAUSE', + 'RESUME', + 'STOP', + 'REPLAY', + 'SEEK', + 'VOLUME', + 'TIMER_START', + 'TIMER_PAUSE', + 'TIMER_RESUME', + 'TIMER_RESET', + ]), + position: integer(s.positionMs ?? 0) / 1000, + volume: integer(s.volume ?? 0, 0, 100), + sound: s.soundEnabled === true, + text: text(s.text ?? '').slice(0, 10000), + timer: { + label: text(s.timerLabel ?? 'Перерыв').slice(0, 200), + durationMs: duration * 1000, + remainingMs: remaining * 1000, + status: (['stopped', 'stopped', 'running', 'paused', 'finished'] as const)[timerState]!, + }, + music, + } +} +export function send(controller: Controller, intent: Intent) { + controller.command({ + ...intent, + session: controller.state.session, + sequence: controller.state.sequence + 1, + }) +} +// Сравниваем с предыдущим ответом сервера, а не с локальными действиями ведущего. +export function applyRemote(controller: Controller, previous: RemoteState, next: RemoteState) { + if (next.sound !== previous.sound) send(controller, { action: 'mute', enabled: !next.sound }) + if (next.version > previous.version) { + if ( + next.command === 0 || + (next.command >= 8 && controller.state.scene.mode !== 7) || + next.mode !== previous.mode || + next.text !== previous.text || + (next.mode === 7 && + (next.timer.label !== previous.timer.label || + next.timer.durationMs !== previous.timer.durationMs)) + ) { + if (next.mode === 8 && next.text.trim()) send(controller, { action: 'text', text: next.text }) + else send(controller, { action: 'mode', mode: next.mode }) + } + if (next.mode === 7) controller.synchronizeTimer(next.timer) + if (next.command > 0 && next.command < 8) { + if (next.command === 7) + send(controller, { action: 'volume', channel: 'main', volume: next.volume }) + else if (next.mode === 2 || next.mode === 4) { + if (controller.state.scene.mode !== next.mode) + send(controller, { action: 'mode', mode: next.mode }) + send(controller, { action: 'volume', channel: 'main', volume: next.volume }) + const action = (['', 'play', 'pause', 'resume', 'stop', 'replay', 'seek'] as const)[ + next.command + ]! + if (action === 'seek') + send(controller, { action, channel: 'main', position: next.position }) + else if (action) { + send(controller, { action, channel: 'main' }) + } + } + } + } + const m = next.music + if (!m || (previous.music && m.version <= previous.music.version)) return + if (!m.playlistId) { + if (controller.state.channels.music.source) + send(controller, { action: 'stop', channel: 'music' }) + return + } + const playlist = controller.state.catalog.playlists.find((p) => p.id === m.playlistId) + if ( + !playlist || + playlist.tracks.length !== m.trackIds.length || + m.trackIds.some((id) => !playlist.tracks.some((t) => t.id === id)) + ) { + if (controller.state.channels.music.source) + send(controller, { action: 'stop', channel: 'music' }) + throw new Error('Музыка изменилась на сервере. Подготовьте материалы при закрытом показе') + } + const index = playlist.tracks.findIndex((t) => t.id === m.trackId) + if (index < 0) throw new Error('Трек отсутствует в подготовленных материалах') + const old = previous.music + const transportChanged = + !old || + old.playlistId !== m.playlistId || + old.trackId !== m.trackId || + old.playback !== m.playback || + old.position !== m.position + if (transportChanged) { + const c = controller.state.channels.music + if (c.playlistId !== playlist.id) + send(controller, { action: 'playlist', channel: 'music', playlistId: playlist.id }) + if (controller.state.channels.music.trackIndex !== index) + send(controller, { action: 'track', channel: 'music', trackIndex: index }) + } + send(controller, { action: 'volume', channel: 'music', volume: m.volume }) + send(controller, { action: 'loop', channel: 'music', enabled: m.loop }) + if (transportChanged) { + send(controller, { + action: m.playback === 2 ? 'resume' : m.playback === 3 ? 'pause' : 'stop', + channel: 'music', + }) + if (m.playback !== 1) + send(controller, { action: 'seek', channel: 'music', position: m.position }) + } +} diff --git a/scripts/online-smoke.cjs b/scripts/online-smoke.cjs new file mode 100644 index 0000000..efc8ca1 --- /dev/null +++ b/scripts/online-smoke.cjs @@ -0,0 +1,167 @@ +const assert = require('node:assert/strict') +const { createServer } = require('node:http') +const { readFileSync } = require('node:fs') +const { join } = require('node:path') + +module.exports = async function onlineSmoke({ + evaluate, + inspect, + state, + command, + poll, + displayId, + root, +}) { + let version = 1 + let offline = false + let polls = 0 + let currentCommand = 'SCREEN_COMMAND_PLAY' + const requests = [] + const server = createServer(async (req, res) => { + const chunks = [] + for await (const chunk of req) chunks.push(chunk) + const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : {} + const path = req.url + requests.push(path) + const send = (value, status = 200) => { + res.writeHead(status, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(value)) + } + if (path === '/api/auth/login') { + assert.equal(body.email, 'organizer@example.test') + return send({ accessToken: 'TEST-ACCESS', refreshToken: 'TEST-REFRESH' }) + } + if (path === '/api/users/me') return send({ user: { roles: ['organizer'] } }) + if (path === '/api/games') + return send({ games: [{ ID: 6, name: 'HTTP smoke', status: 'finish' }] }) + if (path === '/api/games/6') + return send({ + game: { + ID: 6, + name: 'HTTP smoke', + scenario: { story: { introduction: { audio: 'intro.wav' } } }, + }, + }) + if (path === '/api/games/6/screen-plan') return send({ plan: { steps: [{ mode: 2 }] } }) + if (path === '/api/games/6/music') return send({}) + if (path === '/api/files/intro.wav') { + assert.equal(req.headers.authorization, undefined) + res.writeHead(200, { 'Content-Type': 'audio/wav' }) + return res.end(readFileSync(join(root, 'fixtures/audio.wav'))) + } + if (path === '/api/screens') return send({ screenID: '67', accessToken: 'TEST-SCREEN' }) + if (path === '/api/screens/67/bind') return send({}) + if (path === '/api/screens/state') { + polls++ + assert.equal(req.headers.authorization, undefined) + assert.equal(body.accessToken, 'TEST-SCREEN') + if (offline) return send({}, 503) + return send({ + state: { + gameID: 6, + mode: 'SCREEN_MODE_INTRO', + soundEnabled: true, + volume: 80, + stateVersion: String(version), + command: currentCommand, + }, + }) + } + if (path === '/api/screens/results') + return send({ + questions: [{ code: 'A', text: 'Вопрос' }], + teams: [ + { + team: { ID: 't', name: 'Детективы', password: 'PRIVATE' }, + place: 1, + totalScore: 3, + actionsCount: 1, + answers: [{ questionCode: 'A', score: 3 }], + }, + ], + }) + send({}, 404) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const url = `http://127.0.0.1:${server.address().port}` + const act = (intent) => evaluate(`window.screenBridge.onlineAction(${JSON.stringify(intent)})`) + try { + await evaluate('window.screenBridge.closeOutput()') + // Вход через настоящую форму пульта, загрузка через защищённый IPC. + await poll(() => evaluate('!!document.querySelector("#server")'), 'login form') + await evaluate(`(() => { + for (const [id, value] of Object.entries(${JSON.stringify({ server: url, email: 'organizer@example.test', password: 'test-password' })})) { + const element = document.querySelector('#' + id) + element.value = value; element.dispatchEvent(new Event('input', { bubbles: true })) + } + })()`) + await evaluate( + "Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === 'Войти').click()", + ) + await poll( + async () => (await evaluate('window.screenBridge.onlineState()')).authenticated, + 'HTTP login', + ) + await poll( + async () => !(await evaluate('window.screenBridge.onlineState()')).busy, + 'login complete', + ) + await act({ action: 'download', gameId: 6 }) + const packages = await evaluate('window.screenBridge.packages()') + const pkg = packages.find((p) => p.name === 'HTTP smoke') + assert.ok(pkg) + await evaluate(`window.screenBridge.selectPackage(${JSON.stringify(pkg.key)})`) + await act({ action: 'connect' }) + await evaluate(`window.screenBridge.show(${displayId})`) + await poll( + async () => (await evaluate('window.screenBridge.onlineState()')).connection === 'connected', + 'HTTP baseline', + ) + assert.equal((await state()).channels.main.desired, 'stopped') + version++ + await poll( + async () => (await state()).channels.main.status === 'playing', + 'server play -> local audio', + ) + assert.match( + await inspect('document.querySelector("[data-channel=main-audio]").src'), + /^screen-app:/, + ) + await assert.rejects(act({ action: 'download', gameId: 6 }), /Закройте показ/) + await command({ action: 'pause', channel: 'main' }) + const pausePolls = polls + await poll(() => polls > pausePolls + 1, 'duplicate state polled') + assert.equal((await state()).channels.main.desired, 'paused') + offline = true + await poll( + async () => (await evaluate('window.screenBridge.onlineState()')).connection === 'offline', + 'offline indication', + ) + await command({ action: 'play', channel: 'main' }) + offline = false + currentCommand = 'SCREEN_COMMAND_STOP' + version++ + await poll( + async () => (await evaluate('window.screenBridge.onlineState()')).connection === 'connected', + 'reconnect baseline', + ) + assert.equal((await state()).channels.main.desired, 'playing') + version++ + await poll(async () => (await state()).channels.main.status === 'stopped', 'new remote stop') + await act({ action: 'results' }) + assert.equal((await state()).results.teams[0].name, 'Детективы') + assert.equal(JSON.stringify(await state()).includes('PRIVATE'), false) + for (const method of ['onlineState()', "onlineAction({action:'logout'})", 'closeOutput()']) { + assert.equal( + await inspect(`window.screenBridge.${method}.then(() => false, () => true)`), + true, + ) + } + await act({ action: 'logout' }) + assert.equal((await evaluate('window.screenBridge.onlineState()')).authenticated, false) + console.log('SCREEN_ONLINE_SMOKE_PASS', JSON.stringify({ polls, requests: requests.length })) + } finally { + server.closeAllConnections() + await new Promise((resolve) => server.close(resolve)) + } +} diff --git a/scripts/smoke.cjs b/scripts/smoke.cjs index 79a512e..76e2f47 100644 --- a/scripts/smoke.cjs +++ b/scripts/smoke.cjs @@ -212,7 +212,10 @@ app.whenReady().then(async () => { () => BrowserWindow.getAllWindows().find((w) => w !== control), 'output', ) - const inspect = (source) => output.webContents.executeJavaScript(source) + const inspect = (source) => + BrowserWindow.getAllWindows() + .find((w) => w !== control) + .webContents.executeJavaScript(source) await poll(async () => (await state()).channels.main.status === 'stopped', 'fixture prepared') if (process.platform === 'linux') assert.equal(app.commandLine.getSwitchValue('ozone-platform'), 'x11') @@ -550,6 +553,15 @@ app.whenReady().then(async () => { assert.equal((await state()).channels.main.desired, 'stopped') assert.equal(await evaluate('typeof window.screenBridge.lan'), 'undefined') assert.equal((await state()).results, null) + await require('./online-smoke.cjs')({ + evaluate, + inspect, + state, + command, + poll, + displayId: targetDisplay.id, + root, + }) console.log( 'SCREEN_SMOKE_PASS', JSON.stringify({ diff --git a/src/components/ConnectionPanel.vue b/src/components/ConnectionPanel.vue new file mode 100644 index 0000000..c3df6c1 --- /dev/null +++ b/src/components/ConnectionPanel.vue @@ -0,0 +1,198 @@ + + + diff --git a/src/components/ControlPage.vue b/src/components/ControlPage.vue index e7bf24b..18fabf7 100644 --- a/src/components/ControlPage.vue +++ b/src/components/ControlPage.vue @@ -22,6 +22,7 @@ import type { State, } from '../types/screen' +import ConnectionPanel from './ConnectionPanel.vue' import { modeLabels } from '../utils/presentation' import { useScreenTimer } from '../composables/useScreenTimer' @@ -168,6 +169,15 @@ async function selectPackage(key: string) { } }) } +async function refreshPackages() { + if (bridge) packages.value = await bridge.packages() +} +async function removePackage(key: string) { + await run(async () => { + await bridge?.onlineAction({ action: 'remove', key }) + await refreshPackages() + }) +} async function refreshHost() { if (bridge) host.value = await bridge.host() } @@ -216,6 +226,11 @@ onUnmounted(() => {

Вечерний детектив · Локальный проигрыватель

Откройте пульт в приложении Electron. + { Тестовый режим: синтетические аудио и видео. + + {{ pkg.name }} + Удалить материалы +

Готовых игровых пакетов на диске пока нет.

@@ -243,6 +266,9 @@ onUnmounted(() => { Открыть показ + Закрыть показ