From 402e212332787c2ff2186048a15aca07389b4103 Mon Sep 17 00:00:00 2001 From: Fedorov Vladimir Date: Sat, 17 Oct 2026 21:21:03 +0700 Subject: [PATCH] up --- .github/workflows/builds.yml | 9 +- README.md | 9 ++ TASKS.md | 27 +++- docs/SCREEN-001_ACCEPTANCE.md | 6 +- docs/SCREEN-008_ACCEPTANCE.md | 141 ++++++++++++++++++++ docs/SCREEN-008_RESULTS.md | 65 +++++++++ electron/diagnostics.test.ts | 45 +++++++ electron/diagnostics.ts | 98 ++++++++++++++ electron/main.ts | 75 +++++++++-- package-lock.json | 4 +- package.json | 18 ++- scripts/packaged-smoke.cjs | 244 ++++++++++++++++++++++++++++++++++ scripts/release-finish.cjs | 74 +++++++++++ scripts/release.cjs | 145 ++++++++++++++++++++ scripts/release.test.cjs | 81 +++++++++++ 15 files changed, 1017 insertions(+), 24 deletions(-) create mode 100644 docs/SCREEN-008_ACCEPTANCE.md create mode 100644 docs/SCREEN-008_RESULTS.md create mode 100644 electron/diagnostics.test.ts create mode 100644 electron/diagnostics.ts create mode 100644 scripts/packaged-smoke.cjs create mode 100644 scripts/release-finish.cjs create mode 100644 scripts/release.cjs create mode 100644 scripts/release.test.cjs diff --git a/.github/workflows/builds.yml b/.github/workflows/builds.yml index 2549035..28b5330 100644 --- a/.github/workflows/builds.yml +++ b/.github/workflows/builds.yml @@ -1,4 +1,4 @@ -name: SCREEN-001 builds +name: SCREEN-008 release candidate on: workflow_dispatch: permissions: @@ -42,7 +42,7 @@ jobs: run: brew install ffmpeg - name: Install ffmpeg on 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 if: runner.os == 'Windows' shell: pwsh @@ -62,10 +62,15 @@ jobs: - name: Package macOS or Windows archive if: runner.os != 'Linux' 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 with: name: screen-${{ matrix.target }}-${{ matrix.arch }} path: | release/*.zip release/*.tar.gz + release/*.build-info.json + release/SHA256SUMS-* if-no-files-found: error diff --git a/README.md b/README.md index a235cf1..6f18576 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,15 @@ macOS Intel/Apple Silicon, Windows x64/ARM64 и Linux x64/ARM64. предотвращение автоматического сна и восстановление пульта. SCREEN-007 вместе с SCREEN-001/SCREEN-004 остаётся IN_PROGRESS до аппаратной приёмки. [Инструкция проверки устойчивости](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` не изменять. diff --git a/TASKS.md b/TASKS.md index 0d1da19..7d4e698 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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. SCREEN-009 (LAN-пульт) отменена владельцем; ID не переиспользовать. Управление телефоном через сервер входит в SCREEN-006. План и общие ограничения: @@ -164,7 +164,7 @@ SCREEN-009 (LAN-пульт) отменена владельцем; ID не пе ## SCREEN-008 — Сборки всех платформ и приёмка -- Статус: `TODO` +- Статус: `IN_PROGRESS` - Зависимость: SCREEN-007. - Цель: получить устанавливаемую первую версию и подтверждённый сценарий площадки. - Объём: воспроизводимые сборки 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 не выполнялись. diff --git a/docs/SCREEN-001_ACCEPTANCE.md b/docs/SCREEN-001_ACCEPTANCE.md index bef7d3c..c1a2a36 100644 --- a/docs/SCREEN-001_ACCEPTANCE.md +++ b/docs/SCREEN-001_ACCEPTANCE.md @@ -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, выполните команды @@ -37,7 +41,7 @@ Gatekeeper и SmartScreen могут предупреждать о неподп CI не заменяет проверку macOS 13, Windows 11 и обоих дистрибутивов Linux. Windows-стенд собирается с signAndEditExecutable=false: без редактирования -иконки/метаданных EXE и без подписи. Брендинг и подписание относятся к SCREEN-008. +иконки/метаданных EXE и без подписи. В SCREEN-008 сохранена эта настройка; доверенная подпись не используется. В workflow выбран ELECTRON_BUILDER_COMPRESSION_LEVEL=1 для быстрой упаковки. Локально при необходимости задайте ту же переменную окружения перед командой сборки (bash: export ELECTRON_BUILDER_COMPRESSION_LEVEL=1; PowerShell: diff --git a/docs/SCREEN-008_ACCEPTANCE.md b/docs/SCREEN-008_ACCEPTANCE.md new file mode 100644 index 0000000..03b67bb --- /dev/null +++ b/docs/SCREEN-008_ACCEPTANCE.md @@ -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 +покрывает весь упакованный код. После повторного чтения самого архива создаются +`.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). diff --git a/docs/SCREEN-008_RESULTS.md b/docs/SCREEN-008_RESULTS.md new file mode 100644 index 0000000..b6b6a5a --- /dev/null +++ b/docs/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 и весь пользовательский профиль. diff --git a/electron/diagnostics.test.ts b/electron/diagnostics.test.ts new file mode 100644 index 0000000..01be09c --- /dev/null +++ b/electron/diagnostics.test.ts @@ -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')) +}) diff --git a/electron/diagnostics.ts b/electron/diagnostics.ts new file mode 100644 index 0000000..a1aeb62 --- /dev/null +++ b/electron/diagnostics.ts @@ -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>> = { + '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 = {}) { + if (this.disabled || !events.includes(event)) return + const entry: Record = { 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') + } + } +} diff --git a/electron/main.ts b/electron/main.ts index 95d5e8a..dfe5cb9 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -13,6 +13,7 @@ import { createReadStream } from 'node:fs' import { stat } from 'node:fs/promises' import { join } from 'node:path' import { Readable } from 'node:stream' +import { DiagnosticLog } from './diagnostics.js' import { Controller, parseCommand } from './controller.js' import { WindowSettings, fitControlBounds, preferredDisplayId } from './window-settings.js' import { parseRange } from './range.js' @@ -43,8 +44,18 @@ let settings: WindowSettings let sleepBlocker: number | null = null let hostNotice = '' let suspended = false +let diagnostics: DiagnosticLog | null = null +const lastErrors = { main: false, music: false, scene: false } let saveTimer: ReturnType | undefined 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]) if (window && !window.isDestroyed()) window.webContents.send('state', state) }) @@ -101,6 +112,23 @@ function closeOutput(reason = '', destroy = true) { if (reason) outputEpoch++ const window = output 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 if (window) closingWindow = window selectedDisplay = null @@ -123,6 +151,7 @@ function saveWindowSettings() { try { settings.save() } catch { + diagnostics?.record('settings-error') hostNotice = 'Не удалось сохранить расположение окон; материалы игры не затронуты' } } @@ -180,6 +209,7 @@ async function show(displayId: number) { saveWindowSettings() secure(window) controller.output(true) + diagnostics?.record('output-open') try { sleepBlocker = powerSaveBlocker.start('prevent-display-sleep') } catch { @@ -241,10 +271,18 @@ app.on('second-instance', () => { app .whenReady() .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')) try { await settings.load() } catch (error) { + diagnostics.record('settings-error') hostNotice = error instanceof Error ? error.message : 'Не удалось восстановить окна' } const packages = new PackageStore(join(app.getPath('userData'), 'packages')) @@ -258,8 +296,14 @@ app decrypt: (value) => safeStorage.decryptString(value), }) 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(() => { - void online?.poll() + void online?.poll().then(logConnection) }, 1000) const timerTick = setInterval(() => controller.tick(), 250) app.on('before-quit', () => { @@ -271,6 +315,7 @@ app clearInterval(onlineTick) online?.close() packages.cancel() + diagnostics?.record('app-stop') }) const handleAppRequest: Parameters[1] = async (request) => { const url = new URL(request.url) @@ -378,7 +423,20 @@ app ipcMain.handle('online-action', async (event, intent: unknown) => { requireControl(event) 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) => { requireControl(event) @@ -484,8 +542,8 @@ app return try { controller.reportScene(report) - } catch (error) { - console.error('Ошибка подтверждения сцены', error) + } catch { + diagnostics?.record('invalid-report') } }) ipcMain.on('report', (event, report: unknown) => { @@ -496,8 +554,8 @@ app return try { controller.report(report) - } catch (error) { - console.error('Ошибка отчёта проигрывателя', error) + } catch { + diagnostics?.record('invalid-report') } }) screen.on('display-removed', (_event, display) => { @@ -526,8 +584,9 @@ app }) await control.loadURL('screen-app://app/index.html') }) - .catch((error) => { - console.error(error) + .catch(() => { + diagnostics?.record('startup-error') + console.error('SCREEN_STARTUP_FAILED') app.quit() }) app.on('window-all-closed', () => app.quit()) diff --git a/package-lock.json b/package-lock.json index d6d5a81..32ca61c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "evening-detective-screen", - "version": "0.1.0", + "version": "0.2.0-rc.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "evening-detective-screen", - "version": "0.1.0", + "version": "0.2.0-rc.1", "dependencies": { "naive-ui": "^2.44.0", "vue": "^3.5.0" diff --git a/package.json b/package.json index 93afd9e..df502f5 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "evening-detective-screen", - "version": "0.1.0", + "version": "0.2.0-rc.1", "private": true, - "description": "Локальный стенд приложения площадки", + "description": "Приложение площадки «Вечерний детектив»", "author": "Вечерний детектив", "main": "dist-electron/electron/main.js", "scripts": { @@ -11,13 +11,15 @@ "lint": "eslint .", "lint:css": "stylelint \"src/**/*.{css,vue}\"", "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", "pack": "npm run build && electron-builder --dir", "dist": "npm run build && electron-builder", "smoke": "npm run build && electron scripts/smoke.cjs", "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": { "vue": "^3.5.0", @@ -64,7 +66,9 @@ "zip" ], "minimumSystemVersion": "13.0", - "category": "public.app-category.entertainment" + "category": "public.app-category.entertainment", + "notarize": false, + "identity": "-" }, "win": { "target": [ @@ -78,7 +82,9 @@ ], "category": "AudioVideo" }, - "artifactName": "${name}-${version}-${os}-${arch}.${ext}" + "artifactName": "${name}-${version}-${os}-${arch}.${ext}", + "afterPack": "scripts/release.cjs", + "afterAllArtifactBuild": "scripts/release-finish.cjs" }, "engines": { "node": ">=24" diff --git a/scripts/packaged-smoke.cjs b/scripts/packaged-smoke.cjs new file mode 100644 index 0000000..88b712b --- /dev/null +++ b/scripts/packaged-smoke.cjs @@ -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 +}) diff --git a/scripts/release-finish.cjs b/scripts/release-finish.cjs new file mode 100644 index 0000000..d46f147 --- /dev/null +++ b/scripts/release-finish.cjs @@ -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 + }) diff --git a/scripts/release.cjs b/scripts/release.cjs new file mode 100644 index 0000000..8fe7cd6 --- /dev/null +++ b/scripts/release.cjs @@ -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, +}) diff --git a/scripts/release.test.cjs b/scripts/release.test.cjs new file mode 100644 index 0000000..9174532 --- /dev/null +++ b/scripts/release.test.cjs @@ -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/) +})