up
This commit is contained in:
@@ -0,0 +1,68 @@
|
|||||||
|
# Завершённые задачи приложения площадки
|
||||||
|
|
||||||
|
## SCREEN-002 — Собрать описание пакета через существующий API
|
||||||
|
|
||||||
|
- Статус: `DONE`.
|
||||||
|
- Дата: 2026-10-17.
|
||||||
|
- Репозиторий: только screen; бэкенд, frontend и protobuf не изменены.
|
||||||
|
- Уточнение владельца: использовать текущий API без изменений бэкенда;
|
||||||
|
SCREEN-002 разрешена до завершения аппаратной приёмки SCREEN-001.
|
||||||
|
- Результат: `electron/screen-package.ts` получает игру, план и музыку через
|
||||||
|
существующие GET endpoints и собирает ScreenPackage версии формата 1.
|
||||||
|
Пакет содержит обложку, фон (с подстановкой обложки), вступительное аудио,
|
||||||
|
заключительное видео, шаги и связанные плейлисты со всеми треками. Ссылки
|
||||||
|
дедуплицируются, треки упорядочены по позиции/ID, посторонняя библиотека,
|
||||||
|
данные участников и живые команды исключены.
|
||||||
|
- Авторизация: существующий JWT используется только для API указанного сервера,
|
||||||
|
не сохраняется в результате; редиректы API запрещены. Поддерживаются отмена,
|
||||||
|
таймаут и безопасные ошибки без содержимого серверных ответов. Адреса с
|
||||||
|
логином/паролем и ссылки с секретными параметрами отклоняются.
|
||||||
|
- Версия: metadataVersion — детерминированный SHA-256 нормализованных метаданных.
|
||||||
|
Идентификаторы ресурсов — SHA-256 URL. Это не контрольные суммы файлов и не
|
||||||
|
подтверждение готовности пакета. Размеры, хеши байтов и итоговая версия —
|
||||||
|
SCREEN-003. Сервер не предоставляет атомарного снимка и хешей ресурсов.
|
||||||
|
- Проверки: `npm run type-check`, `npm run lint`, `npm run lint:css`, `npm test`
|
||||||
|
(12 тестов, включая 8 новых), `npm run build`, `git diff --check` — PASS.
|
||||||
|
- Ограничения: проверено на фикстурах текущего JSON-контракта, живой сервер и
|
||||||
|
реальные игровые файлы не проверялись. Загрузки файлов и новых кнопок нет;
|
||||||
|
программный контракт подключается к интерфейсу при SCREEN-006. Существующие
|
||||||
|
права доступа и публичная выдача файлов не менялись. Установочные архивы
|
||||||
|
не пересобирались; аппаратная приёмка SCREEN-001 остаётся открытой.
|
||||||
|
- Следующий шаг: SCREEN-003 — скачать и проверить файлы, сформировать итоговую
|
||||||
|
версию и атомарно активировать готовый локальный пакет.
|
||||||
|
|
||||||
|
## SCREEN-003 — Скачивать и проверять материалы на диске
|
||||||
|
|
||||||
|
- Статус: `DONE`.
|
||||||
|
- Дата: 2026-10-17.
|
||||||
|
- Репозиторий: только screen; бэкенд, frontend и protobuf не изменены.
|
||||||
|
- Результат: PackageStore в основном процессе; потоковая последовательная загрузка,
|
||||||
|
отмена, прогресс, контроль свободного места с резервом 64 МиБ, SHA-256/размер
|
||||||
|
при записи и повторная проверка с диска. При обрыве повторяется целый текущий
|
||||||
|
файл (выбор владельца); максимум три попытки, паузы 1/3 секунды, таймаут 30 секунд.
|
||||||
|
- Хранение: userData/packages, отдельные каталоги игры и версий, временная загрузка,
|
||||||
|
синхронизация файлов, атомарная замена указателя текущей версии. Неудачное
|
||||||
|
обновление сохраняет предыдущую готовую копию; отмена удаляет временные данные.
|
||||||
|
При перезапуске проверяются все файлы и очищаются оставшиеся временные/старые
|
||||||
|
версии. Повреждённый текущий пакет не становится готовым и доступен для удаления
|
||||||
|
либо восстановления повторной загрузкой. Приложение запускается одним экземпляром.
|
||||||
|
- Доступ: локальная выдача только готовых файлов по ключу игры и ID ресурса,
|
||||||
|
MIME/Range/no-store; произвольные пути и симлинки отклоняются. Скачивание медиа
|
||||||
|
без JWT/cookies, до пяти проверенных редиректов, запрет HTTPS→HTTP и URL с секретами.
|
||||||
|
Учтены MIME audio/wave и application/ogg существующего бэкенда.
|
||||||
|
- Проверки: `npm run type-check`, `npm run lint`, `npm run lint:css`, `npm test`
|
||||||
|
(26 тестов, 14 новых), сборка в составе `npm run smoke`, `git diff --check` — PASS.
|
||||||
|
Протестированы обрывы/повторы, отмена до активации, нехватка места до и во время
|
||||||
|
загрузки, таймаут, повреждение при проверке/после перезапуска, восстановление,
|
||||||
|
следы сбоя, редиректы, ошибочные ответы, симлинки и запрет параллельных изменений.
|
||||||
|
- Офлайн-проверка: настоящий HTTP-сервер выключен после загрузки, новое хранилище
|
||||||
|
восстановило и прочитало пакет. GUI smoke Electron 42.11.15: повторное открытие
|
||||||
|
хранилища, WAV через локальный протокол, Range 206/44 байта, длительность 60 секунд,
|
||||||
|
воспроизведение — PASS; тестовые --no-sandbox --mute-audio --disable-gpu.
|
||||||
|
- Ограничения: проверено на текущем Linux x64; macOS/Windows и аппаратная матрица
|
||||||
|
SCREEN-001 остаются открытыми. GUI использовал синтетический второй дисплей.
|
||||||
|
Рабочий сервер и реальные игровые материалы не проверялись; сервер не выдаёт
|
||||||
|
эталонных хешей и атомарного снимка. Установочные архивы не пересобирались.
|
||||||
|
UI загрузки/вход остаётся в SCREEN-006; хранилище предоставляет программный API.
|
||||||
|
- Следующий шаг: SCREEN-004 — подключить подготовленные локальные ресурсы к
|
||||||
|
проигрывателю и игровым командам без ожидания сети.
|
||||||
@@ -69,8 +69,11 @@ Linux проверяется на обоих указанных дистрибу
|
|||||||
размеры и SHA-256. Загрузка во временные файлы, проверка, затем атомарная
|
размеры и SHA-256. Загрузка во временные файлы, проверка, затем атомарная
|
||||||
активация полного пакета. Старый готовый пакет сохраняется при ошибке обновления.
|
активация полного пакета. Старый готовый пакет сохраняется при ошибке обновления.
|
||||||
Данные хранятся в каталоге приложения, не в Git и не рядом с исполняемым файлом.
|
Данные хранятся в каталоге приложения, не в Git и не рядом с исполняемым файлом.
|
||||||
5. Сервер проверяет право организатора на скачивание материалов. Существующий
|
5. Данные игры получаются с существующими правами организатора/автора; выдача
|
||||||
API используется где достаточно; манифест добавляется минимальным расширением.
|
файлов сохраняет текущие правила сервера. API используется без изменений
|
||||||
|
бэкенда и protobuf. SCREEN-002 собирает локальное
|
||||||
|
описание и отпечаток метаданных; SCREEN-003 вычисляет размеры и SHA-256
|
||||||
|
скачанных файлов, итоговую версию и подтверждает готовность пакета.
|
||||||
Токены не записываются в пакет игры и логи.
|
Токены не записываются в пакет игры и логи.
|
||||||
6. После подготовки основной процесс применяет локальные команды пульта компьютера
|
6. После подготовки основной процесс применяет локальные команды пульта компьютера
|
||||||
через IPC без ожидания сервера. Материалы воспроизводятся с диска.
|
через IPC без ожидания сервера. Материалы воспроизводятся с диска.
|
||||||
@@ -112,7 +115,8 @@ Linux проверяется на обоих указанных дистрибу
|
|||||||
|
|
||||||
1. SCREEN-001: проверить Electron на всех шести вариантах, два дисплея, локальное
|
1. SCREEN-001: проверить Electron на всех шести вариантах, два дисплея, локальное
|
||||||
медиа и задержки; порог задержки — на M1.
|
медиа и задержки; порог задержки — на M1.
|
||||||
2. SCREEN-002: получить полный и защищённый пакет материалов с сервера.
|
2. SCREEN-002: собрать описание материалов показа через существующий API;
|
||||||
|
вычисление хешей файлов и подтверждение готовности — SCREEN-003.
|
||||||
3. SCREEN-003: реализовать надёжную загрузку и локальное хранение пакетов.
|
3. SCREEN-003: реализовать надёжную загрузку и локальное хранение пакетов.
|
||||||
4. SCREEN-004: реализовать локальный проигрыватель и команды, подготовку медиа.
|
4. SCREEN-004: реализовать локальный проигрыватель и команды, подготовку медиа.
|
||||||
5. SCREEN-005: собрать пульт, план показа и режимы экрана.
|
5. SCREEN-005: собрать пульт, план показа и режимы экрана.
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# Вечерний детектив — приложение площадки
|
# Вечерний детектив — приложение площадки
|
||||||
|
|
||||||
Статус: SCREEN-001 в работе; технический стенд реализован, аппаратная приёмка открыта.
|
Статус: SCREEN-001 в работе; технический стенд реализован, аппаратная приёмка открыта.
|
||||||
|
SCREEN-002 и SCREEN-003 завершены: описание пакета через существующий API,
|
||||||
|
загрузка, проверка, восстановление и локальная выдача файлов.
|
||||||
Управление с телефона идёт через существующий сервер сайта; desktop получает
|
Управление с телефона идёт через существующий сервер сайта; desktop получает
|
||||||
обновления с сервера. Прямое LAN-подключение не используется.
|
обновления с сервера. Прямое LAN-подключение не используется.
|
||||||
|
|
||||||
@@ -41,7 +43,7 @@ npm run dev
|
|||||||
его и откройте показ. Аудио и видео — синтетические локальные файлы длительностью
|
его и откройте показ. Аудио и видео — синтетические локальные файлы длительностью
|
||||||
60 секунд; бинарники находятся в игнорируемом fixtures/ и не входят в Git.
|
60 секунд; бинарники находятся в игнорируемом fixtures/ и не входят в Git.
|
||||||
Стенд поддерживает пуск с начала, паузу, остановку и перемотку. Полные игровые
|
Стенд поддерживает пуск с начала, паузу, остановку и перемотку. Полные игровые
|
||||||
режимы, загрузка игр и авторизация пока не реализованы.
|
режимы, интерфейс загрузки игр и авторизация пока не реализованы.
|
||||||
|
|
||||||
Управление телефоном через сервер, подключение игры и авторизация относятся к
|
Управление телефоном через сервер, подключение игры и авторизация относятся к
|
||||||
SCREEN-006 и в техническом стенде ещё не реализованы. Интернет нужен для получения
|
SCREEN-006 и в техническом стенде ещё не реализованы. Интернет нужен для получения
|
||||||
@@ -49,3 +51,85 @@ SCREEN-006 и в техническом стенде ещё не реализо
|
|||||||
|
|
||||||
Проверки: npm run type-check, npm run lint, npm run lint:css, npm test, npm run build.
|
Проверки: npm run type-check, npm run lint, npm run lint:css, npm test, npm run build.
|
||||||
[Сборки и аппаратная приёмка](docs/SCREEN-001_ACCEPTANCE.md).
|
[Сборки и аппаратная приёмка](docs/SCREEN-001_ACCEPTANCE.md).
|
||||||
|
|
||||||
|
## Описание пакета SCREEN-002
|
||||||
|
|
||||||
|
`electron/screen-package.ts` экспортирует `prepareScreenPackage({ server, gameId,
|
||||||
|
token, signal? })` для вызова из основного процесса. Метод делает три GET-запроса:
|
||||||
|
`/api/games/{ID}`, `/api/games/{ID}/screen-plan`, `/api/games/{ID}/music`.
|
||||||
|
Авторизация — существующий Bearer JWT; токен используется только в запросах API,
|
||||||
|
не входит в результат. Редиректы API запрещены, ожидание ограничено 30 секундами;
|
||||||
|
поддерживается отмена через AbortSignal. Ответы с ошибками не выдают частичный пакет.
|
||||||
|
Интерфейс входа и подключение к пульту остаются в SCREEN-006; сейчас это программный
|
||||||
|
контракт, новых кнопок в стенде нет. Бэкенд, frontend и protobuf не изменены.
|
||||||
|
|
||||||
|
Результат `ScreenPackage` содержит `formatVersion: 1`, `metadataVersion`, адрес
|
||||||
|
сервера, ID/название игры, ссылки на материалы, шаги плана и связанные плейлисты.
|
||||||
|
`assets` содержит уникальные URL и их идентификаторы (SHA-256 адреса, не файла).
|
||||||
|
В `media` и треках используются эти идентификаторы. Фон по умолчанию — обложка.
|
||||||
|
Плейлисты сценария, игры и текущий выбранный включаются со всеми треками; прочая
|
||||||
|
библиотека, данные участников и живые команды не сохраняются. Ссылки с секретами
|
||||||
|
в параметрах и адреса с логином/паролем отклоняются.
|
||||||
|
|
||||||
|
`metadataVersion` — SHA-256 нормализованных метаданных. Он не замечает замену
|
||||||
|
байтов по прежнему URL. Само описание не означает готовность к показу. В SCREEN-003 размер и SHA-256
|
||||||
|
вычисляются при скачивании, после чего формируется итоговая версия и пакет
|
||||||
|
активируется атомарно. Внешним медиа нельзя передавать JWT сервера.
|
||||||
|
Существующий API не предоставляет атомарного снимка или серверных хешей файлов;
|
||||||
|
права доступа к играм и публичная выдача файлов остаются прежними.
|
||||||
|
|
||||||
|
## Локальное хранилище SCREEN-003
|
||||||
|
|
||||||
|
Основной процесс открывает `PackageStore` из `electron/package-store.ts` в
|
||||||
|
`app.getPath('userData')/packages`. Приложение допускает только один экземпляр,
|
||||||
|
чтобы две копии не изменяли хранилище одновременно. При запуске проверяются
|
||||||
|
манифесты, размеры и SHA-256 всех файлов; повреждённые пакеты не становятся готовыми.
|
||||||
|
|
||||||
|
Программный интерфейс (для основного процесса; подключение к UI — SCREEN-006):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const store = new PackageStore(packagesDirectory)
|
||||||
|
await store.initialize()
|
||||||
|
const description = await prepareScreenPackage({ server, gameId, token })
|
||||||
|
const ready = await store.download(description, { signal, onProgress })
|
||||||
|
// Другие операции: list(), cancel(), verify(key), remove(key), localFile(key, assetId).
|
||||||
|
```
|
||||||
|
|
||||||
|
Скачивание последовательно и потоково. Файл синхронизируется с диском и повторно
|
||||||
|
читается для проверки хеша. При обрыве текущий файл повторяется целиком: максимум
|
||||||
|
три попытки с паузами 1 и 3 секунды; завершённые файлы текущей попытки не повторяются.
|
||||||
|
Таймаут бездействия — 30 секунд. Ошибки 408/429/5xx повторяются; ошибки доступа,
|
||||||
|
неизвестные форматы и отсутствующие файлы останавливают подготовку. Незнакомый
|
||||||
|
общий размер в прогрессе обозначается `totalBytes: null`; итоговый размер известен
|
||||||
|
после загрузки. Поддерживаются MIME WAV/OGG, используемые текущим сервером.
|
||||||
|
|
||||||
|
Медиа запрашиваются без JWT и cookies. Разрешено до пяти проверенных HTTP(S)
|
||||||
|
редиректов; запрещены учётные данные в URL, секретные параметры и понижение
|
||||||
|
HTTPS до HTTP. Контроль места использует statfs перед загрузкой и перед записью
|
||||||
|
очередного блока, оставляя резерв 64 МиБ; фактическая ошибка ENOSPC также обрабатывается.
|
||||||
|
|
||||||
|
Каталог игры определяется хешем адреса сервера и ID игры; пути ресурсов — хешами
|
||||||
|
URL, независимо от исходного имени файла. Локальный манифест содержит исходное
|
||||||
|
описание, MIME, размеры и SHA-256 байтов; итоговая версия зависит от метаданных
|
||||||
|
и проверенных файлов. Секреты в манифест не записываются.
|
||||||
|
|
||||||
|
Новая версия готовится в `staging-*`, затем помещается в `v-<version>`. Указатель
|
||||||
|
`current.json` заменяется через временный файл и rename только после проверки.
|
||||||
|
Отмена и ошибки до переключения не затрагивают прежний готовый пакет. После
|
||||||
|
успеха прежняя версия удаляется; заблокированные ОС остатки повторно очищаются
|
||||||
|
при следующем запуске. Сбой после переключения не отменяет новую готовую версию.
|
||||||
|
Повреждённую текущую копию можно восстановить повторной загрузкой или удалить.
|
||||||
|
Симлинки при чтении не обходятся; удаление ограничено каталогом выбранной игры.
|
||||||
|
|
||||||
|
`screen-app://app/packages/<key>/<assetId>` выдаёт только ресурсы готового пакета,
|
||||||
|
с правильным MIME, запретом кеширования и поддержкой Range. Произвольные пути
|
||||||
|
и сетевые URL в протокол не передаются. Воспроизведение игровых пакетов подключается
|
||||||
|
в SCREEN-004; синтетический стенд пока использует прежние fixture-файлы.
|
||||||
|
|
||||||
|
`npm test` включает настоящий временный HTTP-сервер: после скачивания сервер
|
||||||
|
отключается, новое хранилище проверяет пакет, локальная выдача читает все ресурсы.
|
||||||
|
`npm run smoke -- --no-sandbox --mute-audio --disable-gpu` дополнительно готовит
|
||||||
|
WAV-пакет в отдельном временном userData, открывает его повторно через основной
|
||||||
|
процесс и проверяет Range и воспроизведение. Эти флаги используются только в тесте.
|
||||||
|
Серверных эталонных хешей нет: локальная проверка подтверждает целостность
|
||||||
|
скачанной копии, но не неизменность источников во время подготовки.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# Задачи приложения площадки
|
# Задачи приложения площадки
|
||||||
|
|
||||||
SCREEN-001 имеет статус IN_PROGRESS; остальные задачи — TODO.
|
SCREEN-001 имеет статус IN_PROGRESS; SCREEN-002 и SCREEN-003 завершены
|
||||||
|
и перенесены в архив; остальные активные задачи — TODO.
|
||||||
SCREEN-009 (LAN-пульт) отменена владельцем; ID не переиспользовать.
|
SCREEN-009 (LAN-пульт) отменена владельцем; ID не переиспользовать.
|
||||||
Управление телефоном через сервер входит в SCREEN-006. План и общие ограничения:
|
Управление телефоном через сервер входит в SCREEN-006. План и общие ограничения:
|
||||||
[PLAN.md](PLAN.md). Идентификаторы независимы от ED-* в корневом списке.
|
[PLAN.md](PLAN.md). Идентификаторы независимы от ED-* в корневом списке.
|
||||||
@@ -62,47 +63,14 @@ SCREEN-009 (LAN-пульт) отменена владельцем; ID не пе
|
|||||||
дистрибутивах), включая проектор,
|
дистрибутивах), включая проектор,
|
||||||
физический звук/кадр и 30 запусков для каждого формата/аудиовыхода. macOS-сборки
|
физический звук/кадр и 30 запусков для каждого формата/аудиовыхода. macOS-сборки
|
||||||
подготовить на Mac или ручным workflow builds.yml. До результатов всей матрицы
|
подготовить на Mac или ручным workflow builds.yml. До результатов всей матрицы
|
||||||
задача остаётся IN_PROGRESS, SCREEN-002 не начинается.
|
задача остаётся IN_PROGRESS. Владелец разрешил начать SCREEN-002 до завершения
|
||||||
|
аппаратной приёмки; её результаты остаются открытыми.
|
||||||
## SCREEN-002 — Выдать версионированный пакет показа
|
|
||||||
|
|
||||||
- Статус: `TODO`
|
|
||||||
- Зависимость: SCREEN-001 подтверждён на всей матрице платформ.
|
|
||||||
- Репозитории: screen, server; frontend только при изменениях общего protobuf.
|
|
||||||
- Цель: получить полный список данных и файлов для подготовки конкретной игры.
|
|
||||||
- Объём: изучить доступ организатора, план, обложки, фон, вступление, видео,
|
|
||||||
плейлисты и вложенные ресурсы; использовать текущие RPC, добавить недостающий
|
|
||||||
манифест с версией, размерами, контрольными суммами и авторизованной загрузкой.
|
|
||||||
- Готовность: все ресурсы показа перечислены; изменение файла меняет версию;
|
|
||||||
отсутствие обязательного ресурса обнаруживается; чужую игру скачать нельзя;
|
|
||||||
существующие клиенты продолжают работать. Токены не входят в снимок пакета.
|
|
||||||
- Проверка: тесты полноты, версий и отказа в доступе; `go test ./...`; штатная
|
|
||||||
генерация protobuf и проверки frontend при затрагивании контракта.
|
|
||||||
- Не входит: локальная копия всей серверной БД и материалов вне показа.
|
|
||||||
- Следующий шаг: проследить callers RPC экранов и доступ к файлам, описать
|
|
||||||
минимальный контракт пакета и только затем менять API.
|
|
||||||
|
|
||||||
## SCREEN-003 — Скачивать и проверять материалы на диске
|
|
||||||
|
|
||||||
- Статус: `TODO`
|
|
||||||
- Зависимость: SCREEN-002.
|
|
||||||
- Цель: гарантировать полноту локального пакета до начала игры.
|
|
||||||
- Объём: прогресс и отмена загрузки, проверка свободного места, временные файлы,
|
|
||||||
SHA-256/размер, повтор после обрыва, атомарная активация, явное удаление пакета.
|
|
||||||
Незавершённые файлы никогда не выдавать как готовые.
|
|
||||||
- Готовность: все материалы читаются без сети; повреждённый или неполный пакет
|
|
||||||
не получает статус готовности; неудачное обновление сохраняет предыдущую версию;
|
|
||||||
перезапуск сохраняет готовность; удаление не затрагивает файлы вне хранилища.
|
|
||||||
- Проверка: тесты повреждения, нехватки места, обрыва/повтора, путей и обновления;
|
|
||||||
штатные проверки приложения; ручной запуск скачанного пакета без сети.
|
|
||||||
- Не входит: фоновое автообновление активного пакета и неограниченный кэш.
|
|
||||||
- Следующий шаг: определить формат каталога и манифеста, реализовать одну полную
|
|
||||||
загрузку с проверкой и затем обработать сбои.
|
|
||||||
|
|
||||||
## SCREEN-004 — Локальное воспроизведение и быстрые команды
|
## SCREEN-004 — Локальное воспроизведение и быстрые команды
|
||||||
|
|
||||||
- Статус: `TODO`
|
- Статус: `TODO`
|
||||||
- Зависимость: SCREEN-003; использовать основу SCREEN-001.
|
- Зависимость: SCREEN-003 (DONE, см. COMPLETED_TASKS.md); использовать основу
|
||||||
|
SCREEN-001. Материалы брать из PackageStore и screen-app://app/packages/<key>/<assetId>.
|
||||||
- Цель: применять команды пульта независимо от сервера.
|
- Цель: применять команды пульта независимо от сервера.
|
||||||
- Объём: локальное состояние в основном процессе; play/pause/resume/stop/replay,
|
- Объём: локальное состояние в основном процессе; play/pause/resume/stop/replay,
|
||||||
seek, громкость и mute; плейлист, следующий трек и повтор; подготовка ближайшего
|
seek, громкость и mute; плейлист, следующий трек и повтор; подготовка ближайшего
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { join } from 'node:path'
|
|||||||
import { Readable } from 'node:stream'
|
import { Readable } from 'node:stream'
|
||||||
import { Controller } from './controller.js'
|
import { Controller } from './controller.js'
|
||||||
import { parseRange } from './range.js'
|
import { parseRange } from './range.js'
|
||||||
|
import { PackageStore } from './package-store.js'
|
||||||
|
import { packageMediaResponse } from './package-media.js'
|
||||||
import type { HostInfo } from '../src/types/screen.js'
|
import type { HostInfo } from '../src/types/screen.js'
|
||||||
|
|
||||||
// Native Wayland запрещает размещение окна на выбранном дисплее по координатам.
|
// Native Wayland запрещает размещение окна на выбранном дисплее по координатам.
|
||||||
@@ -87,9 +89,19 @@ function show(displayId: number) {
|
|||||||
})
|
})
|
||||||
void output.loadURL('screen-app://app/index.html?output=1')
|
void output.loadURL('screen-app://app/index.html?output=1')
|
||||||
}
|
}
|
||||||
|
// Две копии приложения не должны одновременно менять указатели пакетов.
|
||||||
|
if (!app.requestSingleInstanceLock()) app.exit(0)
|
||||||
|
app.on('second-instance', () => {
|
||||||
|
control?.show()
|
||||||
|
control?.focus()
|
||||||
|
})
|
||||||
|
|
||||||
app
|
app
|
||||||
.whenReady()
|
.whenReady()
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
|
const packages = new PackageStore(join(app.getPath('userData'), 'packages'))
|
||||||
|
await packages.initialize()
|
||||||
|
app.on('before-quit', () => packages.cancel())
|
||||||
session.defaultSession.setPermissionRequestHandler((_contents, _permission, callback) =>
|
session.defaultSession.setPermissionRequestHandler((_contents, _permission, callback) =>
|
||||||
callback(false),
|
callback(false),
|
||||||
)
|
)
|
||||||
@@ -102,6 +114,7 @@ app
|
|||||||
'X-Content-Type-Options': 'nosniff',
|
'X-Content-Type-Options': 'nosniff',
|
||||||
}
|
}
|
||||||
if (url.host !== 'app' || request.method !== 'GET') return new Response(null, { status: 404 })
|
if (url.host !== 'app' || request.method !== 'GET') return new Response(null, { status: 404 })
|
||||||
|
if (url.pathname.startsWith('/packages/')) return packageMediaResponse(packages, request)
|
||||||
if (url.pathname.startsWith('/media/')) {
|
if (url.pathname.startsWith('/media/')) {
|
||||||
const media =
|
const media =
|
||||||
url.pathname === '/media/audio'
|
url.pathname === '/media/audio'
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { createReadStream } from 'node:fs'
|
||||||
|
import { Readable } from 'node:stream'
|
||||||
|
import type { PackageStore } from './package-store.js'
|
||||||
|
import { parseRange } from './range.js'
|
||||||
|
|
||||||
|
export async function packageMediaResponse(
|
||||||
|
store: PackageStore,
|
||||||
|
request: Request,
|
||||||
|
): Promise<Response> {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
const match = /^\/packages\/([a-f0-9]{64})\/([a-f0-9]{64})$/.exec(url.pathname)
|
||||||
|
if (url.host !== 'app' || request.method !== 'GET' || !match)
|
||||||
|
return new Response(null, { status: 404 })
|
||||||
|
try {
|
||||||
|
const file = await store.localFile(match[1]!, match[2]!)
|
||||||
|
const rangeHeader = request.headers.get('range')
|
||||||
|
const range = parseRange(rangeHeader, file.size)
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': file.mime,
|
||||||
|
'Accept-Ranges': 'bytes',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
|
'Content-Security-Policy': "default-src 'none'; frame-ancestors 'none'; sandbox",
|
||||||
|
}
|
||||||
|
if (!range)
|
||||||
|
return new Response(null, {
|
||||||
|
status: 416,
|
||||||
|
headers: { ...headers, 'Content-Range': `bytes */${file.size}` },
|
||||||
|
})
|
||||||
|
headers['Content-Length'] = String(range.end - range.start + 1)
|
||||||
|
if (rangeHeader) headers['Content-Range'] = `bytes ${range.start}-${range.end}/${file.size}`
|
||||||
|
const stream = createReadStream(file.path, range)
|
||||||
|
const abort = () => stream.destroy()
|
||||||
|
request.signal.addEventListener('abort', abort, { once: true })
|
||||||
|
stream.on('close', () => request.signal.removeEventListener('abort', abort))
|
||||||
|
if (request.signal.aborted) stream.destroy()
|
||||||
|
return new Response(Readable.toWeb(stream) as ReadableStream<Uint8Array>, {
|
||||||
|
status: rangeHeader ? 206 : 200,
|
||||||
|
headers,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return new Response(null, { status: 404 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { createHash, randomUUID } from 'node:crypto'
|
||||||
|
import { createServer } from 'node:http'
|
||||||
|
import { mkdtemp, readFile, readdir, rm, mkdir, symlink, writeFile } from 'node:fs/promises'
|
||||||
|
import { readdirSync, writeFileSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { test, type TestContext } from 'node:test'
|
||||||
|
import { buildScreenPackage, parseScreenPackage } from './screen-package.js'
|
||||||
|
import { PackageStore, packageKey, type DownloadProgress } from './package-store.js'
|
||||||
|
import { packageMediaResponse } from './package-media.js'
|
||||||
|
|
||||||
|
const highSpace = 1n << 40n
|
||||||
|
const body = Buffer.from('RIFFtest-data-WAVE')
|
||||||
|
function description(server = 'https://media.test') {
|
||||||
|
return buildScreenPackage(server, 7, {
|
||||||
|
game: {
|
||||||
|
game: {
|
||||||
|
ID: 7,
|
||||||
|
name: 'Test',
|
||||||
|
scenario: {
|
||||||
|
image: `${server}/cover.png`,
|
||||||
|
story: { introduction: { audio: `${server}/intro.wav` } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plan: {},
|
||||||
|
music: {},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
function mediaResponse(bytes: Uint8Array = body, mime = 'audio/wav') {
|
||||||
|
return new Response(Buffer.from(bytes), {
|
||||||
|
headers: { 'Content-Type': mime, 'Content-Length': String(bytes.length) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
async function fixture(t: TestContext) {
|
||||||
|
const dir = await mkdtemp(join(tmpdir(), 'screen-store-'))
|
||||||
|
t.after(() => rm(dir, { recursive: true, force: true }))
|
||||||
|
const root = join(dir, 'packages')
|
||||||
|
const state: {
|
||||||
|
bytes: Buffer
|
||||||
|
free: bigint
|
||||||
|
handler?: (url: string, init?: RequestInit) => Promise<Response>
|
||||||
|
} = { bytes: body, free: highSpace }
|
||||||
|
const calls: string[] = []
|
||||||
|
const delays: number[] = []
|
||||||
|
const dependencies = {
|
||||||
|
fetch: (async (input, init) => {
|
||||||
|
calls.push(String(input))
|
||||||
|
assert.equal(new Headers(init?.headers).get('Authorization'), null)
|
||||||
|
assert.equal(init?.credentials, 'omit')
|
||||||
|
assert.equal(init?.redirect, 'manual')
|
||||||
|
return state.handler ? state.handler(String(input), init) : mediaResponse(state.bytes)
|
||||||
|
}) as typeof fetch,
|
||||||
|
freeBytes: async () => state.free,
|
||||||
|
wait: async (milliseconds: number, signal: AbortSignal) => {
|
||||||
|
signal.throwIfAborted()
|
||||||
|
delays.push(milliseconds)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const store = new PackageStore(root, dependencies)
|
||||||
|
await store.initialize()
|
||||||
|
const pkg = description()
|
||||||
|
const key = packageKey(pkg.server, pkg.game.id)
|
||||||
|
return { root, dir, state, calls, delays, dependencies, store, pkg, key }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('загружает, проверяет с диска, восстанавливает после перезапуска и выдаёт офлайн Range', async (t) => {
|
||||||
|
const f = await fixture(t)
|
||||||
|
const progress: DownloadProgress[] = []
|
||||||
|
const downloaded = await f.store.download(f.pkg, { onProgress: (value) => progress.push(value) })
|
||||||
|
assert.equal(downloaded.files.length, 2)
|
||||||
|
assert.equal(downloaded.files[0]!.sha256, createHash('sha256').update(body).digest('hex'))
|
||||||
|
assert.deepEqual(progress.at(-1), {
|
||||||
|
stage: 'ready',
|
||||||
|
assetId: null,
|
||||||
|
downloadedBytes: body.length * 2,
|
||||||
|
completedFiles: 2,
|
||||||
|
totalFiles: 2,
|
||||||
|
totalBytes: body.length * 2,
|
||||||
|
})
|
||||||
|
assert.ok(progress.slice(0, -1).every((p) => p.totalBytes === null))
|
||||||
|
f.state.handler = async () => {
|
||||||
|
throw new Error('Network disabled')
|
||||||
|
}
|
||||||
|
const restored = new PackageStore(f.root, f.dependencies)
|
||||||
|
await restored.initialize()
|
||||||
|
assert.deepEqual(restored.list(), [downloaded])
|
||||||
|
for (const asset of f.pkg.assets) {
|
||||||
|
const url = `screen-app://app/packages/${f.key}/${asset.id}`
|
||||||
|
const response = await packageMediaResponse(restored, new Request(url))
|
||||||
|
assert.equal(response.status, 200)
|
||||||
|
assert.deepEqual(Buffer.from(await response.arrayBuffer()), body)
|
||||||
|
const range = await packageMediaResponse(
|
||||||
|
restored,
|
||||||
|
new Request(url, { headers: { Range: 'bytes=2-5' } }),
|
||||||
|
)
|
||||||
|
assert.equal(range.status, 206)
|
||||||
|
assert.equal(range.headers.get('content-range'), `bytes 2-5/${body.length}`)
|
||||||
|
assert.deepEqual(Buffer.from(await range.arrayBuffer()), body.subarray(2, 6))
|
||||||
|
assert.equal(
|
||||||
|
(await packageMediaResponse(restored, new Request(url, { headers: { Range: 'bytes=999-' } })))
|
||||||
|
.status,
|
||||||
|
416,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assert.equal(f.calls.length, 2)
|
||||||
|
const copy = restored.list()
|
||||||
|
copy[0]!.files.length = 0
|
||||||
|
assert.equal(restored.list()[0]!.files.length, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('новые байты по тому же URL меняют версию; старая версия удаляется после активации', async (t) => {
|
||||||
|
const f = await fixture(t)
|
||||||
|
const first = await f.store.download(f.pkg)
|
||||||
|
f.state.bytes = Buffer.from('different media bytes')
|
||||||
|
const second = await f.store.download(f.pkg)
|
||||||
|
assert.equal(first.package.metadataVersion, second.package.metadataVersion)
|
||||||
|
assert.notEqual(first.version, second.version)
|
||||||
|
assert.deepEqual(await readdir(join(f.root, f.key)), ['current.json', `v-${second.version}`])
|
||||||
|
assert.equal(
|
||||||
|
await readFile(join(f.root, f.key, 'current.json'), 'utf8'),
|
||||||
|
JSON.stringify(second.version),
|
||||||
|
)
|
||||||
|
assert.equal((await f.store.download(f.pkg)).version, second.version)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('после обрыва повторяет только текущий файл целиком', async (t) => {
|
||||||
|
const f = await fixture(t)
|
||||||
|
const broken = f.pkg.assets[1]!.url
|
||||||
|
let attempts = 0
|
||||||
|
f.state.handler = async (url) => {
|
||||||
|
if (url !== broken || attempts++ > 0) return mediaResponse()
|
||||||
|
let reads = 0
|
||||||
|
return new Response(
|
||||||
|
new ReadableStream({
|
||||||
|
pull(controller) {
|
||||||
|
if (reads++ === 0) controller.enqueue(body.subarray(0, 3))
|
||||||
|
else controller.error(new Error('Connection reset'))
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ headers: { 'Content-Type': 'audio/wav', 'Content-Length': String(body.length) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
await f.store.download(f.pkg)
|
||||||
|
assert.equal(f.calls.filter((url) => url === f.pkg.assets[0]!.url).length, 1)
|
||||||
|
assert.equal(f.calls.filter((url) => url === broken).length, 2)
|
||||||
|
assert.deepEqual(f.delays, [1000])
|
||||||
|
assert.deepEqual(await readFile((await f.store.localFile(f.key, f.pkg.assets[1]!.id)).path), body)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ошибка обновления, отмена и нехватка места сохраняют прежний пакет', async (t) => {
|
||||||
|
const f = await fixture(t)
|
||||||
|
const first = await f.store.download(f.pkg)
|
||||||
|
f.state.handler = async () => {
|
||||||
|
throw new Error('Offline')
|
||||||
|
}
|
||||||
|
await assert.rejects(f.store.download(f.pkg), /Обрыв/)
|
||||||
|
assert.deepEqual(f.delays, [1000, 3000])
|
||||||
|
assert.deepEqual(f.store.list(), [first])
|
||||||
|
f.state.handler = undefined
|
||||||
|
await assert.rejects(
|
||||||
|
f.store.download(f.pkg, {
|
||||||
|
onProgress: (p) => {
|
||||||
|
if (p.stage === 'downloading') f.store.cancel()
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/отменена/,
|
||||||
|
)
|
||||||
|
const signal = new AbortController()
|
||||||
|
await assert.rejects(
|
||||||
|
f.store.download(f.pkg, {
|
||||||
|
signal: signal.signal,
|
||||||
|
onProgress: (p) => {
|
||||||
|
if (p.stage === 'activating') signal.abort()
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/отменена/,
|
||||||
|
)
|
||||||
|
f.state.free = 0n
|
||||||
|
await assert.rejects(f.store.download(f.pkg), /Недостаточно места/)
|
||||||
|
f.state.free = highSpace
|
||||||
|
await assert.rejects(
|
||||||
|
f.store.download(f.pkg, {
|
||||||
|
onProgress: (p) => {
|
||||||
|
if (p.stage === 'downloading') f.state.free = 0n
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/Недостаточно места/,
|
||||||
|
)
|
||||||
|
assert.deepEqual(f.store.list(), [first])
|
||||||
|
assert.deepEqual(await readdir(join(f.root, f.key)), ['current.json', `v-${first.version}`])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('не активирует файл, повреждённый между записью и повторной проверкой', async (t) => {
|
||||||
|
const f = await fixture(t)
|
||||||
|
await assert.rejects(
|
||||||
|
f.store.download(f.pkg, {
|
||||||
|
onProgress: (p) => {
|
||||||
|
if (p.stage !== 'verifying') return
|
||||||
|
const stage = readdirSync(join(f.root, f.key)).find((name) => name.startsWith('staging-'))!
|
||||||
|
writeFileSync(join(f.root, f.key, stage, 'assets', p.assetId!), Buffer.alloc(body.length))
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/Ошибка проверки/,
|
||||||
|
)
|
||||||
|
assert.deepEqual(f.store.list(), [])
|
||||||
|
assert.deepEqual(await readdir(join(f.root, f.key)), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('повреждение после перезапуска и некорректный манифест исключают готовность', async (t) => {
|
||||||
|
const f = await fixture(t)
|
||||||
|
const first = await f.store.download(f.pkg)
|
||||||
|
const path = (await f.store.localFile(f.key, first.files[0]!.id)).path
|
||||||
|
await writeFile(path, Buffer.alloc(body.length))
|
||||||
|
await assert.rejects(f.store.verify(f.key), /Повреждён файл/)
|
||||||
|
assert.deepEqual(f.store.list(), [])
|
||||||
|
const restored = new PackageStore(f.root, f.dependencies)
|
||||||
|
await restored.initialize()
|
||||||
|
assert.deepEqual(restored.list(), [])
|
||||||
|
await writeFile(join(f.root, f.key, 'current.json'), JSON.stringify('../../outside'))
|
||||||
|
await restored.initialize()
|
||||||
|
assert.deepEqual(restored.list(), [])
|
||||||
|
const bad = structuredClone(f.pkg)
|
||||||
|
bad.assets[0]!.id = '../escape'
|
||||||
|
assert.throws(() => parseScreenPackage(bad), /ресурс/)
|
||||||
|
await assert.rejects(f.store.download(bad), /ресурс/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('очищает следы сбоя до/после переключения указателя', async (t) => {
|
||||||
|
const f = await fixture(t)
|
||||||
|
const ready = await f.store.download(f.pkg)
|
||||||
|
const stage = join(f.root, f.key, `staging-${randomUUID()}`)
|
||||||
|
const orphan = join(f.root, f.key, `v-${'a'.repeat(64)}`)
|
||||||
|
await mkdir(stage)
|
||||||
|
await mkdir(orphan)
|
||||||
|
await writeFile(join(f.root, f.key, 'current.tmp'), JSON.stringify('b'.repeat(64)))
|
||||||
|
const restored = new PackageStore(f.root, f.dependencies)
|
||||||
|
await restored.initialize()
|
||||||
|
assert.deepEqual(restored.list(), [ready])
|
||||||
|
assert.deepEqual(await readdir(join(f.root, f.key)), ['current.json', `v-${ready.version}`])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('симлинки и произвольные пути не позволяют читать или удалять чужие файлы', async (t) => {
|
||||||
|
const f = await fixture(t)
|
||||||
|
await writeFile(join(f.dir, 'outside.wav'), body)
|
||||||
|
const ready = await f.store.download(f.pkg)
|
||||||
|
const path = (await f.store.localFile(f.key, ready.files[0]!.id)).path
|
||||||
|
await rm(path)
|
||||||
|
await symlink(join(f.dir, 'outside.wav'), path)
|
||||||
|
await assert.rejects(f.store.verify(f.key), /Симлинк/)
|
||||||
|
await f.store.remove(f.key)
|
||||||
|
assert.deepEqual(await readFile(join(f.dir, 'outside.wav')), body)
|
||||||
|
await symlink(f.dir, join(f.root, f.key), 'dir')
|
||||||
|
await assert.rejects(f.store.remove(f.key), /Симлинк/)
|
||||||
|
await assert.rejects(f.store.remove('../'), /идентификатор/)
|
||||||
|
assert.deepEqual(await readFile(join(f.dir, 'outside.wav')), body)
|
||||||
|
assert.equal(
|
||||||
|
(await packageMediaResponse(f.store, new Request(`screen-app://app/packages/${f.key}/bad`)))
|
||||||
|
.status,
|
||||||
|
404,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('редиректы не отправляют JWT; опасные адреса и ошибочные ответы отклоняются', async (t) => {
|
||||||
|
const f = await fixture(t)
|
||||||
|
f.state.handler = async (url) =>
|
||||||
|
url.startsWith('https://cdn.test')
|
||||||
|
? mediaResponse()
|
||||||
|
: new Response(null, { status: 302, headers: { Location: 'https://cdn.test/video.wav' } })
|
||||||
|
await f.store.download(f.pkg)
|
||||||
|
assert.ok(f.calls.some((url) => url.startsWith('https://cdn.test')))
|
||||||
|
for (const location of [
|
||||||
|
'http://cdn.test/file.wav',
|
||||||
|
'file:///etc/passwd',
|
||||||
|
'https://user:password@cdn.test/file.wav',
|
||||||
|
'https://cdn.test/file.wav?token=SECRET',
|
||||||
|
]) {
|
||||||
|
f.state.handler = async () =>
|
||||||
|
new Response(null, { status: 302, headers: { Location: location } })
|
||||||
|
await assert.rejects(f.store.download(f.pkg), /редирект|адрес/)
|
||||||
|
}
|
||||||
|
for (const status of [401, 403, 404, 206]) {
|
||||||
|
f.state.handler = async () => new Response(null, { status })
|
||||||
|
const before = f.calls.length
|
||||||
|
await assert.rejects(f.store.download(f.pkg), /HTTP/)
|
||||||
|
assert.equal(f.calls.length - before, 1)
|
||||||
|
}
|
||||||
|
f.state.handler = async () => new Response('HTML', { headers: { 'Content-Type': 'text/html' } })
|
||||||
|
await assert.rejects(f.store.download(f.pkg), /тип медиа/)
|
||||||
|
f.state.handler = async () =>
|
||||||
|
new Response('short', { headers: { 'Content-Type': 'audio/wav', 'Content-Length': '100' } })
|
||||||
|
await assert.rejects(f.store.download(f.pkg), /не полностью/)
|
||||||
|
f.state.handler = async () => new Response(null, { status: 302, headers: { Location: '/loop' } })
|
||||||
|
await assert.rejects(f.store.download(f.pkg), /редирект/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('изменения хранилища сериализованы, отмена доступна во время загрузки', async (t) => {
|
||||||
|
const f = await fixture(t)
|
||||||
|
let started!: () => void
|
||||||
|
const loading = new Promise<void>((resolve) => {
|
||||||
|
started = resolve
|
||||||
|
})
|
||||||
|
f.state.handler = async (_url, init) => {
|
||||||
|
started()
|
||||||
|
return new Promise((_resolve, reject) => {
|
||||||
|
init?.signal?.addEventListener('abort', () => reject(new Error('aborted')), { once: true })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const job = f.store.download(f.pkg)
|
||||||
|
await loading
|
||||||
|
await assert.rejects(f.store.remove(f.key), /занято/)
|
||||||
|
await assert.rejects(f.store.download(f.pkg), /занято/)
|
||||||
|
f.store.cancel()
|
||||||
|
await assert.rejects(job, /отменена/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('реальный HTTP: отключение сервера не мешает восстановить и читать пакет', async (t) => {
|
||||||
|
const f = await fixture(t)
|
||||||
|
const server = createServer((_req, response) => {
|
||||||
|
response.writeHead(200, { 'Content-Type': 'audio/wav', 'Content-Length': body.length })
|
||||||
|
response.end(body)
|
||||||
|
})
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server.once('error', reject)
|
||||||
|
server.listen(0, '127.0.0.1', resolve)
|
||||||
|
})
|
||||||
|
t.after(() => server.close())
|
||||||
|
const address = server.address()
|
||||||
|
assert.ok(address && typeof address !== 'string')
|
||||||
|
const pkg = description(`http://127.0.0.1:${address.port}`)
|
||||||
|
const store = new PackageStore(f.root)
|
||||||
|
await store.initialize()
|
||||||
|
const downloaded = await store.download(pkg)
|
||||||
|
await new Promise<void>((resolve, reject) =>
|
||||||
|
server.close((error) => (error ? reject(error) : resolve())),
|
||||||
|
)
|
||||||
|
const restored = new PackageStore(f.root)
|
||||||
|
await restored.initialize()
|
||||||
|
for (const file of downloaded.files) {
|
||||||
|
const response = await packageMediaResponse(
|
||||||
|
restored,
|
||||||
|
new Request(`screen-app://app/packages/${downloaded.key}/${file.id}`),
|
||||||
|
)
|
||||||
|
assert.equal(response.status, 200)
|
||||||
|
assert.deepEqual(Buffer.from(await response.arrayBuffer()), body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('принимает MIME WAV/OGG существующего бэкенда и неизвестный общий размер', async (t) => {
|
||||||
|
const f = await fixture(t)
|
||||||
|
for (const mime of ['audio/wave', 'audio/x-wav', 'application/ogg']) {
|
||||||
|
f.state.handler = async () => new Response(body, { headers: { 'Content-Type': mime } })
|
||||||
|
const pkg = await f.store.download(f.pkg)
|
||||||
|
assert.ok(
|
||||||
|
pkg.files.every(
|
||||||
|
(file) => file.mime === (mime === 'application/ogg' ? 'audio/ogg' : 'audio/wav'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('повторная загрузка восстанавливает повреждённую копию той же версии', async (t) => {
|
||||||
|
const f = await fixture(t)
|
||||||
|
const original = await f.store.download(f.pkg)
|
||||||
|
await writeFile(
|
||||||
|
(await f.store.localFile(f.key, original.files[0]!.id)).path,
|
||||||
|
Buffer.alloc(body.length),
|
||||||
|
)
|
||||||
|
const repaired = await f.store.download(f.pkg)
|
||||||
|
assert.equal(repaired.version, original.version)
|
||||||
|
assert.deepEqual(await f.store.verify(f.key), original)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('превышение времени бездействия повторяет запрос трижды и оставляет пакет неготовым', async (t) => {
|
||||||
|
const f = await fixture(t)
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout'] })
|
||||||
|
let started!: () => void
|
||||||
|
let start = new Promise<void>((resolve) => {
|
||||||
|
started = resolve
|
||||||
|
})
|
||||||
|
f.state.handler = async (_url, init) => {
|
||||||
|
started()
|
||||||
|
return new Promise((_resolve, reject) => {
|
||||||
|
init?.signal?.addEventListener('abort', () => reject(new Error('timeout')), { once: true })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const job = f.store.download(f.pkg)
|
||||||
|
const rejected = assert.rejects(job, /таймаут/)
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await start
|
||||||
|
start = new Promise<void>((resolve) => {
|
||||||
|
started = resolve
|
||||||
|
})
|
||||||
|
t.mock.timers.tick(30_001)
|
||||||
|
}
|
||||||
|
await rejected
|
||||||
|
assert.equal(f.calls.length, 3)
|
||||||
|
assert.deepEqual(f.store.list(), [])
|
||||||
|
})
|
||||||
@@ -0,0 +1,513 @@
|
|||||||
|
import { createHash, randomUUID } from 'node:crypto'
|
||||||
|
import { createReadStream } from 'node:fs'
|
||||||
|
import { lstat, mkdir, open, readFile, readdir, rename, rm, statfs } from 'node:fs/promises'
|
||||||
|
import { extname, join, resolve } from 'node:path'
|
||||||
|
import { setTimeout as wait } from 'node:timers/promises'
|
||||||
|
import { parseScreenPackage, type ScreenPackage } from './screen-package.js'
|
||||||
|
|
||||||
|
const digest = (value: string) => createHash('sha256').update(value).digest('hex')
|
||||||
|
const hex = /^[a-f0-9]{64}$/
|
||||||
|
const reserve = 64n * 1024n * 1024n
|
||||||
|
const mimeAliases: Record<string, string> = {
|
||||||
|
'audio/wave': 'audio/wav',
|
||||||
|
'audio/x-wav': 'audio/wav',
|
||||||
|
'application/ogg': 'audio/ogg',
|
||||||
|
'audio/mp3': 'audio/mpeg',
|
||||||
|
'image/jpg': 'image/jpeg',
|
||||||
|
}
|
||||||
|
const mimeTypes: Record<string, string> = {
|
||||||
|
'.mp3': 'audio/mpeg',
|
||||||
|
'.wav': 'audio/wav',
|
||||||
|
'.ogg': 'audio/ogg',
|
||||||
|
'.mp4': 'video/mp4',
|
||||||
|
'.webm': 'video/webm',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
}
|
||||||
|
export interface StoredFile {
|
||||||
|
id: string
|
||||||
|
size: number
|
||||||
|
sha256: string
|
||||||
|
mime: string
|
||||||
|
}
|
||||||
|
export interface LocalPackage {
|
||||||
|
formatVersion: 1
|
||||||
|
key: string
|
||||||
|
version: string
|
||||||
|
package: ScreenPackage
|
||||||
|
files: StoredFile[]
|
||||||
|
}
|
||||||
|
export interface DownloadProgress {
|
||||||
|
stage: 'downloading' | 'verifying' | 'activating' | 'ready'
|
||||||
|
assetId: string | null
|
||||||
|
downloadedBytes: number
|
||||||
|
completedFiles: number
|
||||||
|
totalFiles: number
|
||||||
|
totalBytes: number | null
|
||||||
|
}
|
||||||
|
interface Dependencies {
|
||||||
|
fetch?: typeof fetch
|
||||||
|
freeBytes?: () => Promise<bigint>
|
||||||
|
wait?: (milliseconds: number, signal: AbortSignal) => Promise<void>
|
||||||
|
}
|
||||||
|
class RetryableError extends Error {}
|
||||||
|
export function packageKey(server: string, gameId: number): string {
|
||||||
|
return digest(JSON.stringify([server, gameId]))
|
||||||
|
}
|
||||||
|
function versionOf(pkg: ScreenPackage, files: StoredFile[]): string {
|
||||||
|
return digest(
|
||||||
|
JSON.stringify([
|
||||||
|
pkg.metadataVersion,
|
||||||
|
files.map(({ id, size, sha256, mime }) => ({ id, size, sha256, mime })),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
function checkHex(value: unknown): asserts value is string {
|
||||||
|
if (typeof value !== 'string' || !hex.test(value))
|
||||||
|
throw new Error('Некорректный идентификатор пакета или файла')
|
||||||
|
}
|
||||||
|
async function checksum(path: string) {
|
||||||
|
const hash = createHash('sha256')
|
||||||
|
let size = 0
|
||||||
|
for await (const chunk of createReadStream(path)) {
|
||||||
|
hash.update(chunk)
|
||||||
|
size += chunk.length
|
||||||
|
}
|
||||||
|
return { size, sha256: hash.digest('hex') }
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PackageStore {
|
||||||
|
private readonly root: string
|
||||||
|
private busy = false
|
||||||
|
private cancellation: AbortController | null = null
|
||||||
|
private readonly ready = new Map<string, LocalPackage>()
|
||||||
|
constructor(
|
||||||
|
root: string,
|
||||||
|
private readonly dependencies: Dependencies = {},
|
||||||
|
) {
|
||||||
|
this.root = resolve(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Все сегменты создаёт сервис; существующие симлинки никогда не обходятся.
|
||||||
|
private async path(...parts: string[]) {
|
||||||
|
let path = this.root
|
||||||
|
for (const part of ['', ...parts]) {
|
||||||
|
if (part) path = join(path, part)
|
||||||
|
try {
|
||||||
|
const info = await lstat(path)
|
||||||
|
if (info.isSymbolicLink()) throw new Error('Симлинк в хранилище пакетов')
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
private async exclusive<T>(action: () => Promise<T>): Promise<T> {
|
||||||
|
if (this.busy) throw new Error('Хранилище пакетов занято')
|
||||||
|
this.busy = true
|
||||||
|
try {
|
||||||
|
return await action()
|
||||||
|
} finally {
|
||||||
|
this.busy = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private async syncDirectory(path: string) {
|
||||||
|
// Windows не поддерживает fsync каталога; файлы синхронизируются на всех ОС.
|
||||||
|
if (process.platform === 'win32') return
|
||||||
|
const handle = await open(path, 'r')
|
||||||
|
try {
|
||||||
|
await handle.sync()
|
||||||
|
} finally {
|
||||||
|
await handle.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private async writeJSON(path: string, value: unknown) {
|
||||||
|
const handle = await open(path, 'wx', 0o600)
|
||||||
|
try {
|
||||||
|
await handle.writeFile(JSON.stringify(value))
|
||||||
|
await handle.sync()
|
||||||
|
} finally {
|
||||||
|
await handle.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private async cleanup(key: string, keep?: string) {
|
||||||
|
const dir = await this.path(key)
|
||||||
|
for (const name of await readdir(dir)) {
|
||||||
|
if (
|
||||||
|
(/^staging-[a-f0-9-]{36}$/.test(name) ||
|
||||||
|
/^v-[a-f0-9]{64}$/.test(name) ||
|
||||||
|
name === 'current.tmp') &&
|
||||||
|
name !== keep
|
||||||
|
) {
|
||||||
|
// rm удаляет сам симлинк, не его цель; содержимое по ссылке не читается.
|
||||||
|
await rm(join(dir, name), { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
await this.exclusive(async () => {
|
||||||
|
await this.path()
|
||||||
|
await mkdir(this.root, { recursive: true })
|
||||||
|
await this.path()
|
||||||
|
this.ready.clear()
|
||||||
|
for (const key of await readdir(this.root)) {
|
||||||
|
if (!hex.test(key)) continue
|
||||||
|
try {
|
||||||
|
await this.path(key)
|
||||||
|
const manifest = await this.load(key)
|
||||||
|
this.ready.set(key, manifest)
|
||||||
|
await this.cleanup(key, `v-${manifest.version}`)
|
||||||
|
} catch {
|
||||||
|
// Повреждённый пакет остаётся для явного удаления, но не становится готовым.
|
||||||
|
try {
|
||||||
|
let keep: string | undefined
|
||||||
|
try {
|
||||||
|
const pointer: unknown = JSON.parse(
|
||||||
|
await readFile(await this.path(key, 'current.json'), 'utf8'),
|
||||||
|
)
|
||||||
|
checkHex(pointer)
|
||||||
|
keep = `v-${pointer}`
|
||||||
|
} catch {
|
||||||
|
/* Без корректного указателя готовой версии нет. */
|
||||||
|
}
|
||||||
|
await this.cleanup(key, keep)
|
||||||
|
} catch {
|
||||||
|
/* Чужие пути не обходим. */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
private async load(key: string): Promise<LocalPackage> {
|
||||||
|
checkHex(key)
|
||||||
|
const pointer: unknown = JSON.parse(
|
||||||
|
await readFile(await this.path(key, 'current.json'), 'utf8'),
|
||||||
|
)
|
||||||
|
checkHex(pointer)
|
||||||
|
return this.verifyDirectory(key, pointer)
|
||||||
|
}
|
||||||
|
private async verifyDirectory(key: string, version: string): Promise<LocalPackage> {
|
||||||
|
const raw = JSON.parse(
|
||||||
|
await readFile(await this.path(key, `v-${version}`, 'manifest.json'), 'utf8'),
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
raw?.formatVersion !== 1 ||
|
||||||
|
raw.key !== key ||
|
||||||
|
raw.version !== version ||
|
||||||
|
!Array.isArray(raw.files)
|
||||||
|
)
|
||||||
|
throw new Error('Повреждён манифест пакета')
|
||||||
|
const pkg = parseScreenPackage(raw.package)
|
||||||
|
if (packageKey(pkg.server, pkg.game.id) !== key || raw.files.length !== pkg.assets.length)
|
||||||
|
throw new Error('Неполный пакет')
|
||||||
|
const files: StoredFile[] = []
|
||||||
|
for (let i = 0; i < pkg.assets.length; i++) {
|
||||||
|
const file = raw.files[i] as StoredFile
|
||||||
|
if (
|
||||||
|
!file ||
|
||||||
|
file.id !== pkg.assets[i]!.id ||
|
||||||
|
!Number.isSafeInteger(file.size) ||
|
||||||
|
file.size <= 0 ||
|
||||||
|
!hex.test(file.sha256) ||
|
||||||
|
!Object.values(mimeTypes).includes(file.mime)
|
||||||
|
)
|
||||||
|
throw new Error('Повреждён список файлов')
|
||||||
|
const path = await this.path(key, `v-${version}`, 'assets', file.id)
|
||||||
|
if (!(await lstat(path)).isFile()) throw new Error('Ресурс не является файлом')
|
||||||
|
const actual = await checksum(path)
|
||||||
|
if (actual.size !== file.size || actual.sha256 !== file.sha256)
|
||||||
|
throw new Error('Повреждён файл пакета')
|
||||||
|
files.push({ id: file.id, ...actual, mime: file.mime })
|
||||||
|
}
|
||||||
|
if (versionOf(pkg, files) !== version) throw new Error('Повреждена версия пакета')
|
||||||
|
return { formatVersion: 1, key, version, package: pkg, files }
|
||||||
|
}
|
||||||
|
list(): LocalPackage[] {
|
||||||
|
return structuredClone([...this.ready.values()])
|
||||||
|
}
|
||||||
|
cancel(): void {
|
||||||
|
this.cancellation?.abort()
|
||||||
|
}
|
||||||
|
async verify(key: string): Promise<LocalPackage> {
|
||||||
|
return this.exclusive(async () => {
|
||||||
|
try {
|
||||||
|
const pkg = await this.load(key)
|
||||||
|
this.ready.set(key, pkg)
|
||||||
|
return structuredClone(pkg)
|
||||||
|
} catch (error) {
|
||||||
|
this.ready.delete(key)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
async remove(key: string): Promise<void> {
|
||||||
|
checkHex(key)
|
||||||
|
await this.exclusive(async () => {
|
||||||
|
const dir = await this.path(key)
|
||||||
|
await rm(dir, { recursive: true, force: true })
|
||||||
|
this.ready.delete(key)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
async localFile(key: string, assetId: string) {
|
||||||
|
checkHex(key)
|
||||||
|
checkHex(assetId)
|
||||||
|
const pkg = this.ready.get(key)
|
||||||
|
const file = pkg?.files.find((file) => file.id === assetId)
|
||||||
|
if (!pkg || !file) throw new Error('Готовый ресурс не найден')
|
||||||
|
try {
|
||||||
|
const path = await this.path(key, `v-${pkg.version}`, 'assets', assetId)
|
||||||
|
const info = await lstat(path)
|
||||||
|
if (!info.isFile() || info.size !== file.size) throw new Error('Повреждён файл пакета')
|
||||||
|
return { path, size: file.size, mime: file.mime }
|
||||||
|
} catch (error) {
|
||||||
|
this.ready.delete(key)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private async space(needed = 0) {
|
||||||
|
const available = this.dependencies.freeBytes
|
||||||
|
? await this.dependencies.freeBytes()
|
||||||
|
: await statfs(this.root, { bigint: true }).then((s) => s.bavail * s.bsize)
|
||||||
|
if (available < reserve + BigInt(needed))
|
||||||
|
throw new Error('Недостаточно места для пакета (нужен резерв 64 МиБ)')
|
||||||
|
}
|
||||||
|
|
||||||
|
async download(
|
||||||
|
value: ScreenPackage,
|
||||||
|
options: { signal?: AbortSignal; onProgress?: (progress: DownloadProgress) => void } = {},
|
||||||
|
): Promise<LocalPackage> {
|
||||||
|
return this.exclusive(async () => {
|
||||||
|
const pkg = parseScreenPackage(value)
|
||||||
|
const key = packageKey(pkg.server, pkg.game.id)
|
||||||
|
this.cancellation = new AbortController()
|
||||||
|
const signal = AbortSignal.any([
|
||||||
|
this.cancellation.signal,
|
||||||
|
...(options.signal ? [options.signal] : []),
|
||||||
|
])
|
||||||
|
const stage = `staging-${randomUUID()}`
|
||||||
|
let staged = ''
|
||||||
|
let published = ''
|
||||||
|
let activated = false
|
||||||
|
const files: StoredFile[] = []
|
||||||
|
let completedBytes = 0
|
||||||
|
const report = (stage: DownloadProgress['stage'], assetId: string | null, bytes = 0) => {
|
||||||
|
try {
|
||||||
|
options.onProgress?.({
|
||||||
|
stage,
|
||||||
|
assetId,
|
||||||
|
downloadedBytes: completedBytes + bytes,
|
||||||
|
completedFiles: files.length,
|
||||||
|
totalFiles: pkg.assets.length,
|
||||||
|
totalBytes: stage === 'ready' ? completedBytes : null,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
/* Наблюдатель не должен прерывать изменение хранилища. */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
signal.throwIfAborted()
|
||||||
|
await this.path()
|
||||||
|
await mkdir(this.root, { recursive: true })
|
||||||
|
const dir = await this.path(key)
|
||||||
|
await mkdir(dir, { recursive: true })
|
||||||
|
await this.space()
|
||||||
|
staged = await this.path(key, stage)
|
||||||
|
await mkdir(join(staged, 'assets'), { recursive: true })
|
||||||
|
for (const asset of pkg.assets) {
|
||||||
|
let file: StoredFile | undefined
|
||||||
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
|
signal.throwIfAborted()
|
||||||
|
const path = join(staged, 'assets', asset.id)
|
||||||
|
try {
|
||||||
|
file = await this.fetchFile(asset, path, signal, (bytes) =>
|
||||||
|
report('downloading', asset.id, bytes),
|
||||||
|
)
|
||||||
|
break
|
||||||
|
} catch (error) {
|
||||||
|
await rm(path, { force: true })
|
||||||
|
signal.throwIfAborted()
|
||||||
|
if (!(error instanceof RetryableError) || attempt === 2) throw error
|
||||||
|
const delay = attempt === 0 ? 1000 : 3000
|
||||||
|
if (this.dependencies.wait) await this.dependencies.wait(delay, signal)
|
||||||
|
else await wait(delay, undefined, { signal })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!file) throw new Error('Не удалось скачать файл')
|
||||||
|
report('verifying', asset.id, file.size)
|
||||||
|
const actual = await checksum(join(staged, 'assets', asset.id))
|
||||||
|
signal.throwIfAborted()
|
||||||
|
if (actual.size !== file.size || actual.sha256 !== file.sha256)
|
||||||
|
throw new Error('Ошибка проверки скачанного файла')
|
||||||
|
files.push(file)
|
||||||
|
completedBytes += file.size
|
||||||
|
}
|
||||||
|
const version = versionOf(pkg, files)
|
||||||
|
const manifest: LocalPackage = { formatVersion: 1, key, version, package: pkg, files }
|
||||||
|
await this.writeJSON(join(staged, 'manifest.json'), manifest)
|
||||||
|
await this.syncDirectory(join(staged, 'assets'))
|
||||||
|
await this.syncDirectory(staged)
|
||||||
|
report('activating', null)
|
||||||
|
signal.throwIfAborted()
|
||||||
|
const target = await this.path(key, `v-${version}`)
|
||||||
|
let exists = false
|
||||||
|
try {
|
||||||
|
await lstat(target)
|
||||||
|
exists = true
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||||
|
}
|
||||||
|
if (exists) {
|
||||||
|
try {
|
||||||
|
await this.verifyDirectory(key, version)
|
||||||
|
} catch {
|
||||||
|
// Повторная загрузка восстанавливает уже повреждённую локальную копию.
|
||||||
|
if (this.ready.get(key)?.version === version) this.ready.delete(key)
|
||||||
|
await rm(target, { recursive: true, force: true })
|
||||||
|
exists = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!exists) {
|
||||||
|
await rename(staged, target)
|
||||||
|
staged = ''
|
||||||
|
published = target
|
||||||
|
}
|
||||||
|
await this.syncDirectory(dir)
|
||||||
|
const pointer = await this.path(key, 'current.tmp')
|
||||||
|
await rm(pointer, { force: true })
|
||||||
|
await this.writeJSON(pointer, version)
|
||||||
|
signal.throwIfAborted()
|
||||||
|
await rename(pointer, await this.path(key, 'current.json'))
|
||||||
|
activated = true
|
||||||
|
this.ready.set(key, manifest)
|
||||||
|
await this.syncDirectory(dir).catch(() => {})
|
||||||
|
// После переключения указателя сбой очистки не отменяет успешную активацию.
|
||||||
|
await this.cleanup(key, `v-${version}`).catch(() => {})
|
||||||
|
report('ready', null)
|
||||||
|
return structuredClone(manifest)
|
||||||
|
} catch (error) {
|
||||||
|
if (signal.aborted && !activated) throw new Error('Загрузка отменена')
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOSPC')
|
||||||
|
throw new Error('Недостаточно места для пакета')
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
this.cancellation = null
|
||||||
|
if (staged) await rm(staged, { recursive: true, force: true }).catch(() => {})
|
||||||
|
if (published && !activated)
|
||||||
|
await rm(published, { recursive: true, force: true }).catch(() => {})
|
||||||
|
if (!activated)
|
||||||
|
await rm(await this.path(key, 'current.tmp'), { force: true }).catch(() => {})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchFile(
|
||||||
|
asset: ScreenPackage['assets'][number],
|
||||||
|
path: string,
|
||||||
|
signal: AbortSignal,
|
||||||
|
progress: (bytes: number) => void,
|
||||||
|
): Promise<StoredFile> {
|
||||||
|
const idle = new AbortController()
|
||||||
|
const combined = AbortSignal.any([signal, idle.signal])
|
||||||
|
let timer: ReturnType<typeof setTimeout>
|
||||||
|
const reset = () => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
timer = setTimeout(() => idle.abort(), 30_000)
|
||||||
|
}
|
||||||
|
reset()
|
||||||
|
const network = async <T>(operation: () => Promise<T>): Promise<T> => {
|
||||||
|
try {
|
||||||
|
return await operation()
|
||||||
|
} catch {
|
||||||
|
signal.throwIfAborted()
|
||||||
|
throw new RetryableError('Обрыв соединения или таймаут загрузки')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let response: Response | undefined
|
||||||
|
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined
|
||||||
|
try {
|
||||||
|
let url = new URL(asset.url)
|
||||||
|
for (let redirects = 0; ; redirects++) {
|
||||||
|
if (
|
||||||
|
!['http:', 'https:'].includes(url.protocol) ||
|
||||||
|
url.username ||
|
||||||
|
url.password ||
|
||||||
|
url.hash ||
|
||||||
|
[...url.searchParams.keys()].some((key) =>
|
||||||
|
/token|signature|credential|password|authorization/i.test(key),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new Error('Недопустимый адрес медиа')
|
||||||
|
response = await network(() =>
|
||||||
|
(this.dependencies.fetch ?? fetch)(url.href, {
|
||||||
|
redirect: 'manual',
|
||||||
|
credentials: 'omit',
|
||||||
|
headers: { 'Accept-Encoding': 'identity' },
|
||||||
|
signal: combined,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
reset()
|
||||||
|
if (![301, 302, 303, 307, 308].includes(response.status)) break
|
||||||
|
await response.body?.cancel()
|
||||||
|
const location = response.headers.get('location')
|
||||||
|
if (!location || redirects === 5) throw new Error('Недопустимый редирект медиа')
|
||||||
|
const next = new URL(location, url)
|
||||||
|
if (url.protocol === 'https:' && next.protocol !== 'https:')
|
||||||
|
throw new Error('Небезопасный редирект медиа')
|
||||||
|
url = next
|
||||||
|
}
|
||||||
|
if ([408, 429].includes(response.status) || response.status >= 500)
|
||||||
|
throw new RetryableError(`Ошибка загрузки: HTTP ${response.status}`)
|
||||||
|
if (response.status !== 200 || !response.body)
|
||||||
|
throw new Error(`Медиа недоступно: HTTP ${response.status}`)
|
||||||
|
const encoding = response.headers.get('content-encoding')
|
||||||
|
if (encoding && encoding !== 'identity') throw new Error('Неожиданное сжатие медиа')
|
||||||
|
const header = response.headers.get('content-length')
|
||||||
|
const length = header === null ? null : Number(header)
|
||||||
|
if (length !== null && (!Number.isSafeInteger(length) || length <= 0))
|
||||||
|
throw new Error('Некорректный размер медиа')
|
||||||
|
const contentType = response.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase()
|
||||||
|
const mime =
|
||||||
|
contentType && contentType !== 'application/octet-stream'
|
||||||
|
? (mimeAliases[contentType] ?? contentType)
|
||||||
|
: mimeTypes[extname(url.pathname).toLowerCase()]
|
||||||
|
if (!mime || !Object.values(mimeTypes).includes(mime))
|
||||||
|
throw new Error('Неподдерживаемый тип медиа')
|
||||||
|
await this.space(length ?? 0)
|
||||||
|
const file = await open(path, 'wx', 0o600)
|
||||||
|
let size = 0
|
||||||
|
const hash = createHash('sha256')
|
||||||
|
try {
|
||||||
|
reader = response.body.getReader()
|
||||||
|
for (;;) {
|
||||||
|
const chunk = await network(() => reader!.read())
|
||||||
|
signal.throwIfAborted()
|
||||||
|
if (chunk.done) break
|
||||||
|
reset()
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(size + chunk.value.length) ||
|
||||||
|
(length !== null && size + chunk.value.length > length)
|
||||||
|
)
|
||||||
|
throw new RetryableError('Размер медиа не совпадает с ответом сервера')
|
||||||
|
await this.space(chunk.value.length)
|
||||||
|
await file.writeFile(chunk.value)
|
||||||
|
hash.update(chunk.value)
|
||||||
|
size += chunk.value.length
|
||||||
|
progress(size)
|
||||||
|
}
|
||||||
|
if (!size || (length !== null && size !== length))
|
||||||
|
throw new RetryableError('Файл загружен не полностью')
|
||||||
|
await file.sync()
|
||||||
|
} finally {
|
||||||
|
await file.close()
|
||||||
|
}
|
||||||
|
return { id: asset.id, size, sha256: hash.digest('hex'), mime }
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer!)
|
||||||
|
if (reader) await reader.cancel().catch(() => {})
|
||||||
|
else await response?.body?.cancel().catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { test } from 'node:test'
|
||||||
|
import { buildScreenPackage, prepareScreenPackage } from './screen-package.js'
|
||||||
|
|
||||||
|
const server = 'https://example.test'
|
||||||
|
function fixture() {
|
||||||
|
const playlist = (ID: number, filename: string) => ({
|
||||||
|
ID,
|
||||||
|
name: `Playlist ${ID}`,
|
||||||
|
tracks: [{ ID: ID * 10, name: 'Track', filename, position: 0 }],
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
game: {
|
||||||
|
game: {
|
||||||
|
ID: 7,
|
||||||
|
name: 'Игра',
|
||||||
|
teams: [{ password: 'PRIVATE' }],
|
||||||
|
scenario: {
|
||||||
|
image: 'cover.png',
|
||||||
|
music_playlist_id: 1,
|
||||||
|
story: {
|
||||||
|
background: '',
|
||||||
|
introduction: { audio: 'intro.mp3' },
|
||||||
|
conclusion: { video: 'https://media.test/final.mp4' },
|
||||||
|
places: [{ image: 'PRIVATE' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plan: {
|
||||||
|
plan: {
|
||||||
|
currentStep: 1,
|
||||||
|
steps: [
|
||||||
|
{ mode: 'SCREEN_MODE_COVER' },
|
||||||
|
{ mode: 'SCREEN_MODE_TEXT', text: 'Привет' },
|
||||||
|
{ mode: 'SCREEN_MODE_TIMER', timerLabel: 'Перерыв', timerDurationSeconds: '60' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
music: {
|
||||||
|
scenarioPlaylistId: 1,
|
||||||
|
gamePlaylistId: 2,
|
||||||
|
playlists: [
|
||||||
|
playlist(99, 'PRIVATE'),
|
||||||
|
playlist(2, 'intro.mp3'),
|
||||||
|
playlist(1, `${server}/api/files/intro.mp3`),
|
||||||
|
],
|
||||||
|
state: {
|
||||||
|
playlist: playlist(3, 'selected.mp3'),
|
||||||
|
positionMs: '1000',
|
||||||
|
playback: 'MUSIC_PLAYBACK_PLAYING',
|
||||||
|
stateVersion: '4',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const build = (data = fixture()) => buildScreenPackage(server, 7, data)
|
||||||
|
|
||||||
|
test('пакет содержит только материалы показа и связанные плейлисты', () => {
|
||||||
|
const result = build()
|
||||||
|
assert.equal(result.formatVersion, 1)
|
||||||
|
assert.equal(result.media.background, result.media.cover)
|
||||||
|
assert.deepEqual(
|
||||||
|
result.playlists.map((p) => p.id),
|
||||||
|
[1, 2, 3],
|
||||||
|
)
|
||||||
|
assert.equal(result.assets.length, 4)
|
||||||
|
assert.equal(result.playlists[0]!.tracks[0]!.assetId, result.playlists[1]!.tracks[0]!.assetId)
|
||||||
|
assert.equal(result.steps[2]!.timerDurationSeconds, 60)
|
||||||
|
assert.deepEqual(
|
||||||
|
result.steps.map((s) => s.mode),
|
||||||
|
[1, 8, 7],
|
||||||
|
)
|
||||||
|
for (const excluded of [
|
||||||
|
'PRIVATE',
|
||||||
|
'currentStep',
|
||||||
|
'positionMs',
|
||||||
|
'playback',
|
||||||
|
'stateVersion',
|
||||||
|
'token',
|
||||||
|
'password',
|
||||||
|
'teams',
|
||||||
|
])
|
||||||
|
assert.ok(!JSON.stringify(result).includes(excluded))
|
||||||
|
assert.match(result.metadataVersion, /^[a-f0-9]{64}$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('отпечаток стабилен, зависит от метаданных, исключает живые команды', () => {
|
||||||
|
const original = build().metadataVersion
|
||||||
|
const data = fixture()
|
||||||
|
data.music.playlists.reverse()
|
||||||
|
data.music.state.positionMs = '5000'
|
||||||
|
data.plan.plan.currentStep = 2
|
||||||
|
assert.equal(build(data).metadataVersion, original)
|
||||||
|
data.plan.plan.steps[1]!.text = 'Новый текст'
|
||||||
|
assert.notEqual(build(data).metadataVersion, original)
|
||||||
|
const other = fixture()
|
||||||
|
other.game.game.scenario.story.conclusion.video = 'https://media.test/new.mp4'
|
||||||
|
assert.notEqual(build(other).metadataVersion, original)
|
||||||
|
assert.equal(buildScreenPackage(`${server}/`, 7, fixture()).metadataVersion, original)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('треки упорядочены по позиции и ID, текущий плейлист не обязан входить в библиотеку', () => {
|
||||||
|
const data = fixture()
|
||||||
|
data.music.state.playlist.tracks = [
|
||||||
|
{ ID: 32, name: 'B', filename: 'b.mp3', position: 1 },
|
||||||
|
{ ID: 31, name: 'A', filename: 'a.mp3', position: 0 },
|
||||||
|
]
|
||||||
|
assert.deepEqual(
|
||||||
|
build(data).playlists[2]!.tracks.map((t) => t.id),
|
||||||
|
[31, 32],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('пустой план и необязательные материалы допустимы', () => {
|
||||||
|
const result = buildScreenPackage(server, 7, {
|
||||||
|
game: { game: { ID: 7, scenario: { story: {} } } },
|
||||||
|
plan: {},
|
||||||
|
music: {},
|
||||||
|
})
|
||||||
|
assert.deepEqual(result.assets, [])
|
||||||
|
assert.deepEqual(result.steps, [])
|
||||||
|
assert.deepEqual(result.playlists, [])
|
||||||
|
assert.equal(result.media.intro, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('неполные или некорректные ответы не становятся пакетом', () => {
|
||||||
|
for (const mutate of [
|
||||||
|
(d: ReturnType<typeof fixture>) => {
|
||||||
|
d.game.game.ID = 8
|
||||||
|
},
|
||||||
|
(d: ReturnType<typeof fixture>) => {
|
||||||
|
d.music.gamePlaylistId = 100
|
||||||
|
},
|
||||||
|
(d: ReturnType<typeof fixture>) => {
|
||||||
|
d.music.playlists[1]!.tracks[0]!.filename = ''
|
||||||
|
},
|
||||||
|
(d: ReturnType<typeof fixture>) => {
|
||||||
|
d.plan.plan.steps[2]!.timerDurationSeconds = 'bad'
|
||||||
|
},
|
||||||
|
(d: ReturnType<typeof fixture>) => {
|
||||||
|
d.plan.plan.steps[0]!.mode = 'SCREEN_MODE_UNKNOWN'
|
||||||
|
},
|
||||||
|
(d: ReturnType<typeof fixture>) => {
|
||||||
|
d.plan.plan.steps[1]!.text = ''
|
||||||
|
},
|
||||||
|
]) {
|
||||||
|
const data = fixture()
|
||||||
|
mutate(data)
|
||||||
|
assert.throws(() => build(data))
|
||||||
|
}
|
||||||
|
assert.throws(() => buildScreenPackage(server, 7, { game: {}, plan: {}, music: {} }))
|
||||||
|
assert.throws(() =>
|
||||||
|
buildScreenPackage(server, 7, { game: { game: { ID: 7, scenario: {} } }, plan: {}, music: {} }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('получает данные через три существующих API без обращения к внешним медиа', async () => {
|
||||||
|
const data = fixture()
|
||||||
|
const calls: string[] = []
|
||||||
|
const mock: typeof fetch = async (input, init) => {
|
||||||
|
const url = String(input)
|
||||||
|
calls.push(url)
|
||||||
|
assert.equal(new Headers(init?.headers).get('Authorization'), 'Bearer SECRET')
|
||||||
|
assert.equal(init?.redirect, 'error')
|
||||||
|
assert.equal(init?.credentials, 'omit')
|
||||||
|
assert.ok(init?.signal)
|
||||||
|
return Response.json(
|
||||||
|
url.endsWith('/music') ? data.music : url.endsWith('/screen-plan') ? data.plan : data.game,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const result = await prepareScreenPackage({ server, gameId: 7, token: 'SECRET' }, mock)
|
||||||
|
assert.deepEqual(calls.sort(), [
|
||||||
|
`${server}/api/games/7`,
|
||||||
|
`${server}/api/games/7/music`,
|
||||||
|
`${server}/api/games/7/screen-plan`,
|
||||||
|
])
|
||||||
|
assert.deepEqual(result, build(data))
|
||||||
|
assert.ok(!JSON.stringify(result).includes('SECRET'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('отказы API и некорректный JSON не выдают частичный пакет или ответ с секретом', async () => {
|
||||||
|
for (const status of [401, 403, 404, 500]) {
|
||||||
|
await assert.rejects(
|
||||||
|
prepareScreenPackage(
|
||||||
|
{ server, gameId: 7, token: 'SECRET' },
|
||||||
|
async () => new Response('SECRET', { status }),
|
||||||
|
),
|
||||||
|
/авторизация|доступа|HTTP/,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
await assert.rejects(
|
||||||
|
prepareScreenPackage(
|
||||||
|
{ server, gameId: 7, token: 'SECRET' },
|
||||||
|
async () => new Response('SECRET'),
|
||||||
|
),
|
||||||
|
/Некорректный JSON/,
|
||||||
|
)
|
||||||
|
await assert.rejects(
|
||||||
|
prepareScreenPackage({ server, gameId: 7, token: 'SECRET' }, async () => {
|
||||||
|
throw new Error('SECRET')
|
||||||
|
}),
|
||||||
|
/^Error: Сервер недоступен$/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('отмена и недопустимые адреса обрабатываются до выдачи пакета', async () => {
|
||||||
|
const controller = new AbortController()
|
||||||
|
controller.abort()
|
||||||
|
await assert.rejects(
|
||||||
|
prepareScreenPackage(
|
||||||
|
{ server, gameId: 7, token: 'SECRET', signal: controller.signal },
|
||||||
|
async (_input, init) => {
|
||||||
|
init?.signal?.throwIfAborted()
|
||||||
|
return Response.json({})
|
||||||
|
},
|
||||||
|
),
|
||||||
|
/отменена/,
|
||||||
|
)
|
||||||
|
for (const address of [
|
||||||
|
'file:///etc/passwd',
|
||||||
|
'https://user:password@example.test',
|
||||||
|
`${server}?token=SECRET`,
|
||||||
|
]) {
|
||||||
|
await assert.rejects(
|
||||||
|
prepareScreenPackage({ server: address, gameId: 7, token: 'SECRET' }),
|
||||||
|
/Недопустимый|секрет/,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const data = fixture()
|
||||||
|
data.game.game.scenario.story.conclusion.video = 'https://media.test/video?access_token=SECRET'
|
||||||
|
assert.throws(() => build(data), /секрет/)
|
||||||
|
await assert.rejects(
|
||||||
|
prepareScreenPackage({ server, gameId: 0, token: 'SECRET' }),
|
||||||
|
/идентификатор/,
|
||||||
|
)
|
||||||
|
await assert.rejects(prepareScreenPackage({ server, gameId: 7, token: '' }), /токен/)
|
||||||
|
})
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
|
||||||
|
export interface ScreenPackage {
|
||||||
|
formatVersion: 1
|
||||||
|
metadataVersion: string
|
||||||
|
server: string
|
||||||
|
game: { id: number; name: string }
|
||||||
|
media: {
|
||||||
|
cover: string | null
|
||||||
|
background: string | null
|
||||||
|
intro: string | null
|
||||||
|
conclusion: string | null
|
||||||
|
}
|
||||||
|
steps: { mode: number; text: string; timerLabel: string; timerDurationSeconds: number }[]
|
||||||
|
music: {
|
||||||
|
scenarioPlaylistId: number | null
|
||||||
|
gamePlaylistId: number | null
|
||||||
|
selectedPlaylistId: number | null
|
||||||
|
}
|
||||||
|
playlists: {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
tracks: { id: number; name: string; position: number; assetId: string }[]
|
||||||
|
}[]
|
||||||
|
assets: { id: string; url: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PackageRequest {
|
||||||
|
server: string
|
||||||
|
gameId: number
|
||||||
|
token: string
|
||||||
|
signal?: AbortSignal
|
||||||
|
}
|
||||||
|
|
||||||
|
function object(value: unknown): Record<string, unknown> {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
||||||
|
throw new Error('Некорректный ответ API')
|
||||||
|
return value as Record<string, unknown>
|
||||||
|
}
|
||||||
|
function optionalObject(value: unknown) {
|
||||||
|
return value == null ? {} : object(value)
|
||||||
|
}
|
||||||
|
function list(value: unknown): unknown[] {
|
||||||
|
if (value == null) return []
|
||||||
|
if (!Array.isArray(value)) throw new Error('Некорректный список в ответе API')
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
function text(value: unknown): string {
|
||||||
|
if (value == null) return ''
|
||||||
|
if (typeof value !== 'string') throw new Error('Некорректный текст в ответе API')
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
function integer(value: unknown, fallback = 0): number {
|
||||||
|
if (value == null) return fallback
|
||||||
|
if (typeof value === 'string' && /^\d+$/.test(value)) value = Number(value)
|
||||||
|
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0)
|
||||||
|
throw new Error('Некорректное число в ответе API')
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
function id(value: unknown): number {
|
||||||
|
const result = integer(value)
|
||||||
|
if (result === 0) throw new Error('Отсутствует идентификатор в ответе API')
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
function hash(value: string) {
|
||||||
|
return createHash('sha256').update(value).digest('hex')
|
||||||
|
}
|
||||||
|
function httpURL(value: string): URL {
|
||||||
|
const url = new URL(value)
|
||||||
|
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash)
|
||||||
|
throw new Error('Недопустимый адрес сервера или файла')
|
||||||
|
// Подписанные ссылки содержат секреты и не должны сохраняться в пакете.
|
||||||
|
if (
|
||||||
|
[...url.searchParams.keys()].some((key) =>
|
||||||
|
/token|signature|credential|password|authorization/i.test(key),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new Error('Ссылка на файл содержит секрет')
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
function serverURL(value: string): string {
|
||||||
|
const url = httpURL(value)
|
||||||
|
if (url.search) throw new Error('Недопустимый адрес сервера')
|
||||||
|
return url.href.replace(/\/+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
const modes = [
|
||||||
|
'UNSPECIFIED',
|
||||||
|
'COVER',
|
||||||
|
'INTRO',
|
||||||
|
'INTRO_DONE',
|
||||||
|
'CONCLUSION',
|
||||||
|
'CONCLUSION_DONE',
|
||||||
|
'RESULTS',
|
||||||
|
'TIMER',
|
||||||
|
'TEXT',
|
||||||
|
]
|
||||||
|
function mode(value: unknown): number {
|
||||||
|
const result =
|
||||||
|
typeof value === 'string' && value.startsWith('SCREEN_MODE_')
|
||||||
|
? modes.indexOf(value.slice('SCREEN_MODE_'.length))
|
||||||
|
: integer(value)
|
||||||
|
if (result < 1 || result > 8) throw new Error('Неизвестный режим показа')
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Только проекция данных показа: сырые ответы API могут содержать участников и секреты.
|
||||||
|
export function buildScreenPackage(
|
||||||
|
server: string,
|
||||||
|
gameId: number,
|
||||||
|
responses: { game: unknown; plan: unknown; music: unknown },
|
||||||
|
): ScreenPackage {
|
||||||
|
server = serverURL(server)
|
||||||
|
id(gameId)
|
||||||
|
const game = object(object(responses.game).game)
|
||||||
|
if (id(game.ID) !== gameId) throw new Error('API вернул другую игру')
|
||||||
|
const scenario = object(game.scenario)
|
||||||
|
const story = object(scenario.story)
|
||||||
|
const intro = optionalObject(story.introduction)
|
||||||
|
const conclusion = optionalObject(story.conclusion)
|
||||||
|
const assets = new Map<string, { id: string; url: string }>()
|
||||||
|
function asset(value: unknown): string | null {
|
||||||
|
const ref = text(value).trim()
|
||||||
|
if (!ref) return null
|
||||||
|
if (ref.startsWith('//') || (/^[a-z][a-z0-9+.-]*:/i.test(ref) && !/^https?:\/\//i.test(ref)))
|
||||||
|
throw new Error('Недопустимый адрес файла')
|
||||||
|
const url = httpURL(
|
||||||
|
/^https?:\/\//i.test(ref)
|
||||||
|
? ref
|
||||||
|
: ref.startsWith('/')
|
||||||
|
? new URL(ref, server).href
|
||||||
|
: `${server}/api/files/${encodeURIComponent(ref)}`,
|
||||||
|
).href
|
||||||
|
const assetId = hash(url)
|
||||||
|
assets.set(assetId, { id: assetId, url })
|
||||||
|
return assetId
|
||||||
|
}
|
||||||
|
const cover = asset(scenario.image)
|
||||||
|
const media = {
|
||||||
|
cover,
|
||||||
|
background: asset(story.background) ?? cover,
|
||||||
|
intro: asset(intro.audio),
|
||||||
|
conclusion: asset(conclusion.video),
|
||||||
|
}
|
||||||
|
const plan = optionalObject(object(responses.plan).plan)
|
||||||
|
const steps = list(plan.steps).map((value) => {
|
||||||
|
const step = object(value)
|
||||||
|
const result = {
|
||||||
|
mode: mode(step.mode),
|
||||||
|
text: text(step.text),
|
||||||
|
timerLabel: text(step.timerLabel),
|
||||||
|
timerDurationSeconds: integer(step.timerDurationSeconds),
|
||||||
|
}
|
||||||
|
if (result.mode === 8 && !result.text.trim()) throw new Error('Пустой текст шага показа')
|
||||||
|
if (
|
||||||
|
result.mode === 7 &&
|
||||||
|
(result.timerDurationSeconds < 1 || result.timerDurationSeconds > 86400)
|
||||||
|
)
|
||||||
|
throw new Error('Некорректная длительность таймера')
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
const musicResponse = object(responses.music)
|
||||||
|
const state = optionalObject(musicResponse.state)
|
||||||
|
const selected = state.playlist == null ? null : object(state.playlist)
|
||||||
|
const music = {
|
||||||
|
scenarioPlaylistId:
|
||||||
|
integer(musicResponse.scenarioPlaylistId ?? scenario.music_playlist_id) || null,
|
||||||
|
gamePlaylistId: integer(musicResponse.gamePlaylistId) || null,
|
||||||
|
selectedPlaylistId: selected ? id(selected.ID) : null,
|
||||||
|
}
|
||||||
|
const available = new Map<number, Record<string, unknown>>()
|
||||||
|
for (const value of list(musicResponse.playlists)) {
|
||||||
|
const playlist = object(value)
|
||||||
|
available.set(id(playlist.ID), playlist)
|
||||||
|
}
|
||||||
|
// Текущий плейлист берём из того же ответа, даже если его нет в библиотеке.
|
||||||
|
if (selected) available.set(id(selected.ID), selected)
|
||||||
|
const selectedIds = [
|
||||||
|
...new Set(Object.values(music).filter((value): value is number => value !== null)),
|
||||||
|
].sort((a, b) => a - b)
|
||||||
|
const playlists = selectedIds.map((playlistId) => {
|
||||||
|
const playlist = available.get(playlistId)
|
||||||
|
if (!playlist) throw new Error('Не найден связанный плейлист')
|
||||||
|
const tracks = list(playlist.tracks)
|
||||||
|
.map((value) => {
|
||||||
|
const track = object(value)
|
||||||
|
const assetId = asset(track.filename)
|
||||||
|
if (!assetId) throw new Error('Не указан файл трека')
|
||||||
|
return {
|
||||||
|
id: id(track.ID),
|
||||||
|
name: text(track.name),
|
||||||
|
position: integer(track.position),
|
||||||
|
assetId,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.position - b.position || a.id - b.id)
|
||||||
|
return { id: playlistId, name: text(playlist.name), tracks }
|
||||||
|
})
|
||||||
|
const data = {
|
||||||
|
formatVersion: 1 as const,
|
||||||
|
server,
|
||||||
|
game: { id: gameId, name: text(game.name) },
|
||||||
|
media,
|
||||||
|
steps,
|
||||||
|
music,
|
||||||
|
playlists,
|
||||||
|
assets: [...assets.values()].sort((a, b) => a.id.localeCompare(b.id)),
|
||||||
|
}
|
||||||
|
// Это отпечаток метаданных, а не подтверждение целостности ещё не скачанных файлов.
|
||||||
|
return { ...data, metadataVersion: hash(JSON.stringify(data)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вызывается основным процессом; токен не передаётся renderer и не входит в результат.
|
||||||
|
export async function prepareScreenPackage(
|
||||||
|
request: PackageRequest,
|
||||||
|
fetchAPI: typeof fetch = fetch,
|
||||||
|
): Promise<ScreenPackage> {
|
||||||
|
const server = serverURL(request.server)
|
||||||
|
id(request.gameId)
|
||||||
|
if (!request.token.trim() || /[\r\n]/.test(request.token))
|
||||||
|
throw new Error('Требуется действующий токен')
|
||||||
|
const signal = AbortSignal.any([
|
||||||
|
AbortSignal.timeout(30_000),
|
||||||
|
...(request.signal ? [request.signal] : []),
|
||||||
|
])
|
||||||
|
async function get(suffix: string): Promise<unknown> {
|
||||||
|
let response: Response
|
||||||
|
try {
|
||||||
|
response = await fetchAPI(`${server}/api/games/${request.gameId}${suffix}`, {
|
||||||
|
headers: { Authorization: `Bearer ${request.token}`, Accept: 'application/json' },
|
||||||
|
credentials: 'omit',
|
||||||
|
redirect: 'error',
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
throw new Error(
|
||||||
|
signal.aborted ? 'Подготовка отменена или превышено время ожидания' : 'Сервер недоступен',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error(
|
||||||
|
response.status === 401
|
||||||
|
? 'Требуется авторизация'
|
||||||
|
: response.status === 403
|
||||||
|
? 'Нет доступа к игре'
|
||||||
|
: `Ошибка API: HTTP ${response.status}`,
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
return await response.json()
|
||||||
|
} catch {
|
||||||
|
throw new Error('Некорректный JSON в ответе API')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const [game, plan, music] = await Promise.all([get(''), get('/screen-plan'), get('/music')])
|
||||||
|
return buildScreenPackage(server, request.gameId, { game, plan, music })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Восстанавливаем только разрешённые поля и проверяем отпечаток перед работой с диском.
|
||||||
|
export function parseScreenPackage(value: unknown): ScreenPackage {
|
||||||
|
const data = object(value)
|
||||||
|
if (data.formatVersion !== 1) throw new Error('Неизвестный формат пакета')
|
||||||
|
const game = object(data.game)
|
||||||
|
const media = object(data.media)
|
||||||
|
const music = object(data.music)
|
||||||
|
const assets = new Map<string, string>()
|
||||||
|
for (const value of list(data.assets)) {
|
||||||
|
const entry = object(value)
|
||||||
|
const key = text(entry.id)
|
||||||
|
const url = httpURL(text(entry.url)).href
|
||||||
|
if (key !== hash(url) || assets.has(key)) throw new Error('Некорректный ресурс пакета')
|
||||||
|
assets.set(key, url)
|
||||||
|
}
|
||||||
|
function ref(value: unknown): string {
|
||||||
|
if (value === null) return ''
|
||||||
|
const url = assets.get(text(value))
|
||||||
|
if (!url) throw new Error('Отсутствует ресурс пакета')
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
const playlists = list(data.playlists).map((value) => {
|
||||||
|
const playlist = object(value)
|
||||||
|
return {
|
||||||
|
ID: id(playlist.id),
|
||||||
|
name: text(playlist.name),
|
||||||
|
tracks: list(playlist.tracks).map((value) => {
|
||||||
|
const track = object(value)
|
||||||
|
return {
|
||||||
|
ID: id(track.id),
|
||||||
|
name: text(track.name),
|
||||||
|
position: integer(track.position),
|
||||||
|
filename: ref(track.assetId),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const selectedId = integer(music.selectedPlaylistId)
|
||||||
|
const selected = selectedId ? playlists.find((playlist) => playlist.ID === selectedId) : undefined
|
||||||
|
if (selectedId && !selected) throw new Error('Отсутствует выбранный плейлист')
|
||||||
|
const result = buildScreenPackage(text(data.server), id(game.id), {
|
||||||
|
game: {
|
||||||
|
game: {
|
||||||
|
ID: game.id,
|
||||||
|
name: text(game.name),
|
||||||
|
scenario: {
|
||||||
|
image: ref(media.cover),
|
||||||
|
story: {
|
||||||
|
background: ref(media.background),
|
||||||
|
introduction: { audio: ref(media.intro) },
|
||||||
|
conclusion: { video: ref(media.conclusion) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plan: {
|
||||||
|
plan: {
|
||||||
|
steps: list(data.steps).map((value) => {
|
||||||
|
const step = object(value)
|
||||||
|
return {
|
||||||
|
mode: step.mode,
|
||||||
|
text: step.text,
|
||||||
|
timerLabel: step.timerLabel,
|
||||||
|
timerDurationSeconds: step.timerDurationSeconds,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
music: {
|
||||||
|
scenarioPlaylistId: integer(music.scenarioPlaylistId),
|
||||||
|
gamePlaylistId: integer(music.gamePlaylistId),
|
||||||
|
state: { playlist: selected },
|
||||||
|
playlists,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (result.metadataVersion !== data.metadataVersion || result.assets.length !== assets.size)
|
||||||
|
throw new Error('Повреждены метаданные пакета')
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -43,7 +43,40 @@ app.whenReady().then(() => {
|
|||||||
screen.getAllDisplays = () => [...original(), targetDisplay]
|
screen.getAllDisplays = () => [...original(), targetDisplay]
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
let offlinePackage
|
||||||
|
const seeded = (async () => {
|
||||||
|
const { PackageStore } = require(root + '/dist-electron/electron/package-store.js')
|
||||||
|
const { buildScreenPackage } = require(root + '/dist-electron/electron/screen-package.js')
|
||||||
|
const bytes = require('node:fs').readFileSync(root + '/fixtures/audio.wav')
|
||||||
|
const pkg = buildScreenPackage('https://offline.invalid', 7, {
|
||||||
|
game: {
|
||||||
|
game: {
|
||||||
|
ID: 7,
|
||||||
|
name: 'Offline smoke',
|
||||||
|
scenario: { story: { introduction: { audio: 'audio.wav' } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plan: {},
|
||||||
|
music: {},
|
||||||
|
})
|
||||||
|
const store = new PackageStore(require('node:path').join(app.getPath('userData'), 'packages'), {
|
||||||
|
fetch: async () =>
|
||||||
|
new Response(bytes, {
|
||||||
|
headers: { 'Content-Type': 'audio/wav', 'Content-Length': String(bytes.length) },
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
await store.initialize()
|
||||||
|
offlinePackage = await store.download(pkg)
|
||||||
|
})()
|
||||||
|
seeded.catch((error) => {
|
||||||
|
console.error('PACKAGE_SEED_FAIL', error)
|
||||||
|
app.exit(1)
|
||||||
|
})
|
||||||
|
// Регистрация протокола выполняется до ready; открытие хранилища ждёт seed.
|
||||||
|
const whenReady = app.whenReady.bind(app)
|
||||||
|
app.whenReady = () => whenReady().then(() => seeded)
|
||||||
require(root + '/dist-electron/electron/main.js')
|
require(root + '/dist-electron/electron/main.js')
|
||||||
|
app.whenReady = whenReady
|
||||||
async function poll(check, label) {
|
async function poll(check, label) {
|
||||||
for (let i = 0; i < 150; i++) {
|
for (let i = 0; i < 150; i++) {
|
||||||
const result = await check()
|
const result = await check()
|
||||||
@@ -67,6 +100,24 @@ app.whenReady().then(async () => {
|
|||||||
'control UI',
|
'control UI',
|
||||||
)
|
)
|
||||||
const evaluate = (s) => control.webContents.executeJavaScript(s)
|
const evaluate = (s) => control.webContents.executeJavaScript(s)
|
||||||
|
await seeded
|
||||||
|
const asset = offlinePackage.files[0]
|
||||||
|
const localURL = `screen-app://app/packages/${offlinePackage.key}/${asset.id}`
|
||||||
|
const offline = await evaluate(`(async () => {
|
||||||
|
const response = await fetch(${JSON.stringify(localURL)}, { headers: { Range: 'bytes=0-43' } })
|
||||||
|
const size = (await response.arrayBuffer()).byteLength
|
||||||
|
const audio = new Audio(${JSON.stringify(localURL)})
|
||||||
|
await new Promise((resolve, reject) => { audio.onloadedmetadata = resolve; audio.onerror = () => reject(new Error('Offline audio failed')) })
|
||||||
|
await audio.play()
|
||||||
|
const playing = !audio.paused
|
||||||
|
const duration = audio.duration
|
||||||
|
audio.pause()
|
||||||
|
return { status: response.status, size, duration, playing }
|
||||||
|
})()`)
|
||||||
|
assert.equal(offline.status, 206)
|
||||||
|
assert.equal(offline.size, 44)
|
||||||
|
assert.ok(offline.duration > 0)
|
||||||
|
assert.equal(offline.playing, true)
|
||||||
assert.equal(await evaluate('typeof window.require'), 'undefined')
|
assert.equal(await evaluate('typeof window.require'), 'undefined')
|
||||||
await evaluate(`window.screenBridge.show(${targetDisplay.id})`)
|
await evaluate(`window.screenBridge.show(${targetDisplay.id})`)
|
||||||
const output = await poll(
|
const output = await poll(
|
||||||
@@ -173,6 +224,7 @@ app.whenReady().then(async () => {
|
|||||||
'SCREEN_SMOKE_PASS',
|
'SCREEN_SMOKE_PASS',
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
range,
|
range,
|
||||||
|
offlinePackage: offline,
|
||||||
display: {
|
display: {
|
||||||
id: targetDisplay.id,
|
id: targetDisplay.id,
|
||||||
bounds: targetDisplay.bounds,
|
bounds: targetDisplay.bounds,
|
||||||
|
|||||||
Reference in New Issue
Block a user