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. Загрузка во временные файлы, проверка, затем атомарная
|
||||
активация полного пакета. Старый готовый пакет сохраняется при ошибке обновления.
|
||||
Данные хранятся в каталоге приложения, не в Git и не рядом с исполняемым файлом.
|
||||
5. Сервер проверяет право организатора на скачивание материалов. Существующий
|
||||
API используется где достаточно; манифест добавляется минимальным расширением.
|
||||
5. Данные игры получаются с существующими правами организатора/автора; выдача
|
||||
файлов сохраняет текущие правила сервера. API используется без изменений
|
||||
бэкенда и protobuf. SCREEN-002 собирает локальное
|
||||
описание и отпечаток метаданных; SCREEN-003 вычисляет размеры и SHA-256
|
||||
скачанных файлов, итоговую версию и подтверждает готовность пакета.
|
||||
Токены не записываются в пакет игры и логи.
|
||||
6. После подготовки основной процесс применяет локальные команды пульта компьютера
|
||||
через IPC без ожидания сервера. Материалы воспроизводятся с диска.
|
||||
@@ -112,7 +115,8 @@ Linux проверяется на обоих указанных дистрибу
|
||||
|
||||
1. SCREEN-001: проверить Electron на всех шести вариантах, два дисплея, локальное
|
||||
медиа и задержки; порог задержки — на M1.
|
||||
2. SCREEN-002: получить полный и защищённый пакет материалов с сервера.
|
||||
2. SCREEN-002: собрать описание материалов показа через существующий API;
|
||||
вычисление хешей файлов и подтверждение готовности — SCREEN-003.
|
||||
3. SCREEN-003: реализовать надёжную загрузку и локальное хранение пакетов.
|
||||
4. SCREEN-004: реализовать локальный проигрыватель и команды, подготовку медиа.
|
||||
5. SCREEN-005: собрать пульт, план показа и режимы экрана.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Вечерний детектив — приложение площадки
|
||||
|
||||
Статус: SCREEN-001 в работе; технический стенд реализован, аппаратная приёмка открыта.
|
||||
SCREEN-002 и SCREEN-003 завершены: описание пакета через существующий API,
|
||||
загрузка, проверка, восстановление и локальная выдача файлов.
|
||||
Управление с телефона идёт через существующий сервер сайта; desktop получает
|
||||
обновления с сервера. Прямое LAN-подключение не используется.
|
||||
|
||||
@@ -41,7 +43,7 @@ npm run dev
|
||||
его и откройте показ. Аудио и видео — синтетические локальные файлы длительностью
|
||||
60 секунд; бинарники находятся в игнорируемом fixtures/ и не входят в Git.
|
||||
Стенд поддерживает пуск с начала, паузу, остановку и перемотку. Полные игровые
|
||||
режимы, загрузка игр и авторизация пока не реализованы.
|
||||
режимы, интерфейс загрузки игр и авторизация пока не реализованы.
|
||||
|
||||
Управление телефоном через сервер, подключение игры и авторизация относятся к
|
||||
SCREEN-006 и в техническом стенде ещё не реализованы. Интернет нужен для получения
|
||||
@@ -49,3 +51,85 @@ SCREEN-006 и в техническом стенде ещё не реализо
|
||||
|
||||
Проверки: npm run type-check, npm run lint, npm run lint:css, npm test, npm run build.
|
||||
[Сборки и аппаратная приёмка](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-006. План и общие ограничения:
|
||||
[PLAN.md](PLAN.md). Идентификаторы независимы от ED-* в корневом списке.
|
||||
@@ -62,47 +63,14 @@ SCREEN-009 (LAN-пульт) отменена владельцем; ID не пе
|
||||
дистрибутивах), включая проектор,
|
||||
физический звук/кадр и 30 запусков для каждого формата/аудиовыхода. macOS-сборки
|
||||
подготовить на Mac или ручным workflow builds.yml. До результатов всей матрицы
|
||||
задача остаётся 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/размер, повтор после обрыва, атомарная активация, явное удаление пакета.
|
||||
Незавершённые файлы никогда не выдавать как готовые.
|
||||
- Готовность: все материалы читаются без сети; повреждённый или неполный пакет
|
||||
не получает статус готовности; неудачное обновление сохраняет предыдущую версию;
|
||||
перезапуск сохраняет готовность; удаление не затрагивает файлы вне хранилища.
|
||||
- Проверка: тесты повреждения, нехватки места, обрыва/повтора, путей и обновления;
|
||||
штатные проверки приложения; ручной запуск скачанного пакета без сети.
|
||||
- Не входит: фоновое автообновление активного пакета и неограниченный кэш.
|
||||
- Следующий шаг: определить формат каталога и манифеста, реализовать одну полную
|
||||
загрузку с проверкой и затем обработать сбои.
|
||||
задача остаётся IN_PROGRESS. Владелец разрешил начать SCREEN-002 до завершения
|
||||
аппаратной приёмки; её результаты остаются открытыми.
|
||||
|
||||
## SCREEN-004 — Локальное воспроизведение и быстрые команды
|
||||
|
||||
- Статус: `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,
|
||||
seek, громкость и mute; плейлист, следующий трек и повтор; подготовка ближайшего
|
||||
|
||||
@@ -5,6 +5,8 @@ import { join } from 'node:path'
|
||||
import { Readable } from 'node:stream'
|
||||
import { Controller } from './controller.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'
|
||||
|
||||
// Native Wayland запрещает размещение окна на выбранном дисплее по координатам.
|
||||
@@ -87,9 +89,19 @@ function show(displayId: number) {
|
||||
})
|
||||
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
|
||||
.whenReady()
|
||||
.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) =>
|
||||
callback(false),
|
||||
)
|
||||
@@ -102,6 +114,7 @@ app
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
}
|
||||
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/')) {
|
||||
const media =
|
||||
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]
|
||||
}
|
||||
})
|
||||
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')
|
||||
app.whenReady = whenReady
|
||||
async function poll(check, label) {
|
||||
for (let i = 0; i < 150; i++) {
|
||||
const result = await check()
|
||||
@@ -67,6 +100,24 @@ app.whenReady().then(async () => {
|
||||
'control UI',
|
||||
)
|
||||
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')
|
||||
await evaluate(`window.screenBridge.show(${targetDisplay.id})`)
|
||||
const output = await poll(
|
||||
@@ -173,6 +224,7 @@ app.whenReady().then(async () => {
|
||||
'SCREEN_SMOKE_PASS',
|
||||
JSON.stringify({
|
||||
range,
|
||||
offlinePackage: offline,
|
||||
display: {
|
||||
id: targetDisplay.id,
|
||||
bounds: targetDisplay.bounds,
|
||||
|
||||
Reference in New Issue
Block a user