up
This commit is contained in:
@@ -368,3 +368,30 @@
|
||||
новая диагностика предназначена для следующего воспроизведения. Изменения
|
||||
только evening_detective_screen, сервер и схема БД не менялись. Установочные
|
||||
архивы не пересобирались; 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 не выполнялись.
|
||||
|
||||
@@ -303,3 +303,28 @@ GUI smoke на стенде без внешнего дисплея модели
|
||||
native fullscreen проверяется при наличии реального второго экрана. Реальное
|
||||
предотвращение сна, маршрутизацию звука ОС и физическое переподключение оборудования
|
||||
проверять по [инструкции 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`; обращения к этим полям напрямую как к числам не допускаются.
|
||||
|
||||
@@ -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),
|
||||
/версия/,
|
||||
)
|
||||
})
|
||||
@@ -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
@@ -188,9 +188,9 @@ async function fixture(t: TestContext, secure = true) {
|
||||
if (init?.method === 'POST') {
|
||||
const body = JSON.parse(String(init.body)) as Record<string, unknown>
|
||||
const state = objectMusic(20, Number(body.trackIndex ?? 0))
|
||||
state.playback = Number(body.playback ?? 1)
|
||||
model.remote.music = {
|
||||
...state,
|
||||
playback: body.playback,
|
||||
volume: body.volume,
|
||||
loop: body.loop,
|
||||
positionMs: body.positionMs,
|
||||
@@ -1160,12 +1160,12 @@ test('локальные режим, музыка и таймер публику
|
||||
f.controller.output(true)
|
||||
await f.online.poll()
|
||||
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 })
|
||||
assert.equal(f.model.remote.timerDurationSeconds, 60)
|
||||
await f.online.planCommand({ action: 'play', channel: 'music' })
|
||||
const music = f.model.remote.music as { playback: number }
|
||||
assert.equal(music.playback, 2)
|
||||
const music = f.model.remote.music as { playback: string }
|
||||
assert.equal(music.playback, 'MUSIC_PLAYBACK_PLAYING')
|
||||
const operation = f.controller.state.channels.music.operation
|
||||
await f.online.poll()
|
||||
assert.equal(f.controller.state.channels.music.operation, operation)
|
||||
|
||||
+103
-144
@@ -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 { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
@@ -449,12 +451,9 @@ export class Online {
|
||||
const version = this.planVersion
|
||||
this.lastPlan = Date.now()
|
||||
try {
|
||||
const result = await this.api(
|
||||
`/api/games/${pkg.package.game.id}/screen-plan`,
|
||||
undefined,
|
||||
true,
|
||||
AbortSignal.timeout(5000),
|
||||
)
|
||||
const result = await this.client(true, AbortSignal.timeout(5000)).GetScreenPlan({
|
||||
gameID: pkg.package.game.id,
|
||||
})
|
||||
await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version)
|
||||
} catch (error) {
|
||||
if (generation === this.generation && version === this.planVersion)
|
||||
@@ -472,13 +471,10 @@ export class Online {
|
||||
const version = ++this.planVersion
|
||||
this.planBusy++
|
||||
try {
|
||||
const result = await this.json(
|
||||
await this.authorized(`/api/games/${pkg.package.game.id}/screen-plan`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(plan),
|
||||
}),
|
||||
)
|
||||
const result = await this.client().SaveScreenPlan({
|
||||
gameID: pkg.package.game.id,
|
||||
...apiPlan(plan),
|
||||
})
|
||||
if (generation !== this.generation) throw new SessionChangedError()
|
||||
await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version)
|
||||
} finally {
|
||||
@@ -576,12 +572,10 @@ export class Online {
|
||||
return
|
||||
}
|
||||
if (intent.action === 'step') {
|
||||
const result = await this.api(
|
||||
`/api/games/${pkg.package.game.id}/screen-plan/step`,
|
||||
{ currentStep: intent.index },
|
||||
true,
|
||||
AbortSignal.timeout(5000),
|
||||
)
|
||||
const result = await this.client(true, AbortSignal.timeout(5000)).SetScreenPlanStep({
|
||||
gameID: pkg.package.game.id,
|
||||
currentStep: intent.index,
|
||||
})
|
||||
const confirmed = parsePlan(result.plan)
|
||||
const shown = this.controller.state.catalog.steps[intent.index]
|
||||
await this.acceptPlan(confirmed, pkg.key, generation, version)
|
||||
@@ -599,29 +593,29 @@ export class Online {
|
||||
}
|
||||
} else if ('channel' in intent && intent.channel === 'music') {
|
||||
const c = localState.channels.music
|
||||
await this.api(
|
||||
`/api/games/${pkg.package.game.id}/music`,
|
||||
{
|
||||
playlistID: c.playlistId ?? 0,
|
||||
trackIndex: c.trackIndex,
|
||||
positionMs: Math.max(0, Math.round(c.position * 1000)),
|
||||
volume: Math.round(c.volume),
|
||||
loop: c.loop,
|
||||
playback: c.desired === 'playing' ? 2 : c.desired === 'paused' ? 3 : 1,
|
||||
},
|
||||
true,
|
||||
AbortSignal.timeout(5000),
|
||||
)
|
||||
await this.client(true, AbortSignal.timeout(5000)).SetGameMusic({
|
||||
gameID: pkg.package.game.id,
|
||||
playlistID: c.playlistId ?? 0,
|
||||
trackIndex: c.trackIndex,
|
||||
positionMs: Math.max(0, Math.round(c.position * 1000)),
|
||||
volume: Math.round(c.volume),
|
||||
loop: c.loop,
|
||||
playback:
|
||||
c.desired === 'playing'
|
||||
? 'MUSIC_PLAYBACK_PLAYING'
|
||||
: c.desired === 'paused'
|
||||
? 'MUSIC_PLAYBACK_PAUSED'
|
||||
: 'MUSIC_PLAYBACK_STOPPED',
|
||||
})
|
||||
} else if (intent.action === 'mode' || intent.action === 'select') {
|
||||
await this.api(
|
||||
`/api/games/${pkg.package.game.id}/screens/mode`,
|
||||
{ mode: localState.scene.mode },
|
||||
true,
|
||||
AbortSignal.timeout(5000),
|
||||
)
|
||||
await this.client(true, AbortSignal.timeout(5000)).SetScreenMode({
|
||||
gameID: pkg.package.game.id,
|
||||
mode: apiScreenMode(localState.scene.mode),
|
||||
})
|
||||
} else if (intent.action === 'text') {
|
||||
const path = `/api/games/${pkg.package.game.id}/screen-plan`
|
||||
const current = parsePlan((await this.api(path)).plan)
|
||||
const current = parsePlan(
|
||||
(await this.client().GetScreenPlan({ gameID: pkg.package.game.id })).plan,
|
||||
)
|
||||
if (
|
||||
generation !== this.generation ||
|
||||
session !== this.controller.state.session ||
|
||||
@@ -637,13 +631,7 @@ export class Online {
|
||||
timerLabel: '',
|
||||
timerDurationSeconds: 0,
|
||||
})
|
||||
await this.json(
|
||||
await this.authorized(path, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(current),
|
||||
}),
|
||||
)
|
||||
await this.client().SaveScreenPlan({ gameID: pkg.package.game.id, ...apiPlan(current) })
|
||||
}
|
||||
if (
|
||||
generation !== this.generation ||
|
||||
@@ -651,7 +639,10 @@ export class Online {
|
||||
version !== this.planVersion
|
||||
)
|
||||
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)
|
||||
if (
|
||||
generation !== this.generation ||
|
||||
@@ -661,21 +652,18 @@ export class Online {
|
||||
return
|
||||
this.controller.markStep(index)
|
||||
} else if (intent.action === 'timer-set') {
|
||||
await this.api(
|
||||
`/api/games/${pkg.package.game.id}/screens/timer`,
|
||||
{ label: intent.label, durationSeconds: intent.durationSeconds },
|
||||
true,
|
||||
AbortSignal.timeout(5000),
|
||||
)
|
||||
await this.client(true, AbortSignal.timeout(5000)).SetScreenTimer({
|
||||
gameID: pkg.package.game.id,
|
||||
label: intent.label,
|
||||
durationSeconds: intent.durationSeconds,
|
||||
})
|
||||
} else {
|
||||
const index = this.controller.state.scene.stepIndex
|
||||
if (index !== null && index !== this.plan.currentStep) {
|
||||
const result = await this.api(
|
||||
`/api/games/${pkg.package.game.id}/screen-plan/step`,
|
||||
{ currentStep: index },
|
||||
true,
|
||||
AbortSignal.timeout(5000),
|
||||
)
|
||||
const result = await this.client(true, AbortSignal.timeout(5000)).SetScreenPlanStep({
|
||||
gameID: pkg.package.game.id,
|
||||
currentStep: index,
|
||||
})
|
||||
await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version)
|
||||
if (
|
||||
generation !== this.generation ||
|
||||
@@ -699,16 +687,12 @@ export class Online {
|
||||
}
|
||||
if (!(intent.action in commands)) throw new Error('Некорректная команда плана')
|
||||
expectedCommand = commands[intent.action as keyof typeof commands]
|
||||
await this.api(
|
||||
`/api/games/${pkg.package.game.id}/screens/command`,
|
||||
{
|
||||
command: expectedCommand,
|
||||
positionMs: 'position' in intent ? Math.round(intent.position * 1000) : 0,
|
||||
volume: 'volume' in intent ? intent.volume : this.controller.state.channels.main.volume,
|
||||
},
|
||||
true,
|
||||
AbortSignal.timeout(5000),
|
||||
)
|
||||
await this.client(true, AbortSignal.timeout(5000)).SendScreenCommand({
|
||||
gameID: pkg.package.game.id,
|
||||
command: apiScreenCommand(expectedCommand),
|
||||
positionMs: 'position' in intent ? Math.round(intent.position * 1000) : 0,
|
||||
volume: 'volume' in intent ? intent.volume : this.controller.state.channels.main.volume,
|
||||
})
|
||||
}
|
||||
if (
|
||||
generation !== this.generation ||
|
||||
@@ -719,12 +703,10 @@ export class Online {
|
||||
this.planNotice = ''
|
||||
// Ответ следующего опроса становится базой: собственная команда не применяется дважды.
|
||||
if (this.screen) {
|
||||
const result = await this.api(
|
||||
'/api/screens/state',
|
||||
{ screenID: this.screen.id, accessToken: this.screen.token },
|
||||
false,
|
||||
AbortSignal.timeout(5000),
|
||||
)
|
||||
const result = await this.client(false, AbortSignal.timeout(5000)).GetScreenState({
|
||||
screenID: this.screen.id,
|
||||
accessToken: this.screen.token,
|
||||
})
|
||||
if (generation !== this.generation || session !== this.controller.state.session) return
|
||||
const remote = parseRemote(result.state)
|
||||
if (remote.gameId !== pkg.package.game.id) throw new Error('Привязка экрана изменилась')
|
||||
@@ -748,12 +730,9 @@ export class Online {
|
||||
needsMainSync = !isMusicCommand
|
||||
}
|
||||
if (needsMainSync && remote.command === 0) {
|
||||
const result = await this.api(
|
||||
`/api/games/${pkg.package.game.id}/screen-plan`,
|
||||
undefined,
|
||||
true,
|
||||
AbortSignal.timeout(5000),
|
||||
)
|
||||
const result = await this.client(true, AbortSignal.timeout(5000)).GetScreenPlan({
|
||||
gameID: pkg.package.game.id,
|
||||
})
|
||||
await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version)
|
||||
if (
|
||||
generation !== this.generation ||
|
||||
@@ -1021,19 +1000,18 @@ export class Online {
|
||||
if (this.refreshPromise) return this.refreshPromise
|
||||
const c = this.credentials!
|
||||
this.refreshPromise = (async () => {
|
||||
const response = await this.raw(c.server, '/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refreshToken: c.refresh }),
|
||||
})
|
||||
if (generation !== this.generation) throw new SessionChangedError()
|
||||
if (response.status === 401) {
|
||||
this.credentials = null
|
||||
this.state.authenticated = false
|
||||
await this.persistSecrets()
|
||||
throw new ApiError(401)
|
||||
let result
|
||||
try {
|
||||
result = await this.client(false, undefined, c.server).Refresh({ refreshToken: c.refresh })
|
||||
} catch (error) {
|
||||
if (generation !== this.generation) throw new SessionChangedError()
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
this.credentials = null
|
||||
this.state.authenticated = false
|
||||
await this.persistSecrets()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const result = await this.json(response)
|
||||
if (generation !== this.generation) throw new SessionChangedError()
|
||||
this.credentials = {
|
||||
...c,
|
||||
@@ -1070,21 +1048,19 @@ export class Online {
|
||||
}
|
||||
return response
|
||||
}
|
||||
private async api(path: string, body?: object, authorized = true, signal?: AbortSignal) {
|
||||
const init: RequestInit =
|
||||
body === undefined
|
||||
? { signal }
|
||||
: {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
}
|
||||
return this.json(
|
||||
authorized
|
||||
? await this.authorized(path, init)
|
||||
: await this.raw(this.state.server, path, init),
|
||||
)
|
||||
private client(authorized = true, signal?: AbortSignal, server = this.state.server) {
|
||||
return createEveningDetectiveServerClient(async (request) => {
|
||||
const init: RequestInit = {
|
||||
method: request.method,
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: request.body,
|
||||
signal,
|
||||
}
|
||||
const path = `/${request.path}`
|
||||
return this.json(
|
||||
authorized ? await this.authorized(path, init) : await this.raw(server, path, init),
|
||||
)
|
||||
})
|
||||
}
|
||||
async act(input: unknown): Promise<OnlineState> {
|
||||
const data = object(input)
|
||||
@@ -1127,12 +1103,7 @@ export class Online {
|
||||
this.state.games = []
|
||||
await this.persistSecrets()
|
||||
this.state.server = server
|
||||
const response = await this.raw(server, '/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
const result = await this.json(response)
|
||||
const result = await this.client(false, undefined, server).Login({ email, password })
|
||||
this.credentials = {
|
||||
server,
|
||||
access: token(result.accessToken),
|
||||
@@ -1140,7 +1111,7 @@ export class Online {
|
||||
screen: null,
|
||||
}
|
||||
try {
|
||||
const me = object((await this.api('/api/users/me')).user)
|
||||
const me = object((await this.client().GetMe({})).user)
|
||||
if (
|
||||
!Array.isArray(me.roles) ||
|
||||
!me.roles.some((r) => ['admin', 'organizer', 'author'].includes(String(r)))
|
||||
@@ -1261,17 +1232,13 @@ export class Online {
|
||||
this.state.packagesRevision++
|
||||
await this.saveSelection()
|
||||
}
|
||||
const result = await this.api('/api/screens', {}, false)
|
||||
const result = await this.client(false).CreateScreenSession({})
|
||||
const s: ScreenSession = {
|
||||
id: String(integer(result.screenID, 1)),
|
||||
token: token(result.accessToken),
|
||||
gameId,
|
||||
}
|
||||
await this.api(`/api/screens/${s.id}/bind`, {
|
||||
screenID: s.id,
|
||||
accessToken: s.token,
|
||||
gameID: s.gameId,
|
||||
})
|
||||
await this.client().BindScreen({ screenID: s.id, accessToken: s.token, gameID: s.gameId })
|
||||
this.screen = s
|
||||
this.state.screenId = s.id
|
||||
this.state.connection = 'waiting'
|
||||
@@ -1285,10 +1252,7 @@ export class Online {
|
||||
case 'sound': {
|
||||
const generation = this.generation
|
||||
if (!this.screen) throw new Error('Сначала подключите управление')
|
||||
await this.api(`/api/screens/${this.screen.id}/sound`, {
|
||||
screenID: this.screen.id,
|
||||
soundEnabled: true,
|
||||
})
|
||||
await this.client().SetScreenSound({ screenID: this.screen.id, soundEnabled: true })
|
||||
if (generation === this.generation) this.state.soundEnabled = true
|
||||
return
|
||||
}
|
||||
@@ -1299,7 +1263,7 @@ export class Online {
|
||||
}
|
||||
}
|
||||
private async loadGames() {
|
||||
const result = await this.api('/api/games')
|
||||
const result = await this.client().GetGames({})
|
||||
if (!Array.isArray(result.games ?? [])) throw new Error('Некорректный список игр')
|
||||
this.state.games = ((result.games ?? []) as unknown[]).map((value) => {
|
||||
const g = object(value)
|
||||
@@ -1328,11 +1292,10 @@ export class Online {
|
||||
this.resultsBusy = true
|
||||
this.lastResults = Date.now()
|
||||
try {
|
||||
const result = await this.api(
|
||||
'/api/screens/results',
|
||||
{ screenID: s.id, accessToken: s.token },
|
||||
false,
|
||||
)
|
||||
const result = await this.client(false).GetScreenResults({
|
||||
screenID: s.id,
|
||||
accessToken: s.token,
|
||||
})
|
||||
if (generation !== this.generation || key !== this.controller.state.catalog.key) return
|
||||
if (!Array.isArray(result.questions ?? []) || !Array.isArray(result.teams ?? []))
|
||||
throw new Error('Некорректные результаты')
|
||||
@@ -1378,12 +1341,10 @@ export class Online {
|
||||
const version = this.planVersion
|
||||
this.pollBusy = true
|
||||
try {
|
||||
const result = await this.api(
|
||||
'/api/screens/state',
|
||||
{ screenID: s.id, accessToken: s.token },
|
||||
false,
|
||||
AbortSignal.timeout(5000),
|
||||
)
|
||||
const result = await this.client(false, AbortSignal.timeout(5000)).GetScreenState({
|
||||
screenID: s.id,
|
||||
accessToken: s.token,
|
||||
})
|
||||
if (generation !== this.generation || version !== this.planVersion) return
|
||||
let next = parseRemote(result.state)
|
||||
if (next.gameId !== s.gameId) {
|
||||
@@ -1458,12 +1419,10 @@ export class Online {
|
||||
await this.synchronize()
|
||||
return
|
||||
}
|
||||
const result = await this.api(
|
||||
'/api/screens/music/advance',
|
||||
{ screenID: this.screen.id, accessToken: this.screen.token },
|
||||
false,
|
||||
AbortSignal.timeout(5000),
|
||||
)
|
||||
const result = await this.client(false, AbortSignal.timeout(5000)).AdvanceScreenMusic({
|
||||
screenID: this.screen.id,
|
||||
accessToken: this.screen.token,
|
||||
})
|
||||
if (generation !== this.generation || report.session !== this.controller.state.session) return
|
||||
const music = parseRemote({ music: result.state }).music
|
||||
if (this.received) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createEveningDetectiveServerClient } from './api/generated/crabs/evening_detective_server/index.js'
|
||||
import {
|
||||
ApiError,
|
||||
NetworkError,
|
||||
@@ -240,10 +241,18 @@ export async function prepareScreenPackage(
|
||||
AbortSignal.timeout(30_000),
|
||||
...(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
|
||||
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' },
|
||||
credentials: 'omit',
|
||||
redirect: 'error',
|
||||
@@ -292,8 +301,12 @@ export async function prepareScreenPackage(
|
||||
request.onRequestFailure?.({ path: `/api/games/{id}${suffix}`, reason: 'INVALID_JSON' })
|
||||
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 })
|
||||
}
|
||||
|
||||
|
||||
+10
-1
@@ -3,7 +3,16 @@ import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescri
|
||||
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
|
||||
|
||||
export default defineConfigWithVueTs(
|
||||
{ ignores: ['dist/**', 'dist-electron/**', 'release/**', 'scripts/**', '*.mjs'] },
|
||||
{
|
||||
ignores: [
|
||||
'electron/api/generated/**',
|
||||
'dist/**',
|
||||
'dist-electron/**',
|
||||
'release/**',
|
||||
'scripts/**',
|
||||
'*.mjs',
|
||||
],
|
||||
},
|
||||
vue.configs['flat/essential'],
|
||||
vueTsConfigs.recommended,
|
||||
skipFormatting,
|
||||
|
||||
+2
-1
@@ -19,7 +19,8 @@
|
||||
"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",
|
||||
"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": {
|
||||
"vue": "^3.5.0",
|
||||
|
||||
@@ -90,9 +90,13 @@ module.exports = async function onlineSmoke({
|
||||
}
|
||||
if (path === '/api/games/6/screens/command') {
|
||||
currentCommand = body.command
|
||||
if (body.command === 8 || body.command === 10) timerState = 2
|
||||
if (body.command === 9) timerState = 3
|
||||
if (body.command === 11) timerState = 1
|
||||
if (
|
||||
body.command === 'SCREEN_COMMAND_TIMER_START' ||
|
||||
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++
|
||||
return send({})
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
Reference in New Issue
Block a user