This commit is contained in:
2026-10-18 04:40:55 +07:00
parent c36d3fd314
commit 41d3e8ac5e
12 changed files with 4105 additions and 157 deletions
+27
View File
@@ -368,3 +368,30 @@
новая диагностика предназначена для следующего воспроизведения. Изменения новая диагностика предназначена для следующего воспроизведения. Изменения
только evening_detective_screen, сервер и схема БД не менялись. Установочные только evening_detective_screen, сервер и схема БД не менялись. Установочные
архивы не пересобирались; commit/push не выполнялись. архивы не пересобирались; commit/push не выполнялись.
## SCREEN-019 — Клиент API из proto
- Статус: `DONE`.
- Дата: 2026-10-18.
- Результат: добавлена npm run proto:gen с protoc 36.0 и тем же плагином,
что во frontend (protoc-gen-typescript-http v0.10.0). Источник — канонический
../evening_detective_server/api/main.proto; генерация пишет только в screen.
Сгенерированный клиент хранится в electron/api/generated; сборка не требует
генератора или соседних репозиториев. Генерируемые файлы исключены из линтера.
- Все API-вызовы Online и загрузчика описания переведены на сгенерированные методы:
вход, refresh, пользователь, игры, план, состояние/привязка/звук экрана,
музыка и результаты. Удалён ручной сборщик API-путей/JSON-тел. Локальные режимы
преобразуются в строковые enum proto; сохранены проверки ответов и точность
строковых int64 через BigInt, авторизация, таймауты, отмена и диагностика.
Медиа, кеш и воспроизведение остаются локальными; runtime-зависимости не добавлены.
- Проверки: повторная генерация побайтово идентична; 109 тестов, type-check,
lint, lint:css, сборка, Electron GUI smoke, 33 локальные Markdown-ссылки
и git diff --check — PASS. Добавлена проверка HTTP-методов/маршрутов, JSON enum,
отсутствующих полей и int64 выше Number.MAX_SAFE_INTEGER. GUI проверяет вход,
подключение, план, команды, музыку, дозагрузку, переподключение и показ.
- Локальное API: сгенерированные Login/GetMe успешны; GetGames возвращает прежний
HTTP 500 из-за устаревшей схемы локальной БД. Production-вход и физический
проектор не проверялись. Декларации int64 генератора требуют сохранения проверки
границы JSON; напрямую трактовать их как number нельзя. Ограничение описано в README.
- Границы: изменения только evening_detective_screen. Сервер, frontend и proto
не менялись, установочные архивы не пересобирались; commit/push не выполнялись.
+25
View File
@@ -303,3 +303,28 @@ GUI smoke на стенде без внешнего дисплея модели
native fullscreen проверяется при наличии реального второго экрана. Реальное native fullscreen проверяется при наличии реального второго экрана. Реальное
предотвращение сна, маршрутизацию звука ОС и физическое переподключение оборудования предотвращение сна, маршрутизацию звука ОС и физическое переподключение оборудования
проверять по [инструкции SCREEN-007](docs/SCREEN-007_ACCEPTANCE.md) на всей матрице. проверять по [инструкции SCREEN-007](docs/SCREEN-007_ACCEPTANCE.md) на всей матрице.
## Генерация клиента API
Приложение использует HTTP-клиент из канонического
`../evening_detective_server/api/main.proto`; сервер и frontend при генерации не меняются.
Сгенерированные методы и типы находятся в `electron/api/generated` и хранятся в Git.
Обычная сборка использует готовые файлы и не требует соседнего репозитория или генератора.
Для обновления нужны `protoc 36.0`, Go и тот же плагин, что во frontend:
```sh
go install go.einride.tech/protoc-gen-typescript-http@v0.10.0
npm run proto:gen
```
Добавьте каталог Go `bin` в PATH. Команда проверяет версии и пишет только в screen;
повторная генерация при неизменном proto должна давать тот же результат.
Генерируемые файлы не редактировать и не форматировать вручную.
Транспорт остаётся в основном процессе Electron: авторизация, обновление токена,
отмена, таймауты и безопасная диагностика общие для вызовов клиента. Медиа скачиваются
отдельно и воспроизводятся с локального диска. Генератор задаёт методы и TypeScript-типы,
но не проверяет и не преобразует JSON. В частности, его декларация `number` для `int64`
не меняет строковый формат gateway: входящие версии проходят проверку и сравниваются
через `BigInt`; обращения к этим полям напрямую как к числам не допускаются.
+80
View File
@@ -0,0 +1,80 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { createEveningDetectiveServerClient } from './api/generated/crabs/evening_detective_server/index.js'
import { apiPlan, apiScreenMode, apiScreenCommand } from './api-values.js'
import { parsePlan } from './screen-plan.js'
import { parseRemote } from './remote-state.js'
test('клиент proto задаёт маршруты, методы и JSON enum существующего gateway', async () => {
const calls: { path: string; method: string; body: string | null }[] = []
const client = createEveningDetectiveServerClient(async (request) => {
calls.push(request)
return {}
})
await client.Login({ email: 'dev@example.test', password: 'SECRET' })
await client.Refresh({ refreshToken: 'SECRET' })
await client.GetMe({})
await client.GetGames({})
await client.GetGame({ ID: 7 })
await client.GetScreenPlan({ gameID: 7 })
await client.GetGameMusic({ gameID: 7 })
const plan = parsePlan({
steps: [
{ mode: 8, text: 'Улика' },
{ mode: 7, timerDurationSeconds: 60 },
],
})
await client.SaveScreenPlan({ gameID: 7, ...apiPlan(plan) })
await client.SetScreenMode({ gameID: 7, mode: apiScreenMode(2) })
await client.SendScreenCommand({
gameID: 7,
command: apiScreenCommand(6),
positionMs: 1500,
volume: 50,
})
assert.deepEqual(
calls.map((c) => [c.method, c.path]),
[
['POST', 'api/auth/login'],
['POST', 'api/auth/refresh'],
['GET', 'api/users/me'],
['GET', 'api/games'],
['GET', 'api/games/7'],
['GET', 'api/games/7/screen-plan'],
['GET', 'api/games/7/music'],
['PUT', 'api/games/7/screen-plan'],
['POST', 'api/games/7/screens/mode'],
['POST', 'api/games/7/screens/command'],
],
)
assert.deepEqual(parsePlan(JSON.parse(calls[7]!.body!)), plan)
assert.equal(JSON.parse(calls[7]!.body!).steps[0].mode, 'SCREEN_MODE_TEXT')
assert.equal(JSON.parse(calls[8]!.body!).mode, 'SCREEN_MODE_INTRO')
assert.equal(JSON.parse(calls[9]!.body!).command, 'SCREEN_COMMAND_SEEK')
assert.throws(() => apiScreenMode(99), /режим/)
assert.throws(() => apiScreenCommand(99), /команда/)
})
test('ответ proto сохраняет строковый int64, enum и отсутствующие поля для проверки границы', async () => {
const client = createEveningDetectiveServerClient(async () =>
JSON.parse(
'{"state":{"stateVersion":"9007199254740993","mode":"SCREEN_MODE_TEXT","text":"Текст","positionMs":"1500"}}',
),
)
const next = parseRemote(
(await client.GetScreenState({ screenID: '12', accessToken: 'SECRET' })).state,
)
assert.equal(next.version, 9007199254740993n)
assert.equal(next.mode, 8)
assert.equal(next.position, 1.5)
assert.equal(next.music, null)
const invalid = createEveningDetectiveServerClient(async () => ({
state: { stateVersion: 9007199254740992 },
}))
assert.throws(() => parseRemote({ stateVersion: 9007199254740992 }), /версия/)
await assert.rejects(
async () =>
parseRemote((await invalid.GetScreenState({ screenID: '12', accessToken: 'SECRET' })).state),
/версия/,
)
})
+44
View File
@@ -0,0 +1,44 @@
import type {
ScreenCommand,
ScreenMode,
SaveScreenPlanReq,
} from './api/generated/crabs/evening_detective_server/index.js'
import type { ScreenPlan } from '../src/types/screen.js'
const modes = [
'SCREEN_MODE_UNSPECIFIED',
'SCREEN_MODE_COVER',
'SCREEN_MODE_INTRO',
'SCREEN_MODE_INTRO_DONE',
'SCREEN_MODE_CONCLUSION',
'SCREEN_MODE_CONCLUSION_DONE',
'SCREEN_MODE_RESULTS',
'SCREEN_MODE_TIMER',
'SCREEN_MODE_TEXT',
] as const satisfies readonly ScreenMode[]
const commands = [
'SCREEN_COMMAND_UNSPECIFIED',
'SCREEN_COMMAND_PLAY',
'SCREEN_COMMAND_PAUSE',
'SCREEN_COMMAND_RESUME',
'SCREEN_COMMAND_STOP',
'SCREEN_COMMAND_REPLAY',
'SCREEN_COMMAND_SEEK',
'SCREEN_COMMAND_VOLUME',
'SCREEN_COMMAND_TIMER_START',
'SCREEN_COMMAND_TIMER_PAUSE',
'SCREEN_COMMAND_TIMER_RESUME',
'SCREEN_COMMAND_TIMER_RESET',
] as const satisfies readonly ScreenCommand[]
export function apiScreenMode(mode: number): ScreenMode {
const value = modes[mode]
if (!value) throw new Error('Некорректный режим экрана')
return value
}
export function apiScreenCommand(command: number): ScreenCommand {
const value = commands[command]
if (!value) throw new Error('Некорректная команда экрана')
return value
}
export function apiPlan(plan: ScreenPlan): Pick<SaveScreenPlanReq, 'steps' | 'currentStep'> {
return { ...plan, steps: plan.steps.map((step) => ({ ...step, mode: apiScreenMode(step.mode) })) }
}
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -188,9 +188,9 @@ async function fixture(t: TestContext, secure = true) {
if (init?.method === 'POST') { if (init?.method === 'POST') {
const body = JSON.parse(String(init.body)) as Record<string, unknown> const body = JSON.parse(String(init.body)) as Record<string, unknown>
const state = objectMusic(20, Number(body.trackIndex ?? 0)) const state = objectMusic(20, Number(body.trackIndex ?? 0))
state.playback = Number(body.playback ?? 1)
model.remote.music = { model.remote.music = {
...state, ...state,
playback: body.playback,
volume: body.volume, volume: body.volume,
loop: body.loop, loop: body.loop,
positionMs: body.positionMs, positionMs: body.positionMs,
@@ -1160,12 +1160,12 @@ test('локальные режим, музыка и таймер публику
f.controller.output(true) f.controller.output(true)
await f.online.poll() await f.online.poll()
await f.online.planCommand({ action: 'mode', mode: 3 }) await f.online.planCommand({ action: 'mode', mode: 3 })
assert.equal(f.model.remote.mode, 3) assert.equal(f.model.remote.mode, 'SCREEN_MODE_INTRO_DONE')
await f.online.planCommand({ action: 'timer-set', label: 'Отдых', durationSeconds: 60 }) await f.online.planCommand({ action: 'timer-set', label: 'Отдых', durationSeconds: 60 })
assert.equal(f.model.remote.timerDurationSeconds, 60) assert.equal(f.model.remote.timerDurationSeconds, 60)
await f.online.planCommand({ action: 'play', channel: 'music' }) await f.online.planCommand({ action: 'play', channel: 'music' })
const music = f.model.remote.music as { playback: number } const music = f.model.remote.music as { playback: string }
assert.equal(music.playback, 2) assert.equal(music.playback, 'MUSIC_PLAYBACK_PLAYING')
const operation = f.controller.state.channels.music.operation const operation = f.controller.state.channels.music.operation
await f.online.poll() await f.online.poll()
assert.equal(f.controller.state.channels.music.operation, operation) assert.equal(f.controller.state.channels.music.operation, operation)
+103 -144
View File
@@ -1,3 +1,5 @@
import { createEveningDetectiveServerClient } from './api/generated/crabs/evening_detective_server/index.js'
import { apiScreenMode, apiScreenCommand, apiPlan } from './api-values.js'
import { ApiError, NetworkError, SessionChangedError, networkFailure } from './request-error.js' import { ApiError, NetworkError, SessionChangedError, networkFailure } from './request-error.js'
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path' import { join } from 'node:path'
@@ -449,12 +451,9 @@ export class Online {
const version = this.planVersion const version = this.planVersion
this.lastPlan = Date.now() this.lastPlan = Date.now()
try { try {
const result = await this.api( const result = await this.client(true, AbortSignal.timeout(5000)).GetScreenPlan({
`/api/games/${pkg.package.game.id}/screen-plan`, gameID: pkg.package.game.id,
undefined, })
true,
AbortSignal.timeout(5000),
)
await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version) await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version)
} catch (error) { } catch (error) {
if (generation === this.generation && version === this.planVersion) if (generation === this.generation && version === this.planVersion)
@@ -472,13 +471,10 @@ export class Online {
const version = ++this.planVersion const version = ++this.planVersion
this.planBusy++ this.planBusy++
try { try {
const result = await this.json( const result = await this.client().SaveScreenPlan({
await this.authorized(`/api/games/${pkg.package.game.id}/screen-plan`, { gameID: pkg.package.game.id,
method: 'PUT', ...apiPlan(plan),
headers: { 'Content-Type': 'application/json' }, })
body: JSON.stringify(plan),
}),
)
if (generation !== this.generation) throw new SessionChangedError() if (generation !== this.generation) throw new SessionChangedError()
await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version) await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version)
} finally { } finally {
@@ -576,12 +572,10 @@ export class Online {
return return
} }
if (intent.action === 'step') { if (intent.action === 'step') {
const result = await this.api( const result = await this.client(true, AbortSignal.timeout(5000)).SetScreenPlanStep({
`/api/games/${pkg.package.game.id}/screen-plan/step`, gameID: pkg.package.game.id,
{ currentStep: intent.index }, currentStep: intent.index,
true, })
AbortSignal.timeout(5000),
)
const confirmed = parsePlan(result.plan) const confirmed = parsePlan(result.plan)
const shown = this.controller.state.catalog.steps[intent.index] const shown = this.controller.state.catalog.steps[intent.index]
await this.acceptPlan(confirmed, pkg.key, generation, version) await this.acceptPlan(confirmed, pkg.key, generation, version)
@@ -599,29 +593,29 @@ export class Online {
} }
} else if ('channel' in intent && intent.channel === 'music') { } else if ('channel' in intent && intent.channel === 'music') {
const c = localState.channels.music const c = localState.channels.music
await this.api( await this.client(true, AbortSignal.timeout(5000)).SetGameMusic({
`/api/games/${pkg.package.game.id}/music`, gameID: pkg.package.game.id,
{ playlistID: c.playlistId ?? 0,
playlistID: c.playlistId ?? 0, trackIndex: c.trackIndex,
trackIndex: c.trackIndex, positionMs: Math.max(0, Math.round(c.position * 1000)),
positionMs: Math.max(0, Math.round(c.position * 1000)), volume: Math.round(c.volume),
volume: Math.round(c.volume), loop: c.loop,
loop: c.loop, playback:
playback: c.desired === 'playing' ? 2 : c.desired === 'paused' ? 3 : 1, c.desired === 'playing'
}, ? 'MUSIC_PLAYBACK_PLAYING'
true, : c.desired === 'paused'
AbortSignal.timeout(5000), ? 'MUSIC_PLAYBACK_PAUSED'
) : 'MUSIC_PLAYBACK_STOPPED',
})
} else if (intent.action === 'mode' || intent.action === 'select') { } else if (intent.action === 'mode' || intent.action === 'select') {
await this.api( await this.client(true, AbortSignal.timeout(5000)).SetScreenMode({
`/api/games/${pkg.package.game.id}/screens/mode`, gameID: pkg.package.game.id,
{ mode: localState.scene.mode }, mode: apiScreenMode(localState.scene.mode),
true, })
AbortSignal.timeout(5000),
)
} else if (intent.action === 'text') { } else if (intent.action === 'text') {
const path = `/api/games/${pkg.package.game.id}/screen-plan` const current = parsePlan(
const current = parsePlan((await this.api(path)).plan) (await this.client().GetScreenPlan({ gameID: pkg.package.game.id })).plan,
)
if ( if (
generation !== this.generation || generation !== this.generation ||
session !== this.controller.state.session || session !== this.controller.state.session ||
@@ -637,13 +631,7 @@ export class Online {
timerLabel: '', timerLabel: '',
timerDurationSeconds: 0, timerDurationSeconds: 0,
}) })
await this.json( await this.client().SaveScreenPlan({ gameID: pkg.package.game.id, ...apiPlan(current) })
await this.authorized(path, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(current),
}),
)
} }
if ( if (
generation !== this.generation || generation !== this.generation ||
@@ -651,7 +639,10 @@ export class Online {
version !== this.planVersion version !== this.planVersion
) )
return return
const result = await this.api(`${path}/step`, { currentStep: index }) const result = await this.client().SetScreenPlanStep({
gameID: pkg.package.game.id,
currentStep: index,
})
await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version) await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version)
if ( if (
generation !== this.generation || generation !== this.generation ||
@@ -661,21 +652,18 @@ export class Online {
return return
this.controller.markStep(index) this.controller.markStep(index)
} else if (intent.action === 'timer-set') { } else if (intent.action === 'timer-set') {
await this.api( await this.client(true, AbortSignal.timeout(5000)).SetScreenTimer({
`/api/games/${pkg.package.game.id}/screens/timer`, gameID: pkg.package.game.id,
{ label: intent.label, durationSeconds: intent.durationSeconds }, label: intent.label,
true, durationSeconds: intent.durationSeconds,
AbortSignal.timeout(5000), })
)
} else { } else {
const index = this.controller.state.scene.stepIndex const index = this.controller.state.scene.stepIndex
if (index !== null && index !== this.plan.currentStep) { if (index !== null && index !== this.plan.currentStep) {
const result = await this.api( const result = await this.client(true, AbortSignal.timeout(5000)).SetScreenPlanStep({
`/api/games/${pkg.package.game.id}/screen-plan/step`, gameID: pkg.package.game.id,
{ currentStep: index }, currentStep: index,
true, })
AbortSignal.timeout(5000),
)
await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version) await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version)
if ( if (
generation !== this.generation || generation !== this.generation ||
@@ -699,16 +687,12 @@ export class Online {
} }
if (!(intent.action in commands)) throw new Error('Некорректная команда плана') if (!(intent.action in commands)) throw new Error('Некорректная команда плана')
expectedCommand = commands[intent.action as keyof typeof commands] expectedCommand = commands[intent.action as keyof typeof commands]
await this.api( await this.client(true, AbortSignal.timeout(5000)).SendScreenCommand({
`/api/games/${pkg.package.game.id}/screens/command`, gameID: pkg.package.game.id,
{ command: apiScreenCommand(expectedCommand),
command: expectedCommand, positionMs: 'position' in intent ? Math.round(intent.position * 1000) : 0,
positionMs: 'position' in intent ? Math.round(intent.position * 1000) : 0, volume: 'volume' in intent ? intent.volume : this.controller.state.channels.main.volume,
volume: 'volume' in intent ? intent.volume : this.controller.state.channels.main.volume, })
},
true,
AbortSignal.timeout(5000),
)
} }
if ( if (
generation !== this.generation || generation !== this.generation ||
@@ -719,12 +703,10 @@ export class Online {
this.planNotice = '' this.planNotice = ''
// Ответ следующего опроса становится базой: собственная команда не применяется дважды. // Ответ следующего опроса становится базой: собственная команда не применяется дважды.
if (this.screen) { if (this.screen) {
const result = await this.api( const result = await this.client(false, AbortSignal.timeout(5000)).GetScreenState({
'/api/screens/state', screenID: this.screen.id,
{ screenID: this.screen.id, accessToken: this.screen.token }, accessToken: this.screen.token,
false, })
AbortSignal.timeout(5000),
)
if (generation !== this.generation || session !== this.controller.state.session) return if (generation !== this.generation || session !== this.controller.state.session) return
const remote = parseRemote(result.state) const remote = parseRemote(result.state)
if (remote.gameId !== pkg.package.game.id) throw new Error('Привязка экрана изменилась') if (remote.gameId !== pkg.package.game.id) throw new Error('Привязка экрана изменилась')
@@ -748,12 +730,9 @@ export class Online {
needsMainSync = !isMusicCommand needsMainSync = !isMusicCommand
} }
if (needsMainSync && remote.command === 0) { if (needsMainSync && remote.command === 0) {
const result = await this.api( const result = await this.client(true, AbortSignal.timeout(5000)).GetScreenPlan({
`/api/games/${pkg.package.game.id}/screen-plan`, gameID: pkg.package.game.id,
undefined, })
true,
AbortSignal.timeout(5000),
)
await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version) await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version)
if ( if (
generation !== this.generation || generation !== this.generation ||
@@ -1021,19 +1000,18 @@ export class Online {
if (this.refreshPromise) return this.refreshPromise if (this.refreshPromise) return this.refreshPromise
const c = this.credentials! const c = this.credentials!
this.refreshPromise = (async () => { this.refreshPromise = (async () => {
const response = await this.raw(c.server, '/api/auth/refresh', { let result
method: 'POST', try {
headers: { 'Content-Type': 'application/json' }, result = await this.client(false, undefined, c.server).Refresh({ refreshToken: c.refresh })
body: JSON.stringify({ refreshToken: c.refresh }), } catch (error) {
}) if (generation !== this.generation) throw new SessionChangedError()
if (generation !== this.generation) throw new SessionChangedError() if (error instanceof ApiError && error.status === 401) {
if (response.status === 401) { this.credentials = null
this.credentials = null this.state.authenticated = false
this.state.authenticated = false await this.persistSecrets()
await this.persistSecrets() }
throw new ApiError(401) throw error
} }
const result = await this.json(response)
if (generation !== this.generation) throw new SessionChangedError() if (generation !== this.generation) throw new SessionChangedError()
this.credentials = { this.credentials = {
...c, ...c,
@@ -1070,21 +1048,19 @@ export class Online {
} }
return response return response
} }
private async api(path: string, body?: object, authorized = true, signal?: AbortSignal) { private client(authorized = true, signal?: AbortSignal, server = this.state.server) {
const init: RequestInit = return createEveningDetectiveServerClient(async (request) => {
body === undefined const init: RequestInit = {
? { signal } method: request.method,
: { headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
method: 'POST', body: request.body,
headers: { 'Content-Type': 'application/json' }, signal,
body: JSON.stringify(body), }
signal, const path = `/${request.path}`
} return this.json(
return this.json( authorized ? await this.authorized(path, init) : await this.raw(server, path, init),
authorized )
? await this.authorized(path, init) })
: await this.raw(this.state.server, path, init),
)
} }
async act(input: unknown): Promise<OnlineState> { async act(input: unknown): Promise<OnlineState> {
const data = object(input) const data = object(input)
@@ -1127,12 +1103,7 @@ export class Online {
this.state.games = [] this.state.games = []
await this.persistSecrets() await this.persistSecrets()
this.state.server = server this.state.server = server
const response = await this.raw(server, '/api/auth/login', { const result = await this.client(false, undefined, server).Login({ email, password })
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
const result = await this.json(response)
this.credentials = { this.credentials = {
server, server,
access: token(result.accessToken), access: token(result.accessToken),
@@ -1140,7 +1111,7 @@ export class Online {
screen: null, screen: null,
} }
try { try {
const me = object((await this.api('/api/users/me')).user) const me = object((await this.client().GetMe({})).user)
if ( if (
!Array.isArray(me.roles) || !Array.isArray(me.roles) ||
!me.roles.some((r) => ['admin', 'organizer', 'author'].includes(String(r))) !me.roles.some((r) => ['admin', 'organizer', 'author'].includes(String(r)))
@@ -1261,17 +1232,13 @@ export class Online {
this.state.packagesRevision++ this.state.packagesRevision++
await this.saveSelection() await this.saveSelection()
} }
const result = await this.api('/api/screens', {}, false) const result = await this.client(false).CreateScreenSession({})
const s: ScreenSession = { const s: ScreenSession = {
id: String(integer(result.screenID, 1)), id: String(integer(result.screenID, 1)),
token: token(result.accessToken), token: token(result.accessToken),
gameId, gameId,
} }
await this.api(`/api/screens/${s.id}/bind`, { await this.client().BindScreen({ screenID: s.id, accessToken: s.token, gameID: s.gameId })
screenID: s.id,
accessToken: s.token,
gameID: s.gameId,
})
this.screen = s this.screen = s
this.state.screenId = s.id this.state.screenId = s.id
this.state.connection = 'waiting' this.state.connection = 'waiting'
@@ -1285,10 +1252,7 @@ export class Online {
case 'sound': { case 'sound': {
const generation = this.generation const generation = this.generation
if (!this.screen) throw new Error('Сначала подключите управление') if (!this.screen) throw new Error('Сначала подключите управление')
await this.api(`/api/screens/${this.screen.id}/sound`, { await this.client().SetScreenSound({ screenID: this.screen.id, soundEnabled: true })
screenID: this.screen.id,
soundEnabled: true,
})
if (generation === this.generation) this.state.soundEnabled = true if (generation === this.generation) this.state.soundEnabled = true
return return
} }
@@ -1299,7 +1263,7 @@ export class Online {
} }
} }
private async loadGames() { private async loadGames() {
const result = await this.api('/api/games') const result = await this.client().GetGames({})
if (!Array.isArray(result.games ?? [])) throw new Error('Некорректный список игр') if (!Array.isArray(result.games ?? [])) throw new Error('Некорректный список игр')
this.state.games = ((result.games ?? []) as unknown[]).map((value) => { this.state.games = ((result.games ?? []) as unknown[]).map((value) => {
const g = object(value) const g = object(value)
@@ -1328,11 +1292,10 @@ export class Online {
this.resultsBusy = true this.resultsBusy = true
this.lastResults = Date.now() this.lastResults = Date.now()
try { try {
const result = await this.api( const result = await this.client(false).GetScreenResults({
'/api/screens/results', screenID: s.id,
{ screenID: s.id, accessToken: s.token }, accessToken: s.token,
false, })
)
if (generation !== this.generation || key !== this.controller.state.catalog.key) return if (generation !== this.generation || key !== this.controller.state.catalog.key) return
if (!Array.isArray(result.questions ?? []) || !Array.isArray(result.teams ?? [])) if (!Array.isArray(result.questions ?? []) || !Array.isArray(result.teams ?? []))
throw new Error('Некорректные результаты') throw new Error('Некорректные результаты')
@@ -1378,12 +1341,10 @@ export class Online {
const version = this.planVersion const version = this.planVersion
this.pollBusy = true this.pollBusy = true
try { try {
const result = await this.api( const result = await this.client(false, AbortSignal.timeout(5000)).GetScreenState({
'/api/screens/state', screenID: s.id,
{ screenID: s.id, accessToken: s.token }, accessToken: s.token,
false, })
AbortSignal.timeout(5000),
)
if (generation !== this.generation || version !== this.planVersion) return if (generation !== this.generation || version !== this.planVersion) return
let next = parseRemote(result.state) let next = parseRemote(result.state)
if (next.gameId !== s.gameId) { if (next.gameId !== s.gameId) {
@@ -1458,12 +1419,10 @@ export class Online {
await this.synchronize() await this.synchronize()
return return
} }
const result = await this.api( const result = await this.client(false, AbortSignal.timeout(5000)).AdvanceScreenMusic({
'/api/screens/music/advance', screenID: this.screen.id,
{ screenID: this.screen.id, accessToken: this.screen.token }, accessToken: this.screen.token,
false, })
AbortSignal.timeout(5000),
)
if (generation !== this.generation || report.session !== this.controller.state.session) return if (generation !== this.generation || report.session !== this.controller.state.session) return
const music = parseRemote({ music: result.state }).music const music = parseRemote({ music: result.state }).music
if (this.received) { if (this.received) {
+17 -4
View File
@@ -1,3 +1,4 @@
import { createEveningDetectiveServerClient } from './api/generated/crabs/evening_detective_server/index.js'
import { import {
ApiError, ApiError,
NetworkError, NetworkError,
@@ -240,10 +241,18 @@ export async function prepareScreenPackage(
AbortSignal.timeout(30_000), AbortSignal.timeout(30_000),
...(request.signal ? [request.signal] : []), ...(request.signal ? [request.signal] : []),
]) ])
async function get(suffix: string): Promise<unknown> { const client = createEveningDetectiveServerClient(async (requestData, meta) => {
const suffix =
meta.method === 'GetGameMusic'
? '/music'
: meta.method === 'GetScreenPlan'
? '/screen-plan'
: ''
let response: Response let response: Response
try { try {
response = await fetchAPI(`${server}/api/games/${request.gameId}${suffix}`, { response = await fetchAPI(`${server}/${requestData.path}`, {
method: requestData.method,
body: requestData.body,
headers: { Authorization: `Bearer ${request.token}`, Accept: 'application/json' }, headers: { Authorization: `Bearer ${request.token}`, Accept: 'application/json' },
credentials: 'omit', credentials: 'omit',
redirect: 'error', redirect: 'error',
@@ -292,8 +301,12 @@ export async function prepareScreenPackage(
request.onRequestFailure?.({ path: `/api/games/{id}${suffix}`, reason: 'INVALID_JSON' }) request.onRequestFailure?.({ path: `/api/games/{id}${suffix}`, reason: 'INVALID_JSON' })
throw new Error('Некорректный JSON в ответе API') throw new Error('Некорректный JSON в ответе API')
} }
} })
const [game, plan, music] = await Promise.all([get(''), get('/screen-plan'), get('/music')]) const [game, plan, music] = await Promise.all([
client.GetGame({ ID: request.gameId }),
client.GetScreenPlan({ gameID: request.gameId }),
client.GetGameMusic({ gameID: request.gameId }),
])
return buildScreenPackage(server, request.gameId, { game, plan, music }) return buildScreenPackage(server, request.gameId, { game, plan, music })
} }
+10 -1
View File
@@ -3,7 +3,16 @@ import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescri
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting' import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
export default defineConfigWithVueTs( export default defineConfigWithVueTs(
{ ignores: ['dist/**', 'dist-electron/**', 'release/**', 'scripts/**', '*.mjs'] }, {
ignores: [
'electron/api/generated/**',
'dist/**',
'dist-electron/**',
'release/**',
'scripts/**',
'*.mjs',
],
},
vue.configs['flat/essential'], vue.configs['flat/essential'],
vueTsConfigs.recommended, vueTsConfigs.recommended,
skipFormatting, skipFormatting,
+2 -1
View File
@@ -19,7 +19,8 @@
"acceptance": "npm run build && node scripts/start.cjs scripts/smoke.cjs --manual", "acceptance": "npm run build && node scripts/start.cjs 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", "smoke:packaged": "node scripts/packaged-smoke.cjs",
"verify:release": "node scripts/release-finish.cjs" "verify:release": "node scripts/release-finish.cjs",
"proto:gen": "node scripts/proto-gen.cjs"
}, },
"dependencies": { "dependencies": {
"vue": "^3.5.0", "vue": "^3.5.0",
+7 -3
View File
@@ -90,9 +90,13 @@ module.exports = async function onlineSmoke({
} }
if (path === '/api/games/6/screens/command') { if (path === '/api/games/6/screens/command') {
currentCommand = body.command currentCommand = body.command
if (body.command === 8 || body.command === 10) timerState = 2 if (
if (body.command === 9) timerState = 3 body.command === 'SCREEN_COMMAND_TIMER_START' ||
if (body.command === 11) timerState = 1 body.command === 'SCREEN_COMMAND_TIMER_RESUME'
)
timerState = 2
if (body.command === 'SCREEN_COMMAND_TIMER_PAUSE') timerState = 3
if (body.command === 'SCREEN_COMMAND_TIMER_RESET') timerState = 1
version++ version++
return send({}) return send({})
} }
+44
View File
@@ -0,0 +1,44 @@
const { execFileSync } = require('node:child_process')
const { existsSync, mkdirSync, mkdtempSync, cpSync, rmSync } = require('node:fs')
const { delimiter, join, resolve } = require('node:path')
const { tmpdir } = require('node:os')
const root = resolve(__dirname, '..')
const plugin = (process.env.PATH || '')
.split(delimiter)
.map((p) =>
join(
p,
process.platform === 'win32'
? 'protoc-gen-typescript-http.exe'
: 'protoc-gen-typescript-http',
),
)
.find(existsSync)
if (!plugin) throw new Error('Install protoc-gen-typescript-http v0.10.0; see README')
if (execFileSync('protoc', ['--version'], { encoding: 'utf8' }).trim() !== 'libprotoc 36.0')
throw new Error('protoc 36.0 is required')
if (
!execFileSync('go', ['version', '-m', plugin], { encoding: 'utf8' }).includes(
'go.einride.tech/protoc-gen-typescript-http\tv0.10.0\t',
)
)
throw new Error('protoc-gen-typescript-http v0.10.0 is required')
const source = resolve(root, '../evening_detective_server/api')
const temporary = mkdtempSync(join(tmpdir(), 'screen-proto-'))
try {
execFileSync(
'protoc',
[
`--plugin=protoc-gen-typescript-http=${plugin}`,
`--proto_path=${source}`,
`--typescript-http_out=${temporary}`,
join(source, 'main.proto'),
],
{ stdio: 'inherit' },
)
const target = join(root, 'electron/api/generated')
mkdirSync(target, { recursive: true })
cpSync(temporary, target, { recursive: true })
} finally {
rmSync(temporary, { recursive: true, force: true })
}