This commit is contained in:
2026-10-17 19:08:09 +07:00
parent 8b29b4f24e
commit dc0d82d88a
11 changed files with 1762 additions and 42 deletions
+68
View File
@@ -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 — подключить подготовленные локальные ресурсы к
проигрывателю и игровым командам без ожидания сети.
+7 -3
View File
@@ -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: собрать пульт, план показа и режимы экрана.
+85 -1
View File
@@ -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 и воспроизведение. Эти флаги используются только в тесте.
Серверных эталонных хешей нет: локальная проверка подтверждает целостность
скачанной копии, но не неизменность источников во время подготовки.
+6 -38
View File
@@ -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; плейлист, следующий трек и повтор; подготовка ближайшего
+13
View File
@@ -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'
+44
View File
@@ -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 })
}
}
+400
View File
@@ -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(), [])
})
+513
View File
@@ -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(() => {})
}
}
}
+238
View File
@@ -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: '' }), /токен/)
})
+336
View File
@@ -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
}
+52
View File
@@ -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,