diff --git a/COMPLETED_TASKS.md b/COMPLETED_TASKS.md index 1d4e553..b959bef 100644 --- a/COMPLETED_TASKS.md +++ b/COMPLETED_TASKS.md @@ -66,3 +66,44 @@ UI загрузки/вход остаётся в SCREEN-006; хранилище предоставляет программный API. - Следующий шаг: SCREEN-004 — подключить подготовленные локальные ресурсы к проигрывателю и игровым командам без ожидания сети. + +## SCREEN-005 — Пульт ведущего и все режимы показа + +- Статус: `DONE`. +- Дата: 2026-10-17. +- Репозиторий: только screen; бэкенд, frontend сайта и protobuf не изменены, + новых зависимостей нет. Разработка продолжена по запросу владельца; + аппаратная приёмка SCREEN-004 остаётся открытой. +- Результат: все восемь ScreenMode, локальные обложка/фон, сообщения завершения, + текст без HTML, список шагов с предыдущим/следующим, редакторы черновиков текста + и таймера. Выбор шага не запускает звук/таймер, уход из основного медиарежима + останавливает основной канал; музыка остаётся независимой. Ошибки и кнопки + остаются на пульте. Готовность подтверждается окном после подготовки сцены. +- Таймер: монотонное время в main, точный остаток, пауза/продолжение/сброс, + отсчёт за другой сценой/закрытым окном, сброс при смене пакета. Интервалы и + медиаснимки не накапливают дрейф; окончание не запускает звук или другой режим. +- Предпросмотр: выбранный владельцем вариант реального окна, capturePage до 2 FPS, + ширина до 960 px, один текущий кадр, проверка прав и сессии/ревизии. Нет второго + проигрывателя; при закрытии окна и скрытии/сворачивании пульта захват прекращается. +- Результаты: локальный контракт ResultsSnapshot, whitelist публичных полей, + проверка ключа игры/времени/чисел/повторов, время снимка на пульте, таблица с + прокруткой. Реальное получение/сохранение остаётся в SCREEN-006; без снимка + честное пустое состояние. Смена пакета очищает снимок. +- Проверки: `npm run type-check`, `npm run lint`, `npm run lint:css`, `npm test` + (46 тестов, 9 новых), сборка и GUI smoke, `git diff --check` — PASS. + Проверены все восемь шагов, локальные изображения, кириллица/системный шрифт, + черновик текста/экранирование HTML, таймер за скрытой сценой, права IPC, + предпросмотр (10 захватов, ограничение частоты, без аудио/видео в пульте), + повреждение/восстановление картинки, таблица на 50 синтетических командах и + сохранение её прокрутки при обновлениях. Фикстура результатов существует только + в тестовом renderer; после пересоздания окна в main результатов по-прежнему нет. +- Исправление по GUI: Chromium повторно использовал декодированную картинку после + повреждения файла. Новый адрес изображения на каждую подготовку сцены устранил + ложную готовность; повторный прогон проверил ошибку и восстановление. +- Ограничения: текущий Linux x64/Electron 42.11.15, синтетический внешний дисплей, + тестовые --no-sandbox --mute-audio --disable-gpu. Рабочий сервер, реальные игровые + материалы и самостоятельный аппаратный/ручной проход владельца не проверялись. + macOS/Windows и физический p95 на M1 не подтверждены; архивы не пересобирались. + Инструкция ручной проверки — docs/SCREEN-005_ACCEPTANCE.md, запуск npm run acceptance. +- Следующий шаг: SCREEN-006 — существующая авторизация, выбор игры/скачивание + через интерфейс, снимок результатов и получение команд существующего сервера. diff --git a/README.md b/README.md index ac2ca82..2c916b1 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Вечерний детектив — приложение площадки -Статус: программная часть SCREEN-004 реализована; SCREEN-001 и SCREEN-004 -остаются IN_PROGRESS до аппаратной приёмки. +Статус: SCREEN-005 реализована; SCREEN-001 и SCREEN-004 остаются IN_PROGRESS +до аппаратной приёмки. Режимы показа и пульт работают с локальными пакетами. SCREEN-002 и SCREEN-003 завершены: описание пакета через существующий API, загрузка, проверка, восстановление и локальная выдача файлов. Управление с телефона идёт через существующий сервер сайта; desktop получает @@ -22,7 +22,8 @@ macOS Intel/Apple Silicon, Windows x64/ARM64 и Linux x64/ARM64. общего правила хранения задач в `../docs/agent`. Не дублировать их там. Идентификаторы `SCREEN-*` относятся только к этому приложению. -Текущий этап: SCREEN-004; программная часть готова, аппаратная проверка открыта. +Следующий этап: SCREEN-006 — авторизация, загрузка через интерфейс и серверная +синхронизация; аппаратная проверка SCREEN-004 остаётся открытой. Новые задачи: `Новая задача: <описание>`. Commit и push выполняет только пользователь. Вложенные `.git` не изменять. @@ -44,8 +45,8 @@ npm run dev его и откройте показ. Аудио и видео — синтетические локальные файлы длительностью 60 секунд; бинарники находятся в игнорируемом fixtures/ и не входят в Git. Тестовый режим сохраняет отдельный выбор WAV/видео; игровые пакеты выбираются -в разделе «Материалы показа». Полные игровые -режимы, интерфейс загрузки игр и авторизация пока не реализованы. +в разделе «Материалы показа». Интерфейс загрузки игр и авторизация пока +не реализованы; они относятся к SCREEN-006. Управление телефоном через сервер, подключение игры и авторизация относятся к SCREEN-006 и в техническом стенде ещё не реализованы. Интернет нужен для получения @@ -179,5 +180,51 @@ URL, независимо от исходного имени файла. Лок `dist-electron`, из которого Electron загружает preload при открытии окна. Программные замеры в пульте разделены по каналу и текущему виду медиа; они не измеряют физический звук/первый кадр. Аппаратная приёмка, остальные ОС, -реальные игровые материалы и p95 на M1 пока не подтверждены. Полные визуальные -режимы остаются в SCREEN-005, авторизация и получение серверных команд — в SCREEN-006. +реальные игровые материалы и p95 на M1 пока не подтверждены. Все восемь визуальных режимов доступны в SCREEN-005; авторизация и получение +серверных команд остаются в SCREEN-006. + +## Пульт и режимы SCREEN-005 + +Пульт включает готовый пакет, план шагов, переключатель восьми режимов, текст, +таймер, предпросмотр и прежние независимые каналы медиа/музыки. План из пакета +доступен только для чтения. Выбор шага применяет его параметры без автоматического +запуска звука/таймера; окончание медиа не переключает шаг. Пуск основного ресурса +показывает соответствующий режим вступления или видео. Уход в другую сцену +останавливает основной канал, сохраняя независимую музыку. + +Текст и параметры таймера редактируются как черновик; только «Показать текст» / +«Показать таймер» публикуют их. Текст отображается как обычный текст, без HTML. +Таймер вычисляется в основном процессе по монотонному deadline; интервалы только +обновляют снимок. Пауза сохраняет точный остаток, сброс возвращает полную длительность. +Таймер продолжает идти за другой сценой и при закрытом окне; смена пакета сбрасывает +его. По окончании остаётся 00:00, без сигнала и смены режима. Восстановление после +сна/перезапуска относится к SCREEN-007. + +Обложка и фон читаются через локальный протокол. Новая подготовка использует +адрес с идентификатором сцены, исключая устаревший декодированный кеш изображения. +Окно подтверждает готовность по загрузке изображений/медиа; запоздалые подтверждения +прошлой сцены/сессии игнорируются. Сообщения об ошибках доступны только пульту. +На проекторе остаются материалы игры и предусмотренные состояния отсутствия +необязательного вступления/видео/результатов, без кнопок и диагностических текстов. + +Предпросмотр — кадры настоящего окна через capturePage, максимум два в секунду, +шириной не более 960 px. В пульте нет второго audio/video. Захват недоступен окну +показа, при закрытом проекторе/скрытом или свёрнутом пульте прекращается; старые +кадры другой сцены/сессии не принимаются. Хранится только последний кадр. +Системные шрифты с кириллицей не требуют сети. + +`Controller.setResults(snapshot)` принимает снимок текущей игры: packageKey, +capturedAt, вопросы и публичные строки таблицы (ID/название команды, место, +сумма, ходы и баллы ответов). Лишние поля отбрасываются, другой ключ игры, +повторные команды/вопросы и некорректные значения отклоняются. На пульте указан +момент снимка. При его отсутствии показывается «Результаты пока не загружены»; +фиктивные результаты в приложении не создаются. Длинная таблица прокручивается +снизу вверх, обновления позиций медиа её не перезапускают. Сетевое получение и +сохранение снимка реализуются в SCREEN-006; публичного IPC редактирования результатов нет. + +`npm run acceptance` теперь создаёт локальный пакет с обложкой, фоном и восемью +шагами; [инструкция SCREEN-005](docs/SCREEN-005_ACCEPTANCE.md) описывает полный +офлайн-проход. Автоматический smoke проверяет также повреждение/восстановление +изображения, отсутствие второго звука, предел частоты кадров предпросмотра, +кириллицу и отрисовку/прокрутку 50 синтетических строк результатов. Эти строки +подаются только в тестовый renderer и не сохраняются в состоянии приложения. diff --git a/TASKS.md b/TASKS.md index 777a9f4..8af5324 100644 --- a/TASKS.md +++ b/TASKS.md @@ -1,7 +1,7 @@ # Задачи приложения площадки -SCREEN-001 и SCREEN-004 имеют статус IN_PROGRESS; SCREEN-002 и SCREEN-003 завершены -и перенесены в архив; остальные активные задачи — TODO. +SCREEN-001 и SCREEN-004 имеют статус IN_PROGRESS; SCREEN-002, SCREEN-003 и SCREEN-005 +завершены и перенесены в архив; остальные активные задачи — TODO. SCREEN-009 (LAN-пульт) отменена владельцем; ID не переиспользовать. Управление телефоном через сервер входит в SCREEN-006. План и общие ограничения: [PLAN.md](PLAN.md). Идентификаторы независимы от ED-* в корневом списке. @@ -109,27 +109,10 @@ SCREEN-009 (LAN-пульт) отменена владельцем; ID не пе docs/SCREEN-004_ACCEPTANCE.md на всей матрице, получить результаты владельца. До их получения статус остаётся IN_PROGRESS; перенос в архив не выполняется. -## SCREEN-005 — Пульт ведущего и все режимы показа - -- Статус: `TODO` -- Зависимость: SCREEN-004. -- Цель: провести показ выбранной игры с ноутбука без телефона. -- Объём: выбор подготовленного пакета, план шагов, предпросмотр без второго звука, - обложка, фон, вступление, закрывающее видео, текст, таймер, результаты и музыка; - фактические статусы загрузки, подготовки и воспроизведения на пульте. -- Готовность: все текущие режимы ScreenMode доступны; таймер работает локально - без накопления дрейфа от интервалов; пульт и технические сообщения не попадают - на проектор; неготовый файл нельзя ошибочно запустить как готовый. -- Проверка: проверки приложения и ручной проход полного плана без интернета; - предпросмотр не дублирует звук; кириллица, шрифты и изображения доступны офлайн. -- Не входит: редактор сценариев и полное управление командами/регистрациями игры. -- Следующий шаг: сопоставить текущие компоненты и callers с экраном пульта; - переиспользовать подходящее без преждевременного общего пакета. - ## SCREEN-006 — Подключение игры и синхронизация с сервером - Статус: `TODO` -- Зависимости: SCREEN-002, SCREEN-005. +- Зависимости: SCREEN-002, SCREEN-005 (DONE, см. COMPLETED_TASKS.md). - Цель: выбирать доступную организатору игру онлайн и сохранять автономность показа. - Объём: существующая авторизация, список игр, загрузка пакета, снимок результатов, индикатор связи, проверка новой версии материалов, синхронизация статуса показа. @@ -148,7 +131,10 @@ SCREEN-009 (LAN-пульт) отменена владельцем; ID не пе затронутых репозиториев; существующий браузерный экран продолжает работать. - Не входит: полноценная офлайн-игра, LAN-сервер и отдельный мобильный пульт desktop. - Следующий шаг: сверить auth flow и модель сессий; зафиксировать правила - локального владения показом и отображения статуса на сервере. + локального владения показом и отображения статуса на сервере. Для результатов + использовать Controller.setResults с ResultsSnapshot (только поля таблицы, + packageKey и capturedAt); получение и сохранение снимка реализовать здесь. + Не менять бэкенд/protobuf без нового прямого запроса владельца. ## SCREEN-007 — Устойчивость на площадке diff --git a/docs/SCREEN-005_ACCEPTANCE.md b/docs/SCREEN-005_ACCEPTANCE.md new file mode 100644 index 0000000..34e56cb --- /dev/null +++ b/docs/SCREEN-005_ACCEPTANCE.md @@ -0,0 +1,46 @@ +# SCREEN-005 — полный офлайн-проход показа + +Программная часть и GUI smoke проверены на Linux x64. Аппаратная приёмка +SCREEN-001/SCREEN-004 остаётся открытой; эта инструкция не отмечает её пройденной. + +## Подготовка + +1. Установите зависимости и создайте fixtures командой `npm run media:generate`. +2. Подключите настоящий внешний дисплей/проектор в режиме расширения, запустите + `npm run acceptance`. Команда создаёт отдельный временный userData и локальный + пакет «Offline smoke» с изображениями, аудио, видео, музыкой и восемью шагами. +3. Выберите этот пакет в пульте, внешний дисплей и «Открыть показ». Затем отключите + сеть. Пульт должен оставаться на основном экране; на проекторе — только материалы. +4. Для проверки физического звука не используйте --mute-audio. Установочные архивы + не пересобирались; команда проверяет текущий исходный код. + +## Полный план + +- Пройдите восемь шагов по порядку и назад: обложка, вступление, «Успехов», + заключительное видео, «Спасибо за игру!», результаты, таймер и текст. +- Убедитесь, что шаги не запускают звук или таймер сами. Запустите вступление/видео + явной кнопкой, переключитесь в другой режим: основное медиа останавливается. +- Включите музыку и переключайте сцены: музыка остаётся независимой. Предпросмотр + повторяет реальное окно с небольшой задержкой, второго звука с пульта нет. + При закрытом окне предпросмотр недоступен; скрытый/свёрнутый пульт не захватывает кадры. +- Проверьте обложку, затемнённый фон и кириллицу без интернета. На проекторе нет + кнопок, сообщений пульта, диагностических статусов и исходных путей файлов. +- Введите текст с переносами строк, кириллицей и ``. + До «Показать текст» сцена не меняется; после применения это обычный текст, + без выполнения HTML/JavaScript. +- Настройте таймер на 10 секунд. «Показать таймер» применяет параметры без старта. + Проверьте старт, паузу, ожидание на паузе, продолжение и сброс. При переходе + к обложке отсчёт продолжается; при возвращении виден актуальный остаток. + В конце остаётся 00:00, без сигнала и автоматической смены режима. +- Закройте/откройте окно показа при работающем таймере: отсчёт продолжается, + медиа и музыка самостоятельно не запускаются. Смена пакета сбрасывает таймер. +- В режиме результатов без снимка видно «Результаты пока не загружены». + Настоящее получение снимков относится к SCREEN-006. Автоматический smoke отдельно + проверяет таблицу и прокрутку на тестовых данных, не сохраняя их в приложении. + +## Результат проверки + +Запишите ОС/архитектуру, версию Electron, дисплей/переходник/аудиовыход, успешность +каждого сценария, ошибки и скриншоты при необходимости. Для физической задержки +и всей аппаратной матрицы используйте [SCREEN-004_ACCEPTANCE.md](SCREEN-004_ACCEPTANCE.md). +Проверки сборки/test/smoke запускайте последовательно: они используют один dist-electron. diff --git a/electron/controller.ts b/electron/controller.ts index cf06b32..040c071 100644 --- a/electron/controller.ts +++ b/electron/controller.ts @@ -8,7 +8,10 @@ import type { Intent, Report, State, + ScreenMode, + TimerState, } from '../src/types/screen.js' +import { parseResults } from './results.js' import type { LocalPackage } from './package-store.js' function number(value: unknown, min: number, max = Number.MAX_SAFE_INTEGER): value is number { @@ -27,6 +30,34 @@ export function parseCommand(value: unknown): Command { let intent: Intent if (c.action === 'mute' && typeof c.enabled === 'boolean') intent = { action: 'mute', enabled: c.enabled } + else if (c.action === 'mode' && Number.isInteger(c.mode) && number(c.mode, 1, 8)) + intent = { action: 'mode', mode: c.mode as ScreenMode } + else if (c.action === 'step' && Number.isSafeInteger(c.index) && number(c.index, 0)) + intent = { action: 'step', index: c.index } + else if ( + c.action === 'text' && + typeof c.text === 'string' && + c.text.trim() && + c.text.length <= 10000 + ) + intent = { action: 'text', text: c.text.trim() } + else if ( + c.action === 'timer-set' && + typeof c.label === 'string' && + c.label.length <= 200 && + Number.isInteger(c.durationSeconds) && + number(c.durationSeconds, 1, 86400) + ) + intent = { + action: 'timer-set', + label: c.label.trim() || 'Перерыв', + durationSeconds: c.durationSeconds, + } + else if ( + typeof c.action === 'string' && + ['timer-start', 'timer-pause', 'timer-resume', 'timer-reset'].includes(c.action) + ) + intent = { action: c.action as 'timer-start' | 'timer-pause' | 'timer-resume' | 'timer-reset' } else { if (c.channel !== 'main' && c.channel !== 'music') throw new Error('Некорректный канал') const channel = c.channel @@ -84,6 +115,9 @@ const fixtureCatalog = (): Catalog => ({ key: null, name: 'Тестовый режим SCREEN-001', playlists: [], + cover: null, + background: null, + steps: [], media: [ { id: 'fixture-audio', @@ -115,6 +149,15 @@ function initialChannel(): ChannelState { loop: false, } } +function initialTimer(): TimerState { + return { + revision: 0, + label: 'Перерыв', + durationMs: 300000, + remainingMs: 300000, + status: 'stopped', + } +} export class Controller { state: State = { session: randomUUID(), @@ -123,11 +166,18 @@ export class Controller { outputOpen: false, catalog: fixtureCatalog(), muted: false, + scene: { revision: 0, mode: 1, stepIndex: null, text: '', status: 'idle', error: '' }, + timer: initialTimer(), + results: null, channels: { main: initialChannel(), music: initialChannel() }, samples: [], } private measured = new Map() - constructor(private publish: (state: State) => void) { + private timerDeadline: number | null = null + constructor( + private publish: (state: State) => void, + private now: () => number = () => performance.now(), + ) { this.state.channels.main.source = this.state.catalog.media[0]! } private broadcast() { @@ -146,8 +196,10 @@ export class Controller { this.measured.delete(channel) } private newSession() { + this.updateTimer() this.state.session = randomUUID() this.state.sequence = 0 + this.invalidateScene() for (const channel of ['main', 'music'] as const) this.change(channel, { desired: 'stopped', @@ -158,6 +210,7 @@ export class Controller { output(open: boolean) { this.newSession() this.state.outputOpen = open + this.state.scene.status = open ? 'pending' : 'idle' if (!open) for (const c of Object.values(this.state.channels)) c.status = c.source ? 'stopped' : 'idle' this.broadcast() @@ -182,6 +235,13 @@ export class Controller { key: pkg.key, name: pkg.package.game.name, media, + cover: pkg.package.media.cover + ? `screen-app://app/packages/${pkg.key}/${pkg.package.media.cover}` + : null, + background: pkg.package.media.background + ? `screen-app://app/packages/${pkg.key}/${pkg.package.media.background}` + : null, + steps: pkg.package.steps.map((step) => ({ ...step, mode: step.mode as ScreenMode })), playlists: pkg.package.playlists.map((p) => ({ id: p.id, name: p.name, @@ -194,6 +254,17 @@ export class Controller { } } this.state.catalog = catalog + this.state.scene = { + revision: this.state.scene.revision, + mode: 1, + stepIndex: null, + text: '', + status: 'idle', + error: '', + } + this.state.timer = initialTimer() + this.timerDeadline = null + this.state.results = null this.state.channels = { main: { ...initialChannel(), source: catalog.media[0] ?? null }, music: initialChannel(), @@ -214,6 +285,134 @@ export class Controller { this.newSession() this.broadcast() } + private invalidateScene() { + this.state.scene.revision++ + this.state.scene.status = this.state.outputOpen ? 'pending' : 'idle' + this.state.scene.error = '' + } + private mode(mode: ScreenMode, stepIndex: number | null = null, text = this.state.scene.text) { + this.state.scene = { ...this.state.scene, mode, stepIndex, text } + const c = this.state.channels.main + const kind = mode === 2 ? 'audio' : mode === 4 ? 'video' : null + const source = kind ? (this.state.catalog.media.find((s) => s.kind === kind) ?? null) : c.source + this.change('main', { + source, + desired: 'stopped', + position: 0, + duration: source?.id === c.source?.id ? c.duration : null, + status: source ? 'pending' : 'idle', + }) + this.invalidateScene() + } + private configureTimer(label: string, seconds: number) { + this.timerDeadline = null + this.state.timer = { + revision: this.state.timer.revision + 1, + label, + durationMs: seconds * 1000, + remainingMs: seconds * 1000, + status: 'stopped', + } + } + private updateTimer() { + if (this.timerDeadline === null) return false + const t = this.state.timer + const remaining = Math.max(0, this.timerDeadline - this.now()) + if (remaining === t.remainingMs) return false + t.remainingMs = remaining + t.revision++ + if (!remaining) { + t.status = 'finished' + this.timerDeadline = null + } + return true + } + tick() { + if (this.updateTimer()) this.broadcast() + } + setResults(value: unknown) { + const key = this.state.catalog.key + if (value !== null && !key) throw new Error('Сначала выберите пакет игры') + const snapshot = value === null ? null : parseResults(value, key!) + this.state.results = snapshot + if (this.state.scene.mode === 6) this.invalidateScene() + this.broadcast() + } + reportScene(value: unknown) { + if (!value || typeof value !== 'object') throw new Error('Некорректное подтверждение сцены') + const r = value as Record + if ( + typeof r.session !== 'string' || + !Number.isSafeInteger(r.revision) || + (r.status !== 'ready' && r.status !== 'error') || + (r.error !== undefined && typeof r.error !== 'string') + ) + throw new Error('Некорректное подтверждение сцены') + if ( + !this.state.outputOpen || + r.session !== this.state.session || + r.revision !== this.state.scene.revision + ) + return + const mediaError = [2, 4].includes(this.state.scene.mode) ? this.state.channels.main.error : '' + const status = mediaError ? 'error' : r.status + const error = String(mediaError || r.error || '').slice(0, 500) + if (this.state.scene.status === status && this.state.scene.error === error) return + this.state.scene.status = status + this.state.scene.error = error + this.broadcast() + } + private presentation(command: Command): boolean { + switch (command.action) { + case 'mode': + this.mode(command.mode) + return true + case 'text': + this.mode(8, null, command.text) + return true + case 'step': { + const step = this.state.catalog.steps[command.index] + if (!step) throw new Error('Шаг плана не найден') + if (step.mode === 7) + this.configureTimer(step.timerLabel || 'Перерыв', step.timerDurationSeconds) + this.mode(step.mode, command.index, step.text) + return true + } + case 'timer-set': + this.configureTimer(command.label, command.durationSeconds) + this.mode(7) + return true + case 'timer-start': + case 'timer-pause': + case 'timer-resume': + case 'timer-reset': { + this.updateTimer() + const t = this.state.timer + if (command.action === 'timer-reset') { + this.configureTimer(t.label, t.durationMs / 1000) + return true + } + if (command.action === 'timer-pause') { + if (t.status === 'running') { + this.timerDeadline = null + t.status = 'paused' + t.revision++ + } + } else if ( + t.status !== 'running' && + (command.action === 'timer-start' || t.status === 'paused') + ) { + if (t.remainingMs <= 0) t.remainingMs = t.durationMs + t.status = 'running' + t.revision++ + this.timerDeadline = this.now() + t.remainingMs + } + return true + } + default: + return false + } + } private track(index: number) { const c = this.state.channels.music const track = this.state.catalog.playlists.find((p) => p.id === c.playlistId)?.tracks[index] @@ -243,13 +442,16 @@ export class Controller { const command = parseCommand(input) if (command.session !== this.state.session || command.sequence <= this.state.sequence) return if (command.action === 'mute') this.state.muted = command.enabled - else { + else if (this.presentation(command)) { + /* Режимы и таймер не зависят от транспортных каналов. */ + } else if ('channel' in command) { const channel = command.channel - const c = this.state.channels[channel] + let c = this.state.channels[channel] switch (command.action) { case 'select': { const source = this.state.catalog.media.find((s) => s.id === command.sourceId) if (!source) throw new Error('Ресурс не найден в выбранном пакете') + this.mode(source.kind === 'audio' ? 2 : 4) this.change(channel, { source, desired: 'stopped', @@ -287,6 +489,14 @@ export class Controller { break default: { if (!c.source) throw new Error('Для канала не выбран ресурс') + if (channel === 'main' && ['play', 'resume', 'replay'].includes(command.action)) { + const mode = c.source.kind === 'audio' ? 2 : 4 + if (this.state.scene.status === 'error') this.invalidateScene() + if (this.state.scene.mode !== mode) { + this.mode(mode) + c = this.state.channels[channel] + } + } const patch: Partial = { status: 'pending' } if (command.action === 'play' || command.action === 'replay') { patch.position = 0 @@ -361,6 +571,10 @@ export class Controller { } if (report.status === 'error') { c.desired = 'paused' + if (report.channel === 'main' && [2, 4].includes(this.state.scene.mode)) { + this.state.scene.status = 'error' + this.state.scene.error = c.error + } this.measured.delete(report.channel) } if (report.status === 'ended') { diff --git a/electron/main.ts b/electron/main.ts index 178cd65..ed97627 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -98,7 +98,11 @@ app .then(async () => { const packages = new PackageStore(join(app.getPath('userData'), 'packages')) await packages.initialize() - app.on('before-quit', () => packages.cancel()) + const timerTick = setInterval(() => controller.tick(), 250) + app.on('before-quit', () => { + clearInterval(timerTick) + packages.cancel() + }) session.defaultSession.setPermissionRequestHandler((_contents, _permission, callback) => callback(false), ) @@ -107,7 +111,7 @@ app const url = new URL(request.url) const headers = { 'Content-Security-Policy': - "default-src 'self' screen-app:; script-src 'self'; style-src 'self' 'unsafe-inline'; media-src screen-app:; connect-src 'self'; frame-ancestors 'none'", + "default-src 'self' screen-app:; script-src 'self'; style-src 'self' 'unsafe-inline'; media-src screen-app:; img-src 'self' screen-app: data:; connect-src 'self'; frame-ancestors 'none'", 'X-Content-Type-Options': 'nosniff', } if (url.host !== 'app' || request.method !== 'GET') return new Response(null, { status: 404 }) @@ -220,6 +224,61 @@ app throw new Error('Сначала откройте окно показа') controller.command(parsed) }) + let capturing = false + let lastCapture = -Infinity + ipcMain.handle('preview', async (event) => { + requireControl(event) + const window = output + const state = controller.state + if ( + !window || + window.isDestroyed() || + window.webContents.isLoading() || + !control?.isVisible() || + control.isMinimized() || + state.scene.status !== 'ready' || + capturing || + performance.now() - lastCapture < 500 + ) + return null + const session = state.session + const revision = state.scene.revision + capturing = true + lastCapture = performance.now() + try { + const image = await window.webContents.capturePage() + if ( + window !== output || + window.isDestroyed() || + session !== controller.state.session || + revision !== controller.state.scene.revision || + controller.state.scene.status !== 'ready' || + image.isEmpty() + ) + return null + return { + session, + revision, + dataURL: image.resize({ width: Math.min(960, image.getSize().width) }).toDataURL(), + } + } catch { + return null + } finally { + capturing = false + } + }) + ipcMain.on('scene-report', (event, report: unknown) => { + if ( + event.sender !== output?.webContents || + event.senderFrame !== output.webContents.mainFrame + ) + return + try { + controller.reportScene(report) + } catch (error) { + console.error('Ошибка подтверждения сцены', error) + } + }) ipcMain.on('report', (event, report: unknown) => { if ( event.sender !== output?.webContents || diff --git a/electron/preload.ts b/electron/preload.ts index a155c71..fec0d2c 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -1,9 +1,11 @@ import { contextBridge, ipcRenderer } from 'electron' -import type { Command, Report, State } from '../src/types/screen.js' +import type { Command, Report, SceneReport, State } from '../src/types/screen.js' contextBridge.exposeInMainWorld('screenBridge', { command: (command: Command) => ipcRenderer.invoke('command', command), report: (report: Report) => ipcRenderer.send('report', report), + reportScene: (report: SceneReport) => ipcRenderer.send('scene-report', report), + preview: () => ipcRenderer.invoke('preview'), state: () => ipcRenderer.invoke('state'), subscribe: (listener: (state: State) => void) => { const handler = (_event: Electron.IpcRendererEvent, state: State) => listener(state) diff --git a/electron/presentation.test.ts b/electron/presentation.test.ts new file mode 100644 index 0000000..4766a30 --- /dev/null +++ b/electron/presentation.test.ts @@ -0,0 +1,284 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { Controller, parseCommand } from './controller.js' +import { buildScreenPackage } from './screen-package.js' +import { packageKey, type LocalPackage } from './package-store.js' +import { parseResults } from './results.js' +import { formatTimer, remainingTime } from '../src/utils/presentation.js' +import type { Intent, ScreenMode } from '../src/types/screen.js' + +function fixture() { + let now = 0 + const c = new Controller( + () => {}, + () => now, + ) + const pkg = buildScreenPackage('https://example.test', 7, { + game: { + game: { + ID: 7, + name: 'Дело №7', + scenario: { + image: 'cover.png', + story: { + background: 'background.png', + introduction: { audio: 'intro.wav' }, + conclusion: { video: 'ending.mp4' }, + }, + }, + }, + }, + plan: { + plan: { + steps: Array.from({ length: 8 }, (_, i) => ({ + mode: i + 1, + text: i === 7 ? 'Текст шага\nКириллица' : '', + timerLabel: 'Перерыв', + timerDurationSeconds: i === 6 ? '10' : '0', + })), + }, + }, + music: { + gamePlaylistId: 1, + playlists: [ + { + ID: 1, + name: 'Музыка', + tracks: [{ ID: 1, name: 'Трек', filename: 'one.wav', position: 0 }], + }, + ], + }, + }) + const local: LocalPackage = { + formatVersion: 1, + key: packageKey(pkg.server, 7), + version: 'a'.repeat(64), + package: pkg, + files: [], + } + c.selectPackage(local) + c.output(true) + const send = (intent: Intent) => + c.command({ session: c.state.session, sequence: c.state.sequence + 1, ...intent }) + return { + c, + send, + key: local.key, + advance: (ms: number) => { + now += ms + }, + } +} + +test('проходит восемь шагов, подставляет локальные изображения, не запускает звук/таймер', () => { + const f = fixture() + assert.match( + f.c.state.catalog.cover!, + /^screen-app:\/\/app\/packages\/[a-f0-9]{64}\/[a-f0-9]{64}$/, + ) + assert.notEqual(f.c.state.catalog.cover, f.c.state.catalog.background) + for (let i = 0; i < 8; i++) { + f.send({ action: 'step', index: i }) + assert.equal(f.c.state.scene.mode, i + 1) + assert.equal(f.c.state.scene.stepIndex, i) + assert.equal(f.c.state.channels.main.desired, 'stopped') + assert.equal(f.c.state.timer.status, 'stopped') + } + assert.equal(f.c.state.scene.text, 'Текст шага\nКириллица') + assert.equal(f.c.state.timer.remainingMs, 10000) + assert.throws(() => f.send({ action: 'step', index: 8 }), /Шаг/) +}) +test('смена сцены останавливает основное медиа, музыка независима, автоматического шага нет', () => { + const f = fixture() + f.send({ action: 'play', channel: 'music' }) + f.send({ action: 'step', index: 1 }) + f.send({ action: 'play', channel: 'main' }) + const c = f.c.state.channels.main + f.c.report({ + session: f.c.state.session, + channel: 'main', + operation: c.operation, + status: 'ended', + position: 60, + duration: 60, + }) + assert.equal(f.c.state.scene.stepIndex, 1) + assert.equal(f.c.state.scene.mode, 2) + f.send({ action: 'mode', mode: 3 }) + assert.equal(f.c.state.channels.main.desired, 'stopped') + assert.equal(f.c.state.channels.music.desired, 'playing') + assert.equal(f.c.state.scene.stepIndex, null) +}) +test('таймер не накапливает дрейф при редких tick, переживает смену сцены и окна', () => { + const f = fixture() + f.send({ action: 'timer-set', label: '', durationSeconds: 10 }) + assert.equal(f.c.state.timer.label, 'Перерыв') + assert.equal(f.c.state.timer.status, 'stopped') + f.send({ action: 'timer-start' }) + f.advance(3250) + f.c.tick() + assert.equal(f.c.state.timer.remainingMs, 6750) + f.send({ action: 'mode', mode: 8 }) + f.advance(1500) + f.c.output(false) + f.c.output(true) + assert.equal(f.c.state.timer.remainingMs, 5250) + f.advance(5250) + f.c.tick() + assert.equal(f.c.state.timer.status, 'finished') + assert.equal(f.c.state.timer.remainingMs, 0) + assert.equal(f.c.state.scene.mode, 8) + assert.equal(f.c.state.channels.main.desired, 'stopped') + f.advance(99999) + f.c.tick() + assert.equal(f.c.state.timer.remainingMs, 0) +}) +test('пауза/продолжение/сброс таймера используют точный остаток, шаг таймера сбрасывает его', () => { + const f = fixture() + f.send({ action: 'step', index: 6 }) + f.send({ action: 'timer-start' }) + f.advance(1234) + f.send({ action: 'timer-pause' }) + assert.equal(f.c.state.timer.remainingMs, 8766) + f.advance(5000) + f.c.tick() + assert.equal(f.c.state.timer.remainingMs, 8766) + f.send({ action: 'timer-resume' }) + f.advance(1000) + f.c.tick() + assert.equal(f.c.state.timer.remainingMs, 7766) + f.send({ action: 'timer-reset' }) + assert.equal(f.c.state.timer.remainingMs, 10000) + assert.equal(f.c.state.timer.status, 'stopped') + f.send({ action: 'timer-start' }) + f.advance(100) + f.send({ action: 'step', index: 6 }) + assert.equal(f.c.state.timer.remainingMs, 10000) + assert.equal(f.c.state.timer.status, 'stopped') + f.c.selectPackage(null) + assert.equal(f.c.state.timer.status, 'stopped') + assert.equal(f.c.state.timer.remainingMs, 300000) +}) +test('формат таймера и интерполяция не зависят от числа обновлений', () => { + assert.equal(formatTimer(60000), '01:00') + assert.equal(formatTimer(1), '00:01') + assert.equal(formatTimer(-5), '00:00') + const f = fixture() + f.send({ action: 'timer-set', label: 'Перерыв', durationSeconds: 10 }) + f.send({ action: 'timer-start' }) + assert.equal(remainingTime(f.c.state.timer, 11000), 0) + assert.equal(remainingTime(f.c.state.timer, 1234), 8766) + f.send({ action: 'timer-pause' }) + assert.equal(remainingTime(f.c.state.timer, 1234), 10000) +}) +test('подтверждение сцены отклоняет старую ревизию и сессию, ошибка не попадает в текст показа', () => { + const f = fixture() + const old = { session: f.c.state.session, revision: f.c.state.scene.revision, status: 'ready' } + f.send({ action: 'text', text: ' Привет\n ' }) + f.c.reportScene(old) + assert.equal(f.c.state.scene.status, 'pending') + assert.equal(f.c.state.scene.text, 'Привет\n') + const report = { + session: f.c.state.session, + revision: f.c.state.scene.revision, + status: 'error', + error: 'Нет изображения', + } + f.c.reportScene(report) + assert.equal(f.c.state.scene.error, 'Нет изображения') + assert.ok(!f.c.state.scene.text.includes('Нет изображения')) + f.c.output(false) + f.c.output(true) + f.c.reportScene(report) + assert.equal(f.c.state.scene.status, 'pending') + f.c.reportScene({ + session: f.c.state.session, + revision: f.c.state.scene.revision, + status: 'ready', + }) + assert.equal(f.c.state.scene.status, 'ready') + assert.throws(() => f.c.reportScene({ ...old, status: 'fake' })) +}) +test('не допускает пустой текст и некорректные параметры таймера/режима на IPC', () => { + const f = fixture() + const base = { session: f.c.state.session, sequence: 1 } + for (const intent of [ + { action: 'text', text: '' }, + { action: 'text', text: ' '.repeat(3) }, + { action: 'text', text: 'x'.repeat(10001) }, + { action: 'timer-set', label: '', durationSeconds: 0 }, + { action: 'timer-set', label: '', durationSeconds: 1.5 }, + { action: 'timer-set', label: 'x'.repeat(201), durationSeconds: 10 }, + { action: 'mode', mode: 9 }, + { action: 'mode', mode: NaN }, + { action: 'step', index: -1 }, + { action: ['timer-start'] }, + ]) + assert.throws(() => parseCommand({ ...base, ...intent })) + for (const mode of [1, 2, 3, 4, 5, 6, 7, 8] as ScreenMode[]) + assert.equal(parseCommand({ ...base, action: 'mode', mode }).action, 'mode') +}) +function snapshot(key: string) { + return { + packageKey: key, + capturedAt: '2026-10-17T10:00:00Z', + questions: [{ code: 'К1', text: 'Вопрос' }], + teams: [ + { + id: 'team', + name: 'Детективы', + place: 1, + totalScore: -2, + actionsCount: 4, + answers: [{ questionCode: 'К1', score: -2 }], + password: 'SECRET', + participants: ['SECRET'], + }, + ], + token: 'SECRET', + } +} +test('снимок результатов ограничен текущей игрой, содержит только публичные поля и время', () => { + const f = fixture() + const data = snapshot(f.key) + f.send({ action: 'mode', mode: 6 }) + const revision = f.c.state.scene.revision + f.c.setResults(data) + assert.equal(f.c.state.scene.revision, revision + 1) + assert.equal(f.c.state.results!.teams[0]!.totalScore, -2) + assert.equal(f.c.state.results!.capturedAt, '2026-10-17T10:00:00.000Z') + assert.ok(!JSON.stringify(f.c.state.results).includes('SECRET')) + assert.throws(() => f.c.setResults(snapshot('a'.repeat(64))), /другой игре/) + f.c.selectPackage(null) + assert.equal(f.c.state.results, null) +}) +test('не принимает повреждённые снимки, повторные команды и неизвестные коды ответов', () => { + const f = fixture() + for (const mutate of [ + (d: ReturnType) => { + d.capturedAt = 'invalid' + }, + (d: ReturnType) => { + d.teams[0]!.totalScore = NaN + }, + (d: ReturnType) => { + d.teams[0]!.answers[0]!.questionCode = 'unknown' + }, + (d: ReturnType) => { + d.teams.push(d.teams[0]!) + }, + (d: ReturnType) => { + d.questions.push(d.questions[0]!) + }, + ]) { + const data = snapshot(f.key) + mutate(data) + assert.throws(() => parseResults(data, f.key)) + } + const command = { session: f.c.state.session, sequence: 1, action: 'timer-start' } + f.c.command(command) + f.advance(500) + f.c.command(command) + f.c.tick() + assert.equal(f.c.state.timer.remainingMs, 299500) +}) diff --git a/electron/results.ts b/electron/results.ts new file mode 100644 index 0000000..d1005f6 --- /dev/null +++ b/electron/results.ts @@ -0,0 +1,58 @@ +import type { ResultsSnapshot } from '../src/types/screen.js' + +function object(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw new Error('Некорректный снимок результатов') + return value as Record +} +function list(value: unknown): unknown[] { + if (!Array.isArray(value)) throw new Error('Некорректный список результатов') + return value +} +function text(value: unknown): string { + if (typeof value !== 'string') throw new Error('Некорректный текст результатов') + return value +} +function integer(value: unknown, minimum = -2147483648): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < minimum) + throw new Error('Некорректное число результатов') + return value +} +// Сохраняем только поля публичной таблицы, без участников, паролей и токенов. +export function parseResults(value: unknown, packageKey: string): ResultsSnapshot { + const data = object(value) + if (data.packageKey !== packageKey || !/^[a-f0-9]{64}$/.test(packageKey)) + throw new Error('Результаты относятся к другой игре') + const captured = Date.parse(text(data.capturedAt)) + if (!Number.isFinite(captured)) throw new Error('Некорректное время снимка результатов') + const questions = list(data.questions).map((value) => { + const q = object(value) + return { code: text(q.code), text: text(q.text) } + }) + const codes = new Set(questions.map((q) => q.code)) + if (codes.size !== questions.length || codes.has('')) + throw new Error('Повтор или отсутствие кода вопроса') + const teams = list(data.teams).map((value) => { + const team = object(value) + const answers = list(team.answers).map((value) => { + const a = object(value) + return { questionCode: text(a.questionCode), score: integer(a.score) } + }) + if ( + new Set(answers.map((a) => a.questionCode)).size !== answers.length || + answers.some((a) => !codes.has(a.questionCode)) + ) + throw new Error('Некорректные ответы результатов') + return { + id: text(team.id), + name: text(team.name), + place: integer(team.place, 1), + totalScore: integer(team.totalScore), + actionsCount: integer(team.actionsCount, 0), + answers, + } + }) + if (teams.some((team) => !team.id) || new Set(teams.map((t) => t.id)).size !== teams.length) + throw new Error('Повтор или отсутствие команды') + return { packageKey, capturedAt: new Date(captured).toISOString(), questions, teams } +} diff --git a/scripts/smoke.cjs b/scripts/smoke.cjs index 03533e4..79a512e 100644 --- a/scripts/smoke.cjs +++ b/scripts/smoke.cjs @@ -1,7 +1,7 @@ const assert = require('node:assert/strict') -const { app, BrowserWindow, screen } = require('electron') +const { app, BrowserWindow, screen, nativeImage } = require('electron') const { join, resolve } = require('node:path') -const { mkdtempSync, readFileSync } = require('node:fs') +const { mkdtempSync, readFileSync, writeFileSync } = require('node:fs') const root = resolve(__dirname, '..') app.setAppPath(root) app.setPath('userData', mkdtempSync(join(require('node:os').tmpdir(), 'screen-smoke-'))) @@ -11,7 +11,13 @@ let targetDisplay let isSyntheticDisplay = false const boundsBeforeFullscreen = new Map() const fullscreenEntered = new Set() +const captures = [] app.on('browser-window-created', (_event, window) => { + const capturePage = window.webContents.capturePage.bind(window.webContents) + window.webContents.capturePage = (...args) => { + captures.push({ id: window.id, time: performance.now() }) + return capturePage(...args) + } window.on('enter-full-screen', () => fullscreenEntered.add(window.id)) let requestedBounds const setBounds = window.setBounds.bind(window) @@ -74,17 +80,40 @@ const seeded = (async () => { const { buildScreenPackage } = require(root + '/dist-electron/electron/screen-package.js') const audio = readFileSync(root + '/fixtures/audio.wav') const video = readFileSync(root + '/fixtures/video.mp4') + const image = (color) => + nativeImage + .createFromBitmap(Buffer.alloc(320 * 180 * 4).fill(Buffer.from(color)), { + width: 320, + height: 180, + }) + .toPNG() + const cover = image([30, 60, 80, 255]) + const background = image([70, 40, 20, 255]) const pkg = buildScreenPackage('https://offline.invalid', 7, { game: { game: { ID: 7, name: 'Offline smoke', scenario: { - story: { introduction: { audio: 'intro.wav' }, conclusion: { video: 'ending.mp4' } }, + image: 'cover.png', + story: { + background: 'background.png', + introduction: { audio: 'intro.wav' }, + conclusion: { video: 'ending.mp4' }, + }, }, }, }, - plan: {}, + plan: { + plan: { + steps: Array.from({ length: 8 }, (_, index) => ({ + mode: index + 1, + text: index === 7 ? 'Привет, детективы!\nКириллица: Ёжик и улика №7' : '', + timerLabel: 'Перерыв', + timerDurationSeconds: index === 6 ? '2' : '0', + })), + }, + }, music: { gamePlaylistId: 1, playlists: [ @@ -102,14 +131,22 @@ const seeded = (async () => { const store = new PackageStore(join(app.getPath('userData'), 'packages'), { fetch: async (input) => { const url = String(input) - const bytes = url.endsWith('intro.wav') - ? audio - : url.endsWith('.mp4') - ? video - : musicWav(url.endsWith('two.wav') ? 660 : 330) + const bytes = url.endsWith('cover.png') + ? cover + : url.endsWith('background.png') + ? background + : url.endsWith('intro.wav') + ? audio + : url.endsWith('.mp4') + ? video + : musicWav(url.endsWith('two.wav') ? 660 : 330) return new Response(bytes, { headers: { - 'Content-Type': url.endsWith('.mp4') ? 'video/mp4' : 'audio/wav', + 'Content-Type': url.endsWith('.png') + ? 'image/png' + : url.endsWith('.mp4') + ? 'video/mp4' + : 'audio/wav', 'Content-Length': String(bytes.length), }, }) @@ -321,6 +358,172 @@ app.whenReady().then(async () => { "Array.from(document.querySelectorAll('audio,video')).every(m => !m.getAttribute('src') || m.src.startsWith('screen-app://app/packages/'))", ), ) + await command({ action: 'play', channel: 'music' }) + const modes = [] + const sceneReady = (mode) => + poll(async () => { + const s = await state() + if (s.scene.status === 'error') throw new Error(s.scene.error) + return s.scene.mode === mode && s.scene.status === 'ready' && s + }, `scene ${mode} ready`) + for (let index = 0; index < 8; index++) { + await command({ action: 'step', index }) + const s = await sceneReady(index + 1) + modes.push(s.scene.mode) + assert.equal(s.scene.stepIndex, index) + assert.equal(s.channels.main.desired, 'stopped') + assert.equal(s.channels.music.desired, 'playing') + assert.equal( + await inspect('document.querySelectorAll("button,input,textarea,select").length'), + 0, + ) + if (index === 0) + assert.equal(await inspect('document.querySelector("img.cover").naturalWidth'), 320) + else assert.equal(await inspect('document.querySelector("img.background").naturalWidth'), 320) + if (index === 2) assert.ok(await inspect('document.body.textContent.includes("Успехов")')) + if (index === 4) + assert.ok(await inspect('document.body.textContent.includes("Спасибо за игру!")')) + if (index === 5) + assert.ok( + await inspect('document.body.textContent.includes("Результаты пока не загружены")'), + ) + if (index === 6) { + assert.equal(s.timer.status, 'stopped') + assert.equal(await inspect('document.querySelector(".timer strong").textContent'), '00:02') + } + if (index === 7) + assert.ok(await inspect('document.body.textContent.includes("Кириллица: Ёжик и улика №7")')) + } + const publishedText = (await state()).scene.text + await evaluate( + `(() => { const input = document.querySelector('#scene-text'); input.value = 'Черновик ещё не показан'; input.dispatchEvent(new Event('input', {bubbles:true})) })()`, + ) + await delay(100) + assert.equal((await state()).scene.text, publishedText) + const safeText = 'Привет\n' + await command({ action: 'text', text: safeText }) + await sceneReady(8) + assert.ok(await inspect(`document.querySelector('.text').textContent.includes(' @@ -154,6 +249,144 @@ onUnmounted(() => { Откройте показ, чтобы подготовить медиа и включить воспроизведение. +
+ + + +

+ В пакете нет плана. Используйте переключатель режимов. +

+ + Предыдущий шаг + Следующий шаг + + + +

{{ sceneLabels[state.scene.status] }}

+ {{ state.scene.error }} + Повторить подготовку +

+ {{ + state.results + ? `Снимок результатов: ${new Date(state.results.capturedAt).toLocaleString('ru-RU')}` + : 'Результаты пока не загружены' + }} +

+
+
+ + Предпросмотр текущего показа +
+ {{ + !state.outputOpen + ? 'Окно показа закрыто' + : state.scene.status === 'pending' + ? 'Подготовка показа…' + : 'Предпросмотр недоступен' + }} +
+

Кадр реального окна показа, без второго звука.

+
+
+ + + + + Показать текст + + + + + Показать таймер +

+ {{ state.timer.label }} · {{ timerDisplay }} · {{ timerLabels[state.timer.status] }} +

+

+ Таймер продолжает отсчёт в фоне. +

+ + Запустить таймер + Пауза таймера + Продолжить таймер + Сбросить таймер + +
+