From dc0d82d88a75ccdc50c29a45ee6add4f888676a2 Mon Sep 17 00:00:00 2001 From: Fedorov Vladimir Date: Sat, 17 Oct 2026 19:08:09 +0700 Subject: [PATCH] up --- COMPLETED_TASKS.md | 68 +++++ PLAN.md | 10 +- README.md | 86 +++++- TASKS.md | 44 +-- electron/main.ts | 13 + electron/package-media.ts | 44 +++ electron/package-store.test.ts | 400 +++++++++++++++++++++++++ electron/package-store.ts | 513 ++++++++++++++++++++++++++++++++ electron/screen-package.test.ts | 238 +++++++++++++++ electron/screen-package.ts | 336 +++++++++++++++++++++ scripts/smoke.cjs | 52 ++++ 11 files changed, 1762 insertions(+), 42 deletions(-) create mode 100644 COMPLETED_TASKS.md create mode 100644 electron/package-media.ts create mode 100644 electron/package-store.test.ts create mode 100644 electron/package-store.ts create mode 100644 electron/screen-package.test.ts create mode 100644 electron/screen-package.ts diff --git a/COMPLETED_TASKS.md b/COMPLETED_TASKS.md new file mode 100644 index 0000000..1d4e553 --- /dev/null +++ b/COMPLETED_TASKS.md @@ -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 — подключить подготовленные локальные ресурсы к + проигрывателю и игровым командам без ожидания сети. diff --git a/PLAN.md b/PLAN.md index 3a68c4b..d35c1c9 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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: собрать пульт, план показа и режимы экрана. diff --git a/README.md b/README.md index 4628fb8..653703a 100644 --- a/README.md +++ b/README.md @@ -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-`. Указатель +`current.json` заменяется через временный файл и rename только после проверки. +Отмена и ошибки до переключения не затрагивают прежний готовый пакет. После +успеха прежняя версия удаляется; заблокированные ОС остатки повторно очищаются +при следующем запуске. Сбой после переключения не отменяет новую готовую версию. +Повреждённую текущую копию можно восстановить повторной загрузкой или удалить. +Симлинки при чтении не обходятся; удаление ограничено каталогом выбранной игры. + +`screen-app://app/packages//` выдаёт только ресурсы готового пакета, +с правильным MIME, запретом кеширования и поддержкой Range. Произвольные пути +и сетевые URL в протокол не передаются. Воспроизведение игровых пакетов подключается +в SCREEN-004; синтетический стенд пока использует прежние fixture-файлы. + +`npm test` включает настоящий временный HTTP-сервер: после скачивания сервер +отключается, новое хранилище проверяет пакет, локальная выдача читает все ресурсы. +`npm run smoke -- --no-sandbox --mute-audio --disable-gpu` дополнительно готовит +WAV-пакет в отдельном временном userData, открывает его повторно через основной +процесс и проверяет Range и воспроизведение. Эти флаги используются только в тесте. +Серверных эталонных хешей нет: локальная проверка подтверждает целостность +скачанной копии, но не неизменность источников во время подготовки. diff --git a/TASKS.md b/TASKS.md index 835f77f..1895e98 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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//. - Цель: применять команды пульта независимо от сервера. - Объём: локальное состояние в основном процессе; play/pause/resume/stop/replay, seek, громкость и mute; плейлист, следующий трек и повтор; подготовка ближайшего diff --git a/electron/main.ts b/electron/main.ts index 86e66bf..19b465d 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -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' diff --git a/electron/package-media.ts b/electron/package-media.ts new file mode 100644 index 0000000..010e414 --- /dev/null +++ b/electron/package-media.ts @@ -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 { + 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 = { + '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, { + status: rangeHeader ? 206 : 200, + headers, + }) + } catch { + return new Response(null, { status: 404 }) + } +} diff --git a/electron/package-store.test.ts b/electron/package-store.test.ts new file mode 100644 index 0000000..bd30049 --- /dev/null +++ b/electron/package-store.test.ts @@ -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 + } = { 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((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((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((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((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((resolve) => { + started = resolve + }) + t.mock.timers.tick(30_001) + } + await rejected + assert.equal(f.calls.length, 3) + assert.deepEqual(f.store.list(), []) +}) diff --git a/electron/package-store.ts b/electron/package-store.ts new file mode 100644 index 0000000..ce8a565 --- /dev/null +++ b/electron/package-store.ts @@ -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 = { + 'audio/wave': 'audio/wav', + 'audio/x-wav': 'audio/wav', + 'application/ogg': 'audio/ogg', + 'audio/mp3': 'audio/mpeg', + 'image/jpg': 'image/jpeg', +} +const mimeTypes: Record = { + '.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 + wait?: (milliseconds: number, signal: AbortSignal) => Promise +} +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() + 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(action: () => Promise): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const idle = new AbortController() + const combined = AbortSignal.any([signal, idle.signal]) + let timer: ReturnType + const reset = () => { + clearTimeout(timer) + timer = setTimeout(() => idle.abort(), 30_000) + } + reset() + const network = async (operation: () => Promise): Promise => { + try { + return await operation() + } catch { + signal.throwIfAborted() + throw new RetryableError('Обрыв соединения или таймаут загрузки') + } + } + let response: Response | undefined + let reader: ReadableStreamDefaultReader | 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(() => {}) + } + } +} diff --git a/electron/screen-package.test.ts b/electron/screen-package.test.ts new file mode 100644 index 0000000..c4d83f1 --- /dev/null +++ b/electron/screen-package.test.ts @@ -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) => { + d.game.game.ID = 8 + }, + (d: ReturnType) => { + d.music.gamePlaylistId = 100 + }, + (d: ReturnType) => { + d.music.playlists[1]!.tracks[0]!.filename = '' + }, + (d: ReturnType) => { + d.plan.plan.steps[2]!.timerDurationSeconds = 'bad' + }, + (d: ReturnType) => { + d.plan.plan.steps[0]!.mode = 'SCREEN_MODE_UNKNOWN' + }, + (d: ReturnType) => { + 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: '' }), /токен/) +}) diff --git a/electron/screen-package.ts b/electron/screen-package.ts new file mode 100644 index 0000000..427d335 --- /dev/null +++ b/electron/screen-package.ts @@ -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 { + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw new Error('Некорректный ответ API') + return value as Record +} +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() + 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>() + 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 { + 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 { + 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() + 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 +} diff --git a/scripts/smoke.cjs b/scripts/smoke.cjs index 717c374..36a2710 100644 --- a/scripts/smoke.cjs +++ b/scripts/smoke.cjs @@ -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,