up
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
name: SCREEN-001 builds
|
name: SCREEN-008 release candidate
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
permissions:
|
permissions:
|
||||||
@@ -42,7 +42,7 @@ jobs:
|
|||||||
run: brew install ffmpeg
|
run: brew install ffmpeg
|
||||||
- name: Install ffmpeg on Linux
|
- name: Install ffmpeg on Linux
|
||||||
if: runner.os == 'Linux'
|
if: runner.os == 'Linux'
|
||||||
run: sudo apt-get update && sudo apt-get install -y ffmpeg
|
run: sudo apt-get update && sudo apt-get install -y ffmpeg xvfb
|
||||||
- name: Install ffmpeg on Windows
|
- name: Install ffmpeg on Windows
|
||||||
if: runner.os == 'Windows'
|
if: runner.os == 'Windows'
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
@@ -62,10 +62,15 @@ jobs:
|
|||||||
- name: Package macOS or Windows archive
|
- name: Package macOS or Windows archive
|
||||||
if: runner.os != 'Linux'
|
if: runner.os != 'Linux'
|
||||||
run: npm run dist -- --${{ matrix.target }} --${{ matrix.arch }} --publish never
|
run: npm run dist -- --${{ matrix.target }} --${{ matrix.arch }} --publish never
|
||||||
|
- name: Test packaged application on Linux x64
|
||||||
|
if: matrix.target == 'linux' && matrix.arch == 'x64'
|
||||||
|
run: xvfb-run -a npm run smoke:packaged
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: screen-${{ matrix.target }}-${{ matrix.arch }}
|
name: screen-${{ matrix.target }}-${{ matrix.arch }}
|
||||||
path: |
|
path: |
|
||||||
release/*.zip
|
release/*.zip
|
||||||
release/*.tar.gz
|
release/*.tar.gz
|
||||||
|
release/*.build-info.json
|
||||||
|
release/SHA256SUMS-*
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|||||||
@@ -25,6 +25,15 @@ macOS Intel/Apple Silicon, Windows x64/ARM64 и Linux x64/ARM64.
|
|||||||
предотвращение автоматического сна и восстановление пульта. SCREEN-007 вместе
|
предотвращение автоматического сна и восстановление пульта. SCREEN-007 вместе
|
||||||
с SCREEN-001/SCREEN-004 остаётся IN_PROGRESS до аппаратной приёмки.
|
с SCREEN-001/SCREEN-004 остаётся IN_PROGRESS до аппаратной приёмки.
|
||||||
[Инструкция проверки устойчивости](docs/SCREEN-007_ACCEPTANCE.md).
|
[Инструкция проверки устойчивости](docs/SCREEN-007_ACCEPTANCE.md).
|
||||||
|
SCREEN-008: подготовлен кандидат **0.2.0-rc.1**. В `release/` собраны Linux
|
||||||
|
x64/ARM64 и Windows x64/ARM64; macOS ожидает сборки на Mac. Архивы проверяются
|
||||||
|
после распаковки, рядом сохраняются build-info и контрольные суммы. Старые архивы
|
||||||
|
0.1.0 — исторический стенд. Приложение пишет ограниченный локальный журнал
|
||||||
|
`userData/logs` с фиксированными событиями без секретов.
|
||||||
|
[Установка, обновление и приёмка](docs/SCREEN-008_ACCEPTANCE.md),
|
||||||
|
[фактические результаты и открытая матрица](docs/SCREEN-008_RESULTS.md).
|
||||||
|
SCREEN-008 остаётся IN_PROGRESS до сборок macOS и аппаратной приёмки.
|
||||||
|
|
||||||
Новые задачи: `Новая задача: <описание>`.
|
Новые задачи: `Новая задача: <описание>`.
|
||||||
Commit и push выполняет только пользователь. Вложенные `.git` не изменять.
|
Commit и push выполняет только пользователь. Вложенные `.git` не изменять.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Задачи приложения площадки
|
# Задачи приложения площадки
|
||||||
|
|
||||||
SCREEN-001, SCREEN-004 и SCREEN-007 имеют статус IN_PROGRESS; SCREEN-002, SCREEN-003, SCREEN-005 и SCREEN-006
|
SCREEN-001, SCREEN-004, SCREEN-007 и SCREEN-008 имеют статус IN_PROGRESS; SCREEN-002, SCREEN-003, SCREEN-005 и SCREEN-006
|
||||||
завершены и перенесены в архив; остальные активные задачи — TODO.
|
завершены и перенесены в архив; остальные активные задачи — TODO.
|
||||||
SCREEN-009 (LAN-пульт) отменена владельцем; ID не переиспользовать.
|
SCREEN-009 (LAN-пульт) отменена владельцем; ID не переиспользовать.
|
||||||
Управление телефоном через сервер входит в SCREEN-006. План и общие ограничения:
|
Управление телефоном через сервер входит в SCREEN-006. План и общие ограничения:
|
||||||
@@ -164,7 +164,7 @@ SCREEN-009 (LAN-пульт) отменена владельцем; ID не пе
|
|||||||
|
|
||||||
## SCREEN-008 — Сборки всех платформ и приёмка
|
## SCREEN-008 — Сборки всех платформ и приёмка
|
||||||
|
|
||||||
- Статус: `TODO`
|
- Статус: `IN_PROGRESS`
|
||||||
- Зависимость: SCREEN-007.
|
- Зависимость: SCREEN-007.
|
||||||
- Цель: получить устанавливаемую первую версию и подтверждённый сценарий площадки.
|
- Цель: получить устанавливаемую первую версию и подтверждённый сценарий площадки.
|
||||||
- Объём: воспроизводимые сборки macOS x64/arm64, Windows x64/ARM64 и Linux
|
- Объём: воспроизводимые сборки macOS x64/arm64, Windows x64/ARM64 и Linux
|
||||||
@@ -181,6 +181,23 @@ SCREEN-009 (LAN-пульт) отменена владельцем; ID не пе
|
|||||||
переподключение проектора, восстановление сети и минимум час работы на каждой
|
переподключение проектора, восстановление сети и минимум час работы на каждой
|
||||||
тестовой конфигурации. Владелец выполняет аппаратную приёмку по инструкции агента.
|
тестовой конфигурации. Владелец выполняет аппаратную приёмку по инструкции агента.
|
||||||
- Не входит: публикация в магазинах приложений и автообновления.
|
- Не входит: публикация в магазинах приложений и автообновления.
|
||||||
- Следующий шаг: выбрать минимальный инструмент упаковки для всей матрицы,
|
- Программный результат (2026-10-17): кандидат 0.2.0-rc.1 на существующем
|
||||||
проверить среду сборки и наличие сертификатов, собрать артефакты и подготовить
|
electron-builder; форматы ZIP для macOS/Windows и tar.gz для Linux сохранены.
|
||||||
инструкции для аппаратной приёмки.
|
Без доверенной подписи/notarization по решению владельца; macOS использует
|
||||||
|
техническую ad-hoc подпись. appId и расположение пользовательских данных сохранены.
|
||||||
|
- Локально собраны Linux x64/ARM64 и Windows x64/ARM64. После распаковки каждого
|
||||||
|
архива проверены архитектура исполняемого файла, состав app.asar и SHA-256 медиа.
|
||||||
|
Добавлены build-info с версиями инструментов, Git revision/dirty и хешами,
|
||||||
|
sidecar-файлы и SHA256SUMS только для текущих артефактов. Шесть нативных CI jobs
|
||||||
|
подготовлены; workflow агент не запускал. Сборки macOS пока отсутствуют.
|
||||||
|
- Диагностика: локальные JSONL, максимум три файла по 1 МиБ, фиксированные события
|
||||||
|
и разрешённые поля; URL, токены, пароли, данные игр и сырые ошибки не записываются.
|
||||||
|
- Проверки: типы, ESLint, Stylelint и 73 теста — PASS. Упакованный Linux x64
|
||||||
|
прошёл обновление из 0.1.0 и два перезапуска с сохранением пакета, результатов
|
||||||
|
и настроек, без автопуска. Подробности и итоговый GUI smoke:
|
||||||
|
docs/SCREEN-008_RESULTS.md. Инструкция: docs/SCREEN-008_ACCEPTANCE.md.
|
||||||
|
- Открыто: получить macOS x64/arm64 через workflow после commit/push владельцем;
|
||||||
|
выполнить аппаратную матрицу, включая оба Linux-дистрибутива, час офлайн,
|
||||||
|
проектор/звук/телефон и физические замеры на M1. Статус IN_PROGRESS до получения
|
||||||
|
результатов всех стендов. SCREEN-001/004/007 также не закрываются.
|
||||||
|
Бэкенд, сайт, protobuf и зависимости не менялись; commit/push не выполнялись.
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
Статус: аппаратная приёмка открыта. Сборка не доказывает звук, первый кадр,
|
Статус: аппаратная приёмка открыта. Сборка не доказывает звук, первый кадр,
|
||||||
совместимость с проектором или выполнение порога задержки.
|
совместимость с проектором или выполнение порога задержки.
|
||||||
|
|
||||||
|
Историческая инструкция технического стенда 0.1.0. Текущий кандидат 0.2.0-rc.1:
|
||||||
|
[SCREEN-008_ACCEPTANCE.md](SCREEN-008_ACCEPTANCE.md),
|
||||||
|
[результаты](SCREEN-008_RESULTS.md). Аппаратные требования SCREEN-001 сохраняются.
|
||||||
|
|
||||||
## Сборки
|
## Сборки
|
||||||
|
|
||||||
На каждой целевой ОС установите Node.js 24.15+ и ffmpeg с libx264, выполните команды
|
На каждой целевой ОС установите Node.js 24.15+ и ffmpeg с libx264, выполните команды
|
||||||
@@ -37,7 +41,7 @@ Gatekeeper и SmartScreen могут предупреждать о неподп
|
|||||||
CI не заменяет проверку macOS 13, Windows 11 и обоих дистрибутивов Linux.
|
CI не заменяет проверку macOS 13, Windows 11 и обоих дистрибутивов Linux.
|
||||||
|
|
||||||
Windows-стенд собирается с signAndEditExecutable=false: без редактирования
|
Windows-стенд собирается с signAndEditExecutable=false: без редактирования
|
||||||
иконки/метаданных EXE и без подписи. Брендинг и подписание относятся к SCREEN-008.
|
иконки/метаданных EXE и без подписи. В SCREEN-008 сохранена эта настройка; доверенная подпись не используется.
|
||||||
В workflow выбран ELECTRON_BUILDER_COMPRESSION_LEVEL=1 для быстрой упаковки.
|
В workflow выбран ELECTRON_BUILDER_COMPRESSION_LEVEL=1 для быстрой упаковки.
|
||||||
Локально при необходимости задайте ту же переменную окружения перед командой
|
Локально при необходимости задайте ту же переменную окружения перед командой
|
||||||
сборки (bash: export ELECTRON_BUILDER_COMPRESSION_LEVEL=1; PowerShell:
|
сборки (bash: export ELECTRON_BUILDER_COMPRESSION_LEVEL=1; PowerShell:
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# SCREEN-008 — Кандидат 0.2.0-rc.1 и общая приёмка
|
||||||
|
|
||||||
|
Кандидат собирается без доверенной подписи Windows/macOS и без notarization.
|
||||||
|
Для macOS используется техническая ad-hoc подпись (`identity: "-"`), без
|
||||||
|
сертификата разработчика и без подтверждения доверия Apple. Это не снимает
|
||||||
|
предупреждения Gatekeeper/SmartScreen. Публикации и автообновления нет.
|
||||||
|
|
||||||
|
## Сборка и проверка архива
|
||||||
|
|
||||||
|
Используются существующие electron-builder и package-lock.json. В CI выбран
|
||||||
|
Node.js 24.15.0; локальную фактическую версию записывает build-info.json.
|
||||||
|
Зависимости устанавливать через `npm ci`, Electron — существующей командой
|
||||||
|
`npx --no-install install-electron --no`. Нужен ffmpeg с libx264; выполнить
|
||||||
|
`npm run media:generate` перед упаковкой. Все тесты и сборки запускать
|
||||||
|
последовательно: команды build/test/smoke пересобирают общий dist-electron.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run type-check
|
||||||
|
npm run lint
|
||||||
|
npm run lint:css
|
||||||
|
npm test
|
||||||
|
npm run smoke
|
||||||
|
npm run dist -- --linux --win --x64 --arm64 --publish never
|
||||||
|
```
|
||||||
|
|
||||||
|
Последняя команда — локальная кросс-сборка Linux/Windows. На соответствующей
|
||||||
|
целевой ОС можно собрать один вариант, например:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run dist -- --mac --arm64 --publish never
|
||||||
|
npm run dist -- --win --x64 --publish never
|
||||||
|
npm run dist -- --linux --arm64 --publish never
|
||||||
|
```
|
||||||
|
|
||||||
|
Матрица ручного workflow builds.yml: macos-15-intel, macos-15,
|
||||||
|
windows-2025, windows-11-arm, ubuntu-24.04, ubuntu-24.04-arm.
|
||||||
|
Commit/push и запуск workflow выполняет владелец. Workflow загружает только
|
||||||
|
артефакты выполнения, не создаёт GitHub Release. macOS здесь локально не собирается.
|
||||||
|
|
||||||
|
Архивы находятся в release/; имена содержат версию, ОС и архитектуру. После
|
||||||
|
упаковки hooks проверяют версию приложения, ELF/PE/Mach-O архитектуру, наличие
|
||||||
|
пульта/preload/синтетических медиа, отсутствие проектных тестов, секретов и
|
||||||
|
посторонних файлов приложения. app.asar включает автоматически упакованные
|
||||||
|
production-зависимости; тестовые скрипты сборки туда не входят.
|
||||||
|
|
||||||
|
В resources/build-info.json записаны Git revision, dirty, версии Node/Electron/
|
||||||
|
electron-builder/ffmpeg, хеш lockfile и SHA-256 app.asar, WAV и MP4. Хеш app.asar
|
||||||
|
покрывает весь упакованный код. После повторного чтения самого архива создаются
|
||||||
|
`<archive>.build-info.json` и `SHA256SUMS-<варианты текущего запуска>`. Старые
|
||||||
|
архивы из release/ не подмешиваются. Это сведения о происхождении и целостности,
|
||||||
|
а не цифровая подпись или гарантия побайтового совпадения разных сред сборки.
|
||||||
|
|
||||||
|
Повторная проверка явно выбранных архивов:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run verify:release -- release/evening-detective-screen-0.2.0-rc.1-linux-x64.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
В Linux проверить соответствующий SHA256SUMS через `sha256sum -c`, в macOS —
|
||||||
|
`shasum -a 256 -c`, в PowerShell — сравнить `Get-FileHash -Algorithm SHA256`
|
||||||
|
с нужной строкой manifest. Проверять суммы после передачи файла на целевой компьютер.
|
||||||
|
|
||||||
|
## Установка и обновление
|
||||||
|
|
||||||
|
| Платформа | Установка |
|
||||||
|
| --- | --- |
|
||||||
|
| macOS Intel/Apple Silicon | Распаковать соответствующий ZIP, перенести .app в Applications. |
|
||||||
|
| Windows 11 x64/ARM64 | Распаковать весь ZIP в отдельную постоянную папку и запустить EXE. |
|
||||||
|
| Ubuntu 24.04 / Debian 13 x64/ARM64 | Распаковать tar.gz с сохранением прав и запустить evening-detective-screen. Под Wayland нужен XWayland. |
|
||||||
|
|
||||||
|
Сначала закрыть старое приложение. Сохранить старый каталог приложения отдельно,
|
||||||
|
распаковать новый в другую папку и запустить его. Не распаковывать поверх работающей
|
||||||
|
копии и не переносить пользовательские файлы в resources/app.asar. Идентификатор,
|
||||||
|
название приложения и формат существующих пакетов не меняются. userData находится
|
||||||
|
отдельно от программы и сохраняется при замене папки приложения.
|
||||||
|
|
||||||
|
Ожидаемые стандартные каталоги userData: `%APPDATA%/evening-detective-screen`
|
||||||
|
в Windows, `~/Library/Application Support/evening-detective-screen` в macOS,
|
||||||
|
`$XDG_CONFIG_HOME/evening-detective-screen` либо `~/.config/evening-detective-screen`
|
||||||
|
в Linux. Путь зависит от имени приложения/профиля среды; test/dev могут использовать
|
||||||
|
другое имя. Архивы и журналы не содержат пользовательскую папку. Перед обновлением
|
||||||
|
можно сохранить её копию локально; файл connection/credentials.bin содержит секреты,
|
||||||
|
его не отправлять в отчётах и не переносить на другой компьютер как способ входа.
|
||||||
|
|
||||||
|
Если ОС предупреждает о неподписанной сборке, проверить источник и SHA-256,
|
||||||
|
использовать штатную процедуру разрешения запуска согласно политике компьютера.
|
||||||
|
Не отключать Gatekeeper/SmartScreen и защиту ОС глобально. Результат запуска
|
||||||
|
конкретного неподписанного кандидата обязательно записать в таблицу приёмки.
|
||||||
|
|
||||||
|
## Локальные журналы
|
||||||
|
|
||||||
|
`userData/logs/diagnostic.jsonl`, `diagnostic.1.jsonl`, `diagnostic.2.jsonl`:
|
||||||
|
не более трёх файлов по 1 МиБ. Формат — JSON Lines, UTC-время и фиксированные
|
||||||
|
коды событий. Записываются версия/платформа, запуск/выход, открытие/причина закрытия
|
||||||
|
показа, ошибки медиа, смена состояния связи и результат подготовки материалов.
|
||||||
|
|
||||||
|
В журнал не передаются сообщения Error.message, URL запросов, токены, пароли,
|
||||||
|
серверные ответы, тексты игры или данные участников. Повторные статусы связи и
|
||||||
|
позиции проигрывателя не пишутся каждую секунду. Если журнал недоступен, показ
|
||||||
|
продолжается; в stderr появляется только код SCREEN_LOG_UNAVAILABLE.
|
||||||
|
Для отчёта достаточно этих файлов и таблицы приёмки; весь userData не прикладывать.
|
||||||
|
|
||||||
|
## Автоматическая проверка упакованного приложения
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run smoke:packaged -- release/linux-unpacked/evening-detective-screen
|
||||||
|
```
|
||||||
|
|
||||||
|
Скрипт запускает настоящий бинарник с отдельным временным userData и тестовыми
|
||||||
|
no-sandbox/mute-audio/disable-gpu и локальным DevTools-портом. Он проверяет интерфейс,
|
||||||
|
preload, локальную выдачу Range, восстановление пакета/результатов/настроек,
|
||||||
|
два перезапуска без автопуска и нормальный выход с журналом. Вторым аргументом
|
||||||
|
можно передать предыдущий бинарник для прогона обновления на том же тестовом профиле.
|
||||||
|
Скрипт не пересобирает dist-electron. В CI этот smoke выполняется на Linux x64
|
||||||
|
под Xvfb; он не заменяет физический звук, проектор и native fullscreen.
|
||||||
|
|
||||||
|
## Общий сценарий на каждом стенде
|
||||||
|
|
||||||
|
1. Записать версию/архитектуру сборки, SHA-256, ОС, CPU/RAM, дисплей/переходник,
|
||||||
|
аудиовыход и форматы. Выполнить инструкции SCREEN-001 и SCREEN-004: по 30 запусков
|
||||||
|
каждого проверяемого формата/аудиовыхода, физические медиана/p95; порог ≤150 мс
|
||||||
|
относится только к M1 с 16 ГБ RAM.
|
||||||
|
2. Войти, скачать настоящую игру, выбрать её и открыть показ на реальном внешнем
|
||||||
|
дисплее. Проверить восемь режимов, план, текст/кириллицу, таймер, музыку и результаты
|
||||||
|
по SCREEN-005/006. Проверить телефон в Safari iPhone и Chrome Android через сайт.
|
||||||
|
3. Отключить сеть на час и пройти полный показ с музыкой, перемоткой, таймером и
|
||||||
|
сменой сцен. Готовые файлы не должны требовать сети; пульт/технические ошибки
|
||||||
|
не должны появляться на проекторе. Записать стабильность и ошибки журнала.
|
||||||
|
4. Выполнить SCREEN-007: переподключение проектора, смена параметров дисплея,
|
||||||
|
fullscreen, автоматический/принудительный сон, кабельный/USB/HDMI звук.
|
||||||
|
Восстановление требует явного открытия; медиа остановлены, таймер на паузе.
|
||||||
|
5. Вернуть сеть. Старая команда телефона не отменяет локальное действие;
|
||||||
|
новая команда применяется. Завершить игру и сохранить результаты с датой снимка.
|
||||||
|
6. Перезапустить приложение без сети: выбранная игра/снимок доступны, окно показа
|
||||||
|
закрыто, звук не начинается. Заменить папку приложения новой сборкой с тем же
|
||||||
|
пользовательским профилем и повторить проверку сохранности данных.
|
||||||
|
7. Передать заполненную таблицу результатов и безопасные журналы. Отметить каждый
|
||||||
|
непройденный пункт отдельно. До результатов всех стендов SCREEN-008 остаётся
|
||||||
|
IN_PROGRESS; открытые SCREEN-001/004/007 не объявляются завершёнными.
|
||||||
|
|
||||||
|
Шаблон: [SCREEN-008_RESULTS.md](SCREEN-008_RESULTS.md).
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# SCREEN-008 — Результаты приёмки 0.2.0-rc.1
|
||||||
|
|
||||||
|
«Собран» не означает аппаратный PASS. Отчёты владельца пока не получены.
|
||||||
|
|
||||||
|
| Стенд | Артефакт | Установка | Реальный проектор/звук | Полный час офлайн | Обновление/сеть/сон | Физическая медиана/p95 |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| macOS 13+ Intel | Ожидается сборка на Mac | Не проверено | Не проверено | Не проверено | Не проверено | Нет замеров |
|
||||||
|
| macOS 13+ Apple Silicon | Ожидается сборка на Mac | Не проверено | Не проверено | Не проверено | Не проверено | M1 16 ГБ: цель p95 ≤150 мс, нет замеров |
|
||||||
|
| Windows 11 x64 | Собран, проверен архив | Не проверено | Не проверено | Не проверено | Не проверено | Нет замеров |
|
||||||
|
| Windows 11 ARM64 | Собран, проверен архив | Не проверено | Не проверено | Не проверено | Не проверено | Нет замеров |
|
||||||
|
| Ubuntu 24.04 x64 | Собран, проверен архив | Не проверено | Не проверено | Не проверено | Не проверено | Нет замеров |
|
||||||
|
| Debian 13 x64 | Тот же Linux x64 архив | Не проверено | Не проверено | Не проверено | Не проверено | Нет замеров |
|
||||||
|
| Ubuntu 24.04 ARM64 | Собран, проверен архив | Не проверено | Не проверено | Не проверено | Не проверено | Нет замеров |
|
||||||
|
| Debian 13 ARM64 | Тот же Linux ARM64 архив | Не проверено | Не проверено | Не проверено | Не проверено | Нет замеров |
|
||||||
|
|
||||||
|
## Программная проверка 2026-10-17
|
||||||
|
|
||||||
|
Стенд: Manjaro Linux x64, один физический дисплей. Node.js 26.8.1,
|
||||||
|
Electron 42.11.15, electron-builder 26.15.3, ffmpeg n9.0.1.
|
||||||
|
Версия приложения 0.2.0-rc.1. Сборка из рабочей копии (`dirty: true`),
|
||||||
|
Git revision `1d4028b18382983196da94d7f2a5762d23d0150f`.
|
||||||
|
|
||||||
|
- `npm run type-check`, ESLint, Stylelint, 73 теста — PASS.
|
||||||
|
- `npm run dist -- --linux --win --x64 --arm64 --publish never` — PASS.
|
||||||
|
Все четыре архива распакованы и проверены: ELF/PE архитектура,
|
||||||
|
разрешённый состав app.asar, версия приложения и хеши включённых файлов.
|
||||||
|
- Артефакты: `release/evening-detective-screen-0.2.0-rc.1-{linux-x64,linux-arm64}.tar.gz`
|
||||||
|
и `release/evening-detective-screen-0.2.0-rc.1-{win-x64,win-arm64}.zip`.
|
||||||
|
У каждого — `.build-info.json`; текущие контрольные суммы:
|
||||||
|
`release/SHA256SUMS-linux-arm64_linux-x64_win-arm64_win-x64`.
|
||||||
|
Архивы 0.1.0 и прежний SHA256SUMS не относятся к кандидату.
|
||||||
|
- Упакованный Linux x64: обновление с сохранённого стенда 0.1.0 и два запуска
|
||||||
|
кандидата на изолированном профиле — PASS. Два офлайн-файла, выбранный пакет,
|
||||||
|
снимок результатов и настройки сохранены; Range работает; автоматического
|
||||||
|
открытия показа/звука/таймера нет. Проверены начало/конец диагностического журнала.
|
||||||
|
- GUI smoke — PASS: восемь режимов, 11 превью, локальные WAV/H.264 и Range,
|
||||||
|
независимая музыка, существующее серверное управление и запреты IPC.
|
||||||
|
Проверены аварии дисплея/питания/окна, настоящее завершение renderer и повторное
|
||||||
|
открытие без автопуска; все 16 блокировок сна освобождены. Дисплей синтетический,
|
||||||
|
события топологии/fullscreen/сна подставлены; флаги no-sandbox/mute-audio/
|
||||||
|
disable-gpu использованы только для теста. Физические задержки не измерялись.
|
||||||
|
- Повторный packaged smoke после исправления завершения тестового процесса — PASS.
|
||||||
|
`sha256sum -c SHA256SUMS-linux-arm64_linux-x64_win-arm64_win-x64` — четыре OK.
|
||||||
|
`git diff --check` — PASS.
|
||||||
|
|
||||||
|
Windows-архивы здесь не запускались. GitHub Actions не запускался, артефактов
|
||||||
|
macOS нет. Этот стенд не подтверждает Ubuntu/Debian, реальный проектор,
|
||||||
|
системный аудиовыход, сон, час офлайн и физическую задержку на M1.
|
||||||
|
|
||||||
|
## Отчёт одного стенда
|
||||||
|
|
||||||
|
- Дата, версия приложения и SHA-256 архива:
|
||||||
|
- ОС/версия/архитектура; CPU/RAM:
|
||||||
|
- Дисплей/проектор, разрешение/масштаб, переходник; для Linux X11/XWayland:
|
||||||
|
- Аудиовыходы (кабель/USB/HDMI) и форматы:
|
||||||
|
- Предупреждения неподписанного запуска и результат установки:
|
||||||
|
- Подготовка/восемь режимов/музыка/таймер/результаты:
|
||||||
|
- Телефон Safari iPhone / Chrome Android:
|
||||||
|
- Час работы офлайн; отключение и возврат сети:
|
||||||
|
- Переподключение дисплея/fullscreen/сон/перезапуск:
|
||||||
|
- Замена приложения с сохранением профиля:
|
||||||
|
- 30 физических пусков каждого формата/выхода; медиана/p95:
|
||||||
|
- Сбои и непройденные пункты; приложенные diagnostic*.jsonl:
|
||||||
|
|
||||||
|
Не прикладывать пароли, токены, файл credentials.bin и весь пользовательский профиль.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { test } from 'node:test'
|
||||||
|
import { mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { DiagnosticLog } from './diagnostics.js'
|
||||||
|
|
||||||
|
test('журнал сохраняет только разрешённые поля и значения без секретов', async (t) => {
|
||||||
|
const dir = await mkdtemp(join(tmpdir(), 'screen-log-'))
|
||||||
|
t.after(() => rm(dir, { recursive: true, force: true }))
|
||||||
|
const log = new DiagnosticLog(dir)
|
||||||
|
log.record('app-start', {
|
||||||
|
version: '0.2.0-rc.1',
|
||||||
|
electron: '42.11.15',
|
||||||
|
platform: 'linux',
|
||||||
|
arch: 'x64',
|
||||||
|
token: 'SECRET',
|
||||||
|
})
|
||||||
|
log.record('output-close', { reason: 'renderer', password: 'SECRET', error: new Error('SECRET') })
|
||||||
|
log.record('output-close', { reason: 'https://secret.example/SECRET' })
|
||||||
|
log.record('package', { action: 'download', status: 'failed', response: 'SECRET' })
|
||||||
|
const content = await readFile(join(dir, 'diagnostic.jsonl'), 'utf8')
|
||||||
|
assert.equal(content.includes('SECRET'), false)
|
||||||
|
const lines = content
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.map((s) => JSON.parse(s))
|
||||||
|
assert.equal(lines.length, 3)
|
||||||
|
assert.equal(lines[0].version, '0.2.0-rc.1')
|
||||||
|
assert.deepEqual(Object.keys(lines[1]).sort(), ['event', 'reason', 'time'])
|
||||||
|
})
|
||||||
|
test('ротация ограничивает журнал тремя файлами; ошибка записи не ломает приложение', async (t) => {
|
||||||
|
const dir = await mkdtemp(join(tmpdir(), 'screen-log-'))
|
||||||
|
t.after(() => rm(dir, { recursive: true, force: true }))
|
||||||
|
const log = new DiagnosticLog(dir, 256)
|
||||||
|
for (let i = 0; i < 100; i++) log.record('output-open')
|
||||||
|
const files = await readdir(dir)
|
||||||
|
assert.equal(files.length, 3)
|
||||||
|
for (const file of files) assert.ok((await stat(join(dir, file))).size <= 256)
|
||||||
|
const blocked = join(dir, 'not-a-directory')
|
||||||
|
await writeFile(blocked, 'file')
|
||||||
|
const broken = new DiagnosticLog(blocked)
|
||||||
|
assert.doesNotThrow(() => broken.record('output-open'))
|
||||||
|
assert.doesNotThrow(() => broken.record('app-stop'))
|
||||||
|
})
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { appendFileSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
|
||||||
|
type Event =
|
||||||
|
| 'app-start'
|
||||||
|
| 'app-stop'
|
||||||
|
| 'startup-error'
|
||||||
|
| 'settings-error'
|
||||||
|
| 'invalid-report'
|
||||||
|
| 'output-open'
|
||||||
|
| 'output-close'
|
||||||
|
| 'media-error'
|
||||||
|
| 'connection'
|
||||||
|
| 'package'
|
||||||
|
const fields: Partial<Record<Event, Record<string, readonly string[]>>> = {
|
||||||
|
'output-close': {
|
||||||
|
reason: [
|
||||||
|
'operator',
|
||||||
|
'display',
|
||||||
|
'power',
|
||||||
|
'fullscreen',
|
||||||
|
'renderer',
|
||||||
|
'document',
|
||||||
|
'geometry',
|
||||||
|
'unresponsive',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'media-error': { channel: ['main', 'music', 'scene'] },
|
||||||
|
connection: { status: ['disabled', 'waiting', 'connected', 'offline', 'expired'] },
|
||||||
|
package: { action: ['download', 'check', 'remove'], status: ['success', 'failed'] },
|
||||||
|
}
|
||||||
|
const events: readonly string[] = [
|
||||||
|
'app-start',
|
||||||
|
'app-stop',
|
||||||
|
'startup-error',
|
||||||
|
'settings-error',
|
||||||
|
'invalid-report',
|
||||||
|
'output-open',
|
||||||
|
'output-close',
|
||||||
|
'media-error',
|
||||||
|
'connection',
|
||||||
|
'package',
|
||||||
|
]
|
||||||
|
export class DiagnosticLog {
|
||||||
|
private disabled = false
|
||||||
|
constructor(
|
||||||
|
private directory: string,
|
||||||
|
private limit = 1024 * 1024,
|
||||||
|
) {}
|
||||||
|
record(event: Event, data: Record<string, unknown> = {}) {
|
||||||
|
if (this.disabled || !events.includes(event)) return
|
||||||
|
const entry: Record<string, string> = { time: new Date().toISOString(), event }
|
||||||
|
// Только разрешённые значения: никаких Error.message, URL, токенов или состояния игры.
|
||||||
|
for (const [key, allowed] of Object.entries(fields[event] ?? {})) {
|
||||||
|
if (typeof data[key] !== 'string' || !allowed.includes(data[key])) return
|
||||||
|
entry[key] = data[key]
|
||||||
|
}
|
||||||
|
if (event === 'app-start') {
|
||||||
|
for (const key of ['version', 'electron'] as const) {
|
||||||
|
if (typeof data[key] !== 'string' || !/^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?$/.test(data[key]))
|
||||||
|
return
|
||||||
|
entry[key] = data[key]
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!['linux', 'darwin', 'win32'].includes(String(data.platform)) ||
|
||||||
|
!['x64', 'arm64', 'ia32'].includes(String(data.arch))
|
||||||
|
)
|
||||||
|
return
|
||||||
|
entry.platform = String(data.platform)
|
||||||
|
entry.arch = String(data.arch)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
mkdirSync(this.directory, { recursive: true, mode: 0o700 })
|
||||||
|
const path = join(this.directory, 'diagnostic.jsonl')
|
||||||
|
const line = JSON.stringify(entry) + '\n'
|
||||||
|
let size = 0
|
||||||
|
try {
|
||||||
|
size = statSync(path).size
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||||
|
}
|
||||||
|
if (size + Buffer.byteLength(line) > this.limit) {
|
||||||
|
rmSync(join(this.directory, 'diagnostic.2.jsonl'), { force: true })
|
||||||
|
const previous = join(this.directory, 'diagnostic.1.jsonl')
|
||||||
|
try {
|
||||||
|
renameSync(previous, join(this.directory, 'diagnostic.2.jsonl'))
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||||
|
}
|
||||||
|
renameSync(path, previous)
|
||||||
|
}
|
||||||
|
appendFileSync(path, line, { mode: 0o600 })
|
||||||
|
} catch {
|
||||||
|
this.disabled = true
|
||||||
|
console.error('SCREEN_LOG_UNAVAILABLE')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+67
-8
@@ -13,6 +13,7 @@ import { createReadStream } from 'node:fs'
|
|||||||
import { stat } from 'node:fs/promises'
|
import { stat } from 'node:fs/promises'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { Readable } from 'node:stream'
|
import { Readable } from 'node:stream'
|
||||||
|
import { DiagnosticLog } from './diagnostics.js'
|
||||||
import { Controller, parseCommand } from './controller.js'
|
import { Controller, parseCommand } from './controller.js'
|
||||||
import { WindowSettings, fitControlBounds, preferredDisplayId } from './window-settings.js'
|
import { WindowSettings, fitControlBounds, preferredDisplayId } from './window-settings.js'
|
||||||
import { parseRange } from './range.js'
|
import { parseRange } from './range.js'
|
||||||
@@ -43,8 +44,18 @@ let settings: WindowSettings
|
|||||||
let sleepBlocker: number | null = null
|
let sleepBlocker: number | null = null
|
||||||
let hostNotice = ''
|
let hostNotice = ''
|
||||||
let suspended = false
|
let suspended = false
|
||||||
|
let diagnostics: DiagnosticLog | null = null
|
||||||
|
const lastErrors = { main: false, music: false, scene: false }
|
||||||
let saveTimer: ReturnType<typeof setTimeout> | undefined
|
let saveTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
const controller = new Controller((state) => {
|
const controller = new Controller((state) => {
|
||||||
|
for (const channel of ['main', 'music', 'scene'] as const) {
|
||||||
|
const failed =
|
||||||
|
channel === 'scene'
|
||||||
|
? state.scene.status === 'error'
|
||||||
|
: state.channels[channel].status === 'error'
|
||||||
|
if (failed && !lastErrors[channel]) diagnostics?.record('media-error', { channel })
|
||||||
|
lastErrors[channel] = failed
|
||||||
|
}
|
||||||
for (const window of [control, output])
|
for (const window of [control, output])
|
||||||
if (window && !window.isDestroyed()) window.webContents.send('state', state)
|
if (window && !window.isDestroyed()) window.webContents.send('state', state)
|
||||||
})
|
})
|
||||||
@@ -101,6 +112,23 @@ function closeOutput(reason = '', destroy = true) {
|
|||||||
if (reason) outputEpoch++
|
if (reason) outputEpoch++
|
||||||
const window = output
|
const window = output
|
||||||
if (!window && (!reason || controller.state.timer.status !== 'running')) return
|
if (!window && (!reason || controller.state.timer.status !== 'running')) return
|
||||||
|
diagnostics?.record('output-close', {
|
||||||
|
reason: !reason
|
||||||
|
? 'operator'
|
||||||
|
: reason.includes('сон')
|
||||||
|
? 'power'
|
||||||
|
: reason.includes('полноэкран')
|
||||||
|
? 'fullscreen'
|
||||||
|
: reason.includes('Процесс')
|
||||||
|
? 'renderer'
|
||||||
|
: reason.includes('не отвечает')
|
||||||
|
? 'unresponsive'
|
||||||
|
: reason.includes('загрузить')
|
||||||
|
? 'document'
|
||||||
|
: reason.includes('Параметры')
|
||||||
|
? 'geometry'
|
||||||
|
: 'display',
|
||||||
|
})
|
||||||
output = null
|
output = null
|
||||||
if (window) closingWindow = window
|
if (window) closingWindow = window
|
||||||
selectedDisplay = null
|
selectedDisplay = null
|
||||||
@@ -123,6 +151,7 @@ function saveWindowSettings() {
|
|||||||
try {
|
try {
|
||||||
settings.save()
|
settings.save()
|
||||||
} catch {
|
} catch {
|
||||||
|
diagnostics?.record('settings-error')
|
||||||
hostNotice = 'Не удалось сохранить расположение окон; материалы игры не затронуты'
|
hostNotice = 'Не удалось сохранить расположение окон; материалы игры не затронуты'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -180,6 +209,7 @@ async function show(displayId: number) {
|
|||||||
saveWindowSettings()
|
saveWindowSettings()
|
||||||
secure(window)
|
secure(window)
|
||||||
controller.output(true)
|
controller.output(true)
|
||||||
|
diagnostics?.record('output-open')
|
||||||
try {
|
try {
|
||||||
sleepBlocker = powerSaveBlocker.start('prevent-display-sleep')
|
sleepBlocker = powerSaveBlocker.start('prevent-display-sleep')
|
||||||
} catch {
|
} catch {
|
||||||
@@ -241,10 +271,18 @@ app.on('second-instance', () => {
|
|||||||
app
|
app
|
||||||
.whenReady()
|
.whenReady()
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
|
diagnostics = new DiagnosticLog(join(app.getPath('userData'), 'logs'))
|
||||||
|
diagnostics.record('app-start', {
|
||||||
|
version: app.getVersion(),
|
||||||
|
electron: process.versions.electron,
|
||||||
|
platform: process.platform,
|
||||||
|
arch: process.arch,
|
||||||
|
})
|
||||||
settings = new WindowSettings(join(app.getPath('userData'), 'windows.json'))
|
settings = new WindowSettings(join(app.getPath('userData'), 'windows.json'))
|
||||||
try {
|
try {
|
||||||
await settings.load()
|
await settings.load()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
diagnostics.record('settings-error')
|
||||||
hostNotice = error instanceof Error ? error.message : 'Не удалось восстановить окна'
|
hostNotice = error instanceof Error ? error.message : 'Не удалось восстановить окна'
|
||||||
}
|
}
|
||||||
const packages = new PackageStore(join(app.getPath('userData'), 'packages'))
|
const packages = new PackageStore(join(app.getPath('userData'), 'packages'))
|
||||||
@@ -258,8 +296,14 @@ app
|
|||||||
decrypt: (value) => safeStorage.decryptString(value),
|
decrypt: (value) => safeStorage.decryptString(value),
|
||||||
})
|
})
|
||||||
await online.initialize()
|
await online.initialize()
|
||||||
|
let previousConnection = online.state.connection
|
||||||
|
const logConnection = () => {
|
||||||
|
const status = online!.state.connection
|
||||||
|
if (status !== previousConnection) diagnostics?.record('connection', { status })
|
||||||
|
previousConnection = status
|
||||||
|
}
|
||||||
const onlineTick = setInterval(() => {
|
const onlineTick = setInterval(() => {
|
||||||
void online?.poll()
|
void online?.poll().then(logConnection)
|
||||||
}, 1000)
|
}, 1000)
|
||||||
const timerTick = setInterval(() => controller.tick(), 250)
|
const timerTick = setInterval(() => controller.tick(), 250)
|
||||||
app.on('before-quit', () => {
|
app.on('before-quit', () => {
|
||||||
@@ -271,6 +315,7 @@ app
|
|||||||
clearInterval(onlineTick)
|
clearInterval(onlineTick)
|
||||||
online?.close()
|
online?.close()
|
||||||
packages.cancel()
|
packages.cancel()
|
||||||
|
diagnostics?.record('app-stop')
|
||||||
})
|
})
|
||||||
const handleAppRequest: Parameters<typeof protocol.handle>[1] = async (request) => {
|
const handleAppRequest: Parameters<typeof protocol.handle>[1] = async (request) => {
|
||||||
const url = new URL(request.url)
|
const url = new URL(request.url)
|
||||||
@@ -378,7 +423,20 @@ app
|
|||||||
ipcMain.handle('online-action', async (event, intent: unknown) => {
|
ipcMain.handle('online-action', async (event, intent: unknown) => {
|
||||||
requireControl(event)
|
requireControl(event)
|
||||||
if (selectingPackage) throw new Error('Подождите проверки пакета')
|
if (selectingPackage) throw new Error('Подождите проверки пакета')
|
||||||
return online!.act(intent)
|
const action =
|
||||||
|
intent && typeof intent === 'object' && 'action' in intent ? intent.action : null
|
||||||
|
try {
|
||||||
|
const result = await online!.act(intent)
|
||||||
|
if (['download', 'check', 'remove'].includes(String(action)))
|
||||||
|
diagnostics?.record('package', { action, status: 'success' })
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
if (['download', 'check', 'remove'].includes(String(action)))
|
||||||
|
diagnostics?.record('package', { action, status: 'failed' })
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
logConnection()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
ipcMain.handle('close-output', (event) => {
|
ipcMain.handle('close-output', (event) => {
|
||||||
requireControl(event)
|
requireControl(event)
|
||||||
@@ -484,8 +542,8 @@ app
|
|||||||
return
|
return
|
||||||
try {
|
try {
|
||||||
controller.reportScene(report)
|
controller.reportScene(report)
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('Ошибка подтверждения сцены', error)
|
diagnostics?.record('invalid-report')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
ipcMain.on('report', (event, report: unknown) => {
|
ipcMain.on('report', (event, report: unknown) => {
|
||||||
@@ -496,8 +554,8 @@ app
|
|||||||
return
|
return
|
||||||
try {
|
try {
|
||||||
controller.report(report)
|
controller.report(report)
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('Ошибка отчёта проигрывателя', error)
|
diagnostics?.record('invalid-report')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
screen.on('display-removed', (_event, display) => {
|
screen.on('display-removed', (_event, display) => {
|
||||||
@@ -526,8 +584,9 @@ app
|
|||||||
})
|
})
|
||||||
await control.loadURL('screen-app://app/index.html')
|
await control.loadURL('screen-app://app/index.html')
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch(() => {
|
||||||
console.error(error)
|
diagnostics?.record('startup-error')
|
||||||
|
console.error('SCREEN_STARTUP_FAILED')
|
||||||
app.quit()
|
app.quit()
|
||||||
})
|
})
|
||||||
app.on('window-all-closed', () => app.quit())
|
app.on('window-all-closed', () => app.quit())
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "evening-detective-screen",
|
"name": "evening-detective-screen",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0-rc.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "evening-detective-screen",
|
"name": "evening-detective-screen",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0-rc.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"naive-ui": "^2.44.0",
|
"naive-ui": "^2.44.0",
|
||||||
"vue": "^3.5.0"
|
"vue": "^3.5.0"
|
||||||
|
|||||||
+12
-6
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "evening-detective-screen",
|
"name": "evening-detective-screen",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0-rc.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Локальный стенд приложения площадки",
|
"description": "Приложение площадки «Вечерний детектив»",
|
||||||
"author": "Вечерний детектив",
|
"author": "Вечерний детектив",
|
||||||
"main": "dist-electron/electron/main.js",
|
"main": "dist-electron/electron/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -11,13 +11,15 @@
|
|||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"lint:css": "stylelint \"src/**/*.{css,vue}\"",
|
"lint:css": "stylelint \"src/**/*.{css,vue}\"",
|
||||||
"build": "vite build && npm run build:electron",
|
"build": "vite build && npm run build:electron",
|
||||||
"test": "npm run build:electron && node --test dist-electron/electron/*.test.js",
|
"test": "npm run build:electron && node --test dist-electron/electron/*.test.js scripts/*.test.cjs",
|
||||||
"media:generate": "node scripts/generate-media.cjs",
|
"media:generate": "node scripts/generate-media.cjs",
|
||||||
"pack": "npm run build && electron-builder --dir",
|
"pack": "npm run build && electron-builder --dir",
|
||||||
"dist": "npm run build && electron-builder",
|
"dist": "npm run build && electron-builder",
|
||||||
"smoke": "npm run build && electron scripts/smoke.cjs",
|
"smoke": "npm run build && electron scripts/smoke.cjs",
|
||||||
"acceptance": "npm run build && electron scripts/smoke.cjs --manual",
|
"acceptance": "npm run build && electron scripts/smoke.cjs --manual",
|
||||||
"build:electron": "node -e \"require('node:fs').rmSync('dist-electron', { recursive: true, force: true })\" && tsc -p tsconfig.electron.json"
|
"build:electron": "node -e \"require('node:fs').rmSync('dist-electron', { recursive: true, force: true })\" && tsc -p tsconfig.electron.json",
|
||||||
|
"smoke:packaged": "node scripts/packaged-smoke.cjs",
|
||||||
|
"verify:release": "node scripts/release-finish.cjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vue": "^3.5.0",
|
"vue": "^3.5.0",
|
||||||
@@ -64,7 +66,9 @@
|
|||||||
"zip"
|
"zip"
|
||||||
],
|
],
|
||||||
"minimumSystemVersion": "13.0",
|
"minimumSystemVersion": "13.0",
|
||||||
"category": "public.app-category.entertainment"
|
"category": "public.app-category.entertainment",
|
||||||
|
"notarize": false,
|
||||||
|
"identity": "-"
|
||||||
},
|
},
|
||||||
"win": {
|
"win": {
|
||||||
"target": [
|
"target": [
|
||||||
@@ -78,7 +82,9 @@
|
|||||||
],
|
],
|
||||||
"category": "AudioVideo"
|
"category": "AudioVideo"
|
||||||
},
|
},
|
||||||
"artifactName": "${name}-${version}-${os}-${arch}.${ext}"
|
"artifactName": "${name}-${version}-${os}-${arch}.${ext}",
|
||||||
|
"afterPack": "scripts/release.cjs",
|
||||||
|
"afterAllArtifactBuild": "scripts/release-finish.cjs"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24"
|
"node": ">=24"
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const { spawn } = require('node:child_process')
|
||||||
|
const { mkdtemp, mkdir, readFile, writeFile, rm } = require('node:fs/promises')
|
||||||
|
const { join, resolve } = require('node:path')
|
||||||
|
const { tmpdir } = require('node:os')
|
||||||
|
const { fileHash } = require('./release.cjs')
|
||||||
|
const metadata = require('../package.json')
|
||||||
|
const root = resolve(__dirname, '..')
|
||||||
|
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
async function until(check, label) {
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
const result = await check()
|
||||||
|
if (result) return result
|
||||||
|
await delay(100)
|
||||||
|
}
|
||||||
|
throw new Error(`Timeout: ${label}`)
|
||||||
|
}
|
||||||
|
async function launch(binary, profile, check) {
|
||||||
|
const child = spawn(
|
||||||
|
binary,
|
||||||
|
[
|
||||||
|
`--user-data-dir=${profile}`,
|
||||||
|
'--remote-debugging-port=0',
|
||||||
|
'--remote-debugging-address=127.0.0.1',
|
||||||
|
'--no-sandbox',
|
||||||
|
'--mute-audio',
|
||||||
|
'--disable-gpu',
|
||||||
|
],
|
||||||
|
{ stdio: ['ignore', 'ignore', 'pipe'] },
|
||||||
|
)
|
||||||
|
let stderr = ''
|
||||||
|
let spawnError = null
|
||||||
|
let exited = false
|
||||||
|
child.on('error', (error) => {
|
||||||
|
spawnError = error
|
||||||
|
})
|
||||||
|
const exit = new Promise((resolve) =>
|
||||||
|
child.on('close', () => {
|
||||||
|
exited = true
|
||||||
|
resolve()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
child.stderr.on('data', (bytes) => {
|
||||||
|
stderr = (stderr + bytes.toString()).slice(-32768)
|
||||||
|
})
|
||||||
|
let socket
|
||||||
|
try {
|
||||||
|
const port = await until(() => {
|
||||||
|
if (spawnError) throw spawnError
|
||||||
|
if (exited) throw new Error('Packaged application exited before startup')
|
||||||
|
const match = /DevTools listening on ws:\/\/127\.0\.0\.1:(\d+)\//.exec(stderr)
|
||||||
|
return match?.[1]
|
||||||
|
}, 'DevTools endpoint')
|
||||||
|
const target = await until(async () => {
|
||||||
|
const response = await fetch(`http://127.0.0.1:${port}/json`)
|
||||||
|
const targets = await response.json()
|
||||||
|
return targets.find((page) => page.url === 'screen-app://app/index.html')
|
||||||
|
}, 'control target')
|
||||||
|
socket = new WebSocket(target.webSocketDebuggerUrl)
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
socket.onopen = resolve
|
||||||
|
socket.onerror = reject
|
||||||
|
})
|
||||||
|
let id = 0
|
||||||
|
const pending = new Map()
|
||||||
|
socket.onmessage = ({ data }) => {
|
||||||
|
const result = JSON.parse(data)
|
||||||
|
const entry = pending.get(result.id)
|
||||||
|
if (entry) {
|
||||||
|
pending.delete(result.id)
|
||||||
|
clearTimeout(entry.timer)
|
||||||
|
entry.resolve(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
socket.onclose = () => {
|
||||||
|
for (const entry of pending.values()) {
|
||||||
|
clearTimeout(entry.timer)
|
||||||
|
entry.reject(new Error('Debugger disconnected'))
|
||||||
|
}
|
||||||
|
pending.clear()
|
||||||
|
}
|
||||||
|
const request = (method, params = {}) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const callId = ++id
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
pending.delete(callId)
|
||||||
|
reject(new Error('Debugger request timeout'))
|
||||||
|
}, 5000)
|
||||||
|
pending.set(callId, { resolve, reject, timer })
|
||||||
|
socket.send(JSON.stringify({ id: callId, method, params }))
|
||||||
|
})
|
||||||
|
const evaluate = async (expression) => {
|
||||||
|
const result = await request('Runtime.evaluate', {
|
||||||
|
expression,
|
||||||
|
awaitPromise: true,
|
||||||
|
returnByValue: true,
|
||||||
|
})
|
||||||
|
assert.ok(!result.error && !result.result?.exceptionDetails, 'Packaged evaluation failed')
|
||||||
|
return result.result.result.value
|
||||||
|
}
|
||||||
|
await until(
|
||||||
|
() => evaluate('!!window.screenBridge && !!document.querySelector("h1")'),
|
||||||
|
'packaged UI',
|
||||||
|
)
|
||||||
|
await check(evaluate)
|
||||||
|
void request('Page.close').catch(() => {})
|
||||||
|
await until(() => exited, 'normal application exit')
|
||||||
|
} finally {
|
||||||
|
socket?.close()
|
||||||
|
if (!exited) {
|
||||||
|
child.kill('SIGKILL')
|
||||||
|
await exit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function main() {
|
||||||
|
const binary = resolve(process.argv[2] || 'release/linux-unpacked/evening-detective-screen')
|
||||||
|
const previous = process.argv[3] ? resolve(process.argv[3]) : null
|
||||||
|
const profile = await mkdtemp(join(tmpdir(), 'screen-packaged-'))
|
||||||
|
const { PackageStore } = require('../dist-electron/electron/package-store.js')
|
||||||
|
const { buildScreenPackage } = require('../dist-electron/electron/screen-package.js')
|
||||||
|
try {
|
||||||
|
const store = new PackageStore(join(profile, 'packages'), {
|
||||||
|
fetch: async (input) => {
|
||||||
|
const video = String(input).endsWith('.mp4')
|
||||||
|
return new Response(
|
||||||
|
await readFile(join(root, 'fixtures', video ? 'video.mp4' : 'audio.wav')),
|
||||||
|
{ headers: { 'Content-Type': video ? 'video/mp4' : 'audio/wav' } },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await store.initialize()
|
||||||
|
const description = buildScreenPackage('https://packaged.test', 7, {
|
||||||
|
game: {
|
||||||
|
game: {
|
||||||
|
ID: 7,
|
||||||
|
name: 'Packaged offline',
|
||||||
|
scenario: {
|
||||||
|
story: { introduction: { audio: 'intro.wav' }, conclusion: { video: 'video.mp4' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plan: {},
|
||||||
|
music: {},
|
||||||
|
})
|
||||||
|
const pkg = await store.download(description)
|
||||||
|
const connection = join(profile, 'connection')
|
||||||
|
await mkdir(connection)
|
||||||
|
await writeFile(
|
||||||
|
join(connection, 'selection.json'),
|
||||||
|
JSON.stringify({ server: description.server, key: pkg.key }),
|
||||||
|
)
|
||||||
|
const results = {
|
||||||
|
packageKey: pkg.key,
|
||||||
|
capturedAt: '2026-10-17T10:00:00.000Z',
|
||||||
|
questions: [{ code: 'A', text: 'Question' }],
|
||||||
|
teams: [
|
||||||
|
{
|
||||||
|
id: 't',
|
||||||
|
name: 'Team',
|
||||||
|
place: 1,
|
||||||
|
totalScore: 3,
|
||||||
|
actionsCount: 1,
|
||||||
|
answers: [{ questionCode: 'A', score: 3 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const resultsFile = join(connection, `results-${pkg.key}.json`)
|
||||||
|
await writeFile(resultsFile, JSON.stringify(results))
|
||||||
|
await writeFile(
|
||||||
|
join(profile, 'windows.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
control: { bounds: { x: 30, y: 30, width: 900, height: 650 }, maximized: false },
|
||||||
|
display: null,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const manifest = join(profile, 'packages', pkg.key, `v-${pkg.version}`, 'manifest.json')
|
||||||
|
const before = await fileHash(manifest)
|
||||||
|
const resultHash = await fileHash(resultsFile)
|
||||||
|
if (previous)
|
||||||
|
await launch(previous, profile, async (evaluate) => {
|
||||||
|
assert.equal(await evaluate('typeof window.require'), 'undefined')
|
||||||
|
})
|
||||||
|
for (let attempt = 0; attempt < 2; attempt++)
|
||||||
|
await launch(binary, profile, async (evaluate) => {
|
||||||
|
assert.equal(await evaluate('typeof window.require'), 'undefined')
|
||||||
|
const state = await evaluate('window.screenBridge.state()')
|
||||||
|
assert.equal(state.catalog.key, pkg.key)
|
||||||
|
assert.equal(state.results.teams[0].totalScore, 3)
|
||||||
|
assert.equal(state.outputOpen, false)
|
||||||
|
assert.equal(state.timer.status, 'stopped')
|
||||||
|
assert.equal(state.channels.main.desired, 'stopped')
|
||||||
|
assert.equal(state.channels.music.desired, 'stopped')
|
||||||
|
assert.equal(await evaluate('document.querySelectorAll("audio,video").length'), 0)
|
||||||
|
for (const url of [
|
||||||
|
'screen-app://app/media/video',
|
||||||
|
state.catalog.media.find((m) => m.kind === 'video').url,
|
||||||
|
]) {
|
||||||
|
const range = await evaluate(
|
||||||
|
`fetch(${JSON.stringify(url)}, {headers:{Range:'bytes=100-199'}}).then(async r=>({status:r.status,size:(await r.arrayBuffer()).byteLength}))`,
|
||||||
|
)
|
||||||
|
assert.deepEqual(range, { status: 206, size: 100 })
|
||||||
|
}
|
||||||
|
const logs = await readFile(join(profile, 'logs', 'diagnostic.jsonl'), 'utf8')
|
||||||
|
const starts = logs
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => JSON.parse(line))
|
||||||
|
.filter((row) => row.event === 'app-start')
|
||||||
|
assert.equal(starts.at(-1).version, metadata.version)
|
||||||
|
assert.equal(await fileHash(manifest), before)
|
||||||
|
assert.equal(await fileHash(resultsFile), resultHash)
|
||||||
|
assert.equal((await evaluate('window.screenBridge.host()')).sleepBlocked, false)
|
||||||
|
})
|
||||||
|
const log = (await readFile(join(profile, 'logs', 'diagnostic.jsonl'), 'utf8'))
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => JSON.parse(line))
|
||||||
|
assert.equal(log.at(-1).event, 'app-stop')
|
||||||
|
const restored = new PackageStore(join(profile, 'packages'))
|
||||||
|
await restored.initialize()
|
||||||
|
assert.equal(restored.list()[0].version, pkg.version)
|
||||||
|
const windows = JSON.parse(await readFile(join(profile, 'windows.json'), 'utf8'))
|
||||||
|
assert.equal(windows.control.maximized, false)
|
||||||
|
assert.ok(windows.control.bounds.width <= 900 && windows.control.bounds.height <= 650)
|
||||||
|
console.log(
|
||||||
|
'PACKAGED_SMOKE_PASS',
|
||||||
|
JSON.stringify({
|
||||||
|
version: metadata.version,
|
||||||
|
previous: !!previous,
|
||||||
|
restarts: 2,
|
||||||
|
offlineFiles: pkg.files.length,
|
||||||
|
preservedResults: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
await rm(profile, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error.message)
|
||||||
|
process.exitCode = 1
|
||||||
|
})
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const { mkdtempSync, readdirSync, writeFileSync, rmSync } = require('node:fs')
|
||||||
|
const { join, basename, resolve } = require('node:path')
|
||||||
|
const { execFileSync } = require('node:child_process')
|
||||||
|
const { fileHash, verifyDirectory } = require('./release.cjs')
|
||||||
|
const metadata = require('../package.json')
|
||||||
|
const output = resolve(__dirname, '..', 'release')
|
||||||
|
async function verifyArchive(artifact) {
|
||||||
|
const temporary = mkdtempSync(join(output, '.verify-'))
|
||||||
|
try {
|
||||||
|
if (artifact.endsWith('.tar.gz')) execFileSync('tar', ['-xzf', artifact, '-C', temporary])
|
||||||
|
else {
|
||||||
|
const sevenZip = await require('app-builder-lib/out/toolsets/7zip').getPath7za()
|
||||||
|
execFileSync(sevenZip, ['x', artifact, `-o${temporary}`, '-y', '-bd'], { stdio: 'pipe' })
|
||||||
|
}
|
||||||
|
let directory = temporary
|
||||||
|
if (!readdirSync(temporary).some((name) => name === 'resources' || name.endsWith('.app'))) {
|
||||||
|
const dirs = readdirSync(temporary, { withFileTypes: true }).filter((d) => d.isDirectory())
|
||||||
|
assert.equal(dirs.length, 1, 'Expected one application directory')
|
||||||
|
directory = join(temporary, dirs[0].name)
|
||||||
|
}
|
||||||
|
return await verifyDirectory(directory)
|
||||||
|
} finally {
|
||||||
|
rmSync(temporary, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function finish(context) {
|
||||||
|
const supplied = context?.artifactPaths ?? process.argv.slice(2).map((p) => resolve(p))
|
||||||
|
const artifacts = supplied.filter((path) => /\.(?:zip|tar\.gz)$/.test(path))
|
||||||
|
assert.ok(artifacts.length, 'No artifacts from current build')
|
||||||
|
const sums = []
|
||||||
|
const sidecars = []
|
||||||
|
for (const artifact of artifacts) {
|
||||||
|
assert.ok(
|
||||||
|
basename(artifact).startsWith(`${metadata.name}-${metadata.version}-`),
|
||||||
|
'Wrong release version',
|
||||||
|
)
|
||||||
|
const info = await verifyArchive(artifact)
|
||||||
|
const name = basename(artifact)
|
||||||
|
assert.ok(
|
||||||
|
name.includes(
|
||||||
|
`-${info.platform === 'win32' ? 'win' : info.platform === 'darwin' ? 'mac' : 'linux'}-${info.arch}.`,
|
||||||
|
),
|
||||||
|
'Artifact name/architecture mismatch',
|
||||||
|
)
|
||||||
|
const sha256 = await fileHash(artifact)
|
||||||
|
const sidecar = `${artifact}.build-info.json`
|
||||||
|
writeFileSync(sidecar, JSON.stringify({ ...info, artifact: name, sha256 }, null, 2) + '\n')
|
||||||
|
sidecars.push(sidecar)
|
||||||
|
sums.push(`${sha256} ${name}`)
|
||||||
|
}
|
||||||
|
// Не подмешивать старые файлы из release/: каждый build получает свой manifest.
|
||||||
|
const path = join(
|
||||||
|
output,
|
||||||
|
`SHA256SUMS-${artifacts
|
||||||
|
.map((p) =>
|
||||||
|
basename(p)
|
||||||
|
.split(`${metadata.version}-`)[1]
|
||||||
|
.replace(/\.(zip|tar\.gz)$/, ''),
|
||||||
|
)
|
||||||
|
.sort()
|
||||||
|
.join('_')}`,
|
||||||
|
)
|
||||||
|
writeFileSync(path, sums.sort().join('\n') + '\n')
|
||||||
|
console.log('RELEASE_ARCHIVES_PASS', artifacts.map((path) => basename(path)).join(', '))
|
||||||
|
return [...sidecars, path]
|
||||||
|
}
|
||||||
|
module.exports = finish
|
||||||
|
module.exports.verifyArchive = verifyArchive
|
||||||
|
if (require.main === module)
|
||||||
|
finish().catch((error) => {
|
||||||
|
console.error(error.message)
|
||||||
|
process.exitCode = 1
|
||||||
|
})
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const { createHash } = require('node:crypto')
|
||||||
|
const {
|
||||||
|
createReadStream,
|
||||||
|
existsSync,
|
||||||
|
readdirSync,
|
||||||
|
readFileSync,
|
||||||
|
writeFileSync,
|
||||||
|
} = require('node:fs')
|
||||||
|
const { join, relative } = require('node:path')
|
||||||
|
const { execFileSync } = require('node:child_process')
|
||||||
|
const asar = require('@electron/asar')
|
||||||
|
const root = join(__dirname, '..')
|
||||||
|
const metadata = require('../package.json')
|
||||||
|
const sha = (bytes) => createHash('sha256').update(bytes).digest('hex')
|
||||||
|
async function fileHash(path) {
|
||||||
|
const hash = createHash('sha256')
|
||||||
|
for await (const bytes of createReadStream(path)) hash.update(bytes)
|
||||||
|
return hash.digest('hex')
|
||||||
|
}
|
||||||
|
function resourcesIn(directory) {
|
||||||
|
const app = readdirSync(directory).find((name) => name.endsWith('.app'))
|
||||||
|
return app ? join(directory, app, 'Contents', 'Resources') : join(directory, 'resources')
|
||||||
|
}
|
||||||
|
function verifyAsar(path) {
|
||||||
|
const files = asar.listPackage(path).map((name) => name.replace(/^\//, ''))
|
||||||
|
for (const name of files) {
|
||||||
|
assert.ok(
|
||||||
|
!/(^|\/)(?:\.git|\.env(?:\.[^/]*)?|credentials\.bin|windows\.json|selection\.json|__tests__)(?:\/|$)|\.test\.(?:js|ts)$|\.(?:pem|key|p12|pfx)$/.test(
|
||||||
|
name,
|
||||||
|
),
|
||||||
|
`Forbidden packaged file: ${name}`,
|
||||||
|
)
|
||||||
|
assert.ok(
|
||||||
|
/^(?:node_modules(?:\/|$)|dist(?:\/|$)|dist-electron(?:\/|$)|package\.json$)/.test(name),
|
||||||
|
`Unexpected packaged file: ${name}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for (const name of [
|
||||||
|
'package.json',
|
||||||
|
'dist/index.html',
|
||||||
|
'dist-electron/electron/main.js',
|
||||||
|
'dist-electron/electron/preload.js',
|
||||||
|
'dist-electron/electron/online.js',
|
||||||
|
'dist-electron/electron/diagnostics.js',
|
||||||
|
])
|
||||||
|
assert.ok(files.includes(name), `Missing ${name}`)
|
||||||
|
const pkg = JSON.parse(asar.extractFile(path, 'package.json'))
|
||||||
|
assert.equal(pkg.version, metadata.version)
|
||||||
|
assert.equal(pkg.name, metadata.name)
|
||||||
|
return pkg
|
||||||
|
}
|
||||||
|
function binaryArchitecture(path, platform) {
|
||||||
|
const bytes = readFileSync(path)
|
||||||
|
if (platform === 'linux') {
|
||||||
|
assert.equal(bytes.subarray(0, 4).toString('hex'), '7f454c46')
|
||||||
|
return { 62: 'x64', 183: 'arm64' }[bytes.readUInt16LE(18)]
|
||||||
|
}
|
||||||
|
if (platform === 'win32') {
|
||||||
|
assert.equal(bytes.subarray(0, 2).toString(), 'MZ')
|
||||||
|
const offset = bytes.readUInt32LE(0x3c)
|
||||||
|
assert.equal(bytes.subarray(offset, offset + 4).toString(), 'PE\0\0')
|
||||||
|
return { 0x8664: 'x64', 0xaa64: 'arm64' }[bytes.readUInt16LE(offset + 4)]
|
||||||
|
}
|
||||||
|
assert.equal(bytes.subarray(0, 4).toString('hex'), 'cffaedfe')
|
||||||
|
return { 0x1000007: 'x64', 0x100000c: 'arm64' }[bytes.readUInt32LE(4)]
|
||||||
|
}
|
||||||
|
function executableIn(directory, platform) {
|
||||||
|
if (platform === 'darwin') {
|
||||||
|
const app = readdirSync(directory).find((name) => name.endsWith('.app'))
|
||||||
|
return join(directory, app, 'Contents', 'MacOS', metadata.build.productName)
|
||||||
|
}
|
||||||
|
return join(directory, platform === 'win32' ? `${metadata.build.productName}.exe` : metadata.name)
|
||||||
|
}
|
||||||
|
async function verifyDirectory(directory) {
|
||||||
|
const resources = resourcesIn(directory)
|
||||||
|
const info = JSON.parse(readFileSync(join(resources, 'build-info.json')))
|
||||||
|
assert.equal(info.formatVersion, 1)
|
||||||
|
assert.equal(info.version, metadata.version)
|
||||||
|
assert.ok(['linux', 'win32', 'darwin'].includes(info.platform))
|
||||||
|
assert.ok(['x64', 'arm64'].includes(info.arch))
|
||||||
|
verifyAsar(join(resources, 'app.asar'))
|
||||||
|
assert.deepEqual(Object.keys(info.files).sort(), [
|
||||||
|
'app.asar',
|
||||||
|
'fixtures/audio.wav',
|
||||||
|
'fixtures/video.mp4',
|
||||||
|
])
|
||||||
|
for (const [name, expected] of Object.entries(info.files))
|
||||||
|
assert.equal(await fileHash(join(resources, name)), expected, `Hash mismatch: ${name}`)
|
||||||
|
assert.deepEqual(readdirSync(join(resources, 'fixtures')).sort(), ['audio.wav', 'video.mp4'])
|
||||||
|
assert.ok(
|
||||||
|
!existsSync(join(resources, 'app.asar.unpacked')),
|
||||||
|
'Unexpected unpacked application files',
|
||||||
|
)
|
||||||
|
assert.equal(binaryArchitecture(executableIn(directory, info.platform), info.platform), info.arch)
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
async function afterPack(context) {
|
||||||
|
const resources = resourcesIn(context.appOutDir)
|
||||||
|
verifyAsar(join(resources, 'app.asar'))
|
||||||
|
const files = {}
|
||||||
|
for (const name of ['app.asar', 'fixtures/audio.wav', 'fixtures/video.mp4'])
|
||||||
|
files[name] = await fileHash(join(resources, name))
|
||||||
|
let revision = null
|
||||||
|
let dirty = true
|
||||||
|
try {
|
||||||
|
const result = execFileSync('git', ['rev-parse', 'HEAD'], {
|
||||||
|
cwd: root,
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
|
}).trim()
|
||||||
|
if (/^[a-f0-9]{40,64}$/.test(result)) revision = result
|
||||||
|
dirty = !!execFileSync('git', ['status', '--porcelain'], { cwd: root, encoding: 'utf8' }).trim()
|
||||||
|
} catch {
|
||||||
|
/* Экспорт исходников без .git тоже может быть собран. */
|
||||||
|
}
|
||||||
|
const ffmpeg = execFileSync('ffmpeg', ['-version'], { encoding: 'utf8' }).split('\n')[0]
|
||||||
|
const info = {
|
||||||
|
formatVersion: 1,
|
||||||
|
version: metadata.version,
|
||||||
|
platform: context.electronPlatformName,
|
||||||
|
arch: require('builder-util').Arch[context.arch],
|
||||||
|
revision,
|
||||||
|
dirty,
|
||||||
|
tools: {
|
||||||
|
node: process.versions.node,
|
||||||
|
electron: context.packager.info.framework.version,
|
||||||
|
builder: require('electron-builder/package.json').version,
|
||||||
|
ffmpeg,
|
||||||
|
},
|
||||||
|
lockfileSha256: sha(readFileSync(join(root, 'package-lock.json'))),
|
||||||
|
files,
|
||||||
|
}
|
||||||
|
writeFileSync(join(resources, 'build-info.json'), JSON.stringify(info, null, 2) + '\n')
|
||||||
|
await verifyDirectory(context.appOutDir)
|
||||||
|
console.log('RELEASE_CONTENTS_PASS', relative(root, context.appOutDir), info.platform, info.arch)
|
||||||
|
}
|
||||||
|
module.exports = afterPack
|
||||||
|
Object.assign(module.exports, {
|
||||||
|
fileHash,
|
||||||
|
verifyAsar,
|
||||||
|
verifyDirectory,
|
||||||
|
binaryArchitecture,
|
||||||
|
resourcesIn,
|
||||||
|
})
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const { test } = require('node:test')
|
||||||
|
const { mkdtempSync, writeFileSync, mkdirSync, rmSync } = require('node:fs')
|
||||||
|
const { join } = require('node:path')
|
||||||
|
const { tmpdir } = require('node:os')
|
||||||
|
const asar = require('@electron/asar')
|
||||||
|
const { binaryArchitecture, verifyAsar, verifyDirectory, fileHash } = require('./release.cjs')
|
||||||
|
test('release verifier rejects test files and secrets in app.asar', async (t) => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'screen-release-test-'))
|
||||||
|
t.after(() => rmSync(dir, { recursive: true, force: true }))
|
||||||
|
const source = join(dir, 'source')
|
||||||
|
mkdirSync(source)
|
||||||
|
writeFileSync(join(source, '.env'), 'secret')
|
||||||
|
const archive = join(dir, 'app.asar')
|
||||||
|
await asar.createPackage(source, archive)
|
||||||
|
assert.throws(() => verifyAsar(archive), /Forbidden/)
|
||||||
|
})
|
||||||
|
test('release verifier checks native ELF and PE architecture', (t) => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'screen-release-test-'))
|
||||||
|
t.after(() => rmSync(dir, { recursive: true, force: true }))
|
||||||
|
const elf = Buffer.alloc(64)
|
||||||
|
elf.write('7f454c46', 0, 'hex')
|
||||||
|
elf.writeUInt16LE(183, 18)
|
||||||
|
const path = join(dir, 'binary')
|
||||||
|
writeFileSync(path, elf)
|
||||||
|
assert.equal(binaryArchitecture(path, 'linux'), 'arm64')
|
||||||
|
const pe = Buffer.alloc(128)
|
||||||
|
pe.write('MZ')
|
||||||
|
pe.writeUInt32LE(64, 0x3c)
|
||||||
|
pe.write('PE\0\0', 64)
|
||||||
|
pe.writeUInt16LE(0x8664, 68)
|
||||||
|
writeFileSync(path, pe)
|
||||||
|
assert.equal(binaryArchitecture(path, 'win32'), 'x64')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('release verifier detects changed media bytes after packaging', async (t) => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'screen-release-test-'))
|
||||||
|
t.after(() => rmSync(dir, { recursive: true, force: true }))
|
||||||
|
const source = join(dir, 'source')
|
||||||
|
for (const file of [
|
||||||
|
'dist/index.html',
|
||||||
|
'dist-electron/electron/main.js',
|
||||||
|
'dist-electron/electron/preload.js',
|
||||||
|
'dist-electron/electron/online.js',
|
||||||
|
'dist-electron/electron/diagnostics.js',
|
||||||
|
]) {
|
||||||
|
const path = join(source, file)
|
||||||
|
mkdirSync(require('node:path').dirname(path), { recursive: true })
|
||||||
|
writeFileSync(path, 'fixture')
|
||||||
|
}
|
||||||
|
const metadata = require('../package.json')
|
||||||
|
writeFileSync(
|
||||||
|
join(source, 'package.json'),
|
||||||
|
JSON.stringify({ name: metadata.name, version: metadata.version }),
|
||||||
|
)
|
||||||
|
const resources = join(dir, 'resources')
|
||||||
|
mkdirSync(join(resources, 'fixtures'), { recursive: true })
|
||||||
|
await asar.createPackage(source, join(resources, 'app.asar'))
|
||||||
|
writeFileSync(join(resources, 'fixtures/audio.wav'), 'audio')
|
||||||
|
writeFileSync(join(resources, 'fixtures/video.mp4'), 'video')
|
||||||
|
const elf = Buffer.alloc(64)
|
||||||
|
elf.write('7f454c46', 0, 'hex')
|
||||||
|
elf.writeUInt16LE(62, 18)
|
||||||
|
writeFileSync(join(dir, metadata.name), elf)
|
||||||
|
const files = {}
|
||||||
|
for (const name of ['app.asar', 'fixtures/audio.wav', 'fixtures/video.mp4'])
|
||||||
|
files[name] = await fileHash(join(resources, name))
|
||||||
|
writeFileSync(
|
||||||
|
join(resources, 'build-info.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
formatVersion: 1,
|
||||||
|
version: metadata.version,
|
||||||
|
platform: 'linux',
|
||||||
|
arch: 'x64',
|
||||||
|
files,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await verifyDirectory(dir)
|
||||||
|
writeFileSync(join(resources, 'fixtures/video.mp4'), 'altered')
|
||||||
|
await assert.rejects(verifyDirectory(dir), /Hash mismatch/)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user