Files
evening_detective_screen/electron/screen-package.ts
T
2026-10-19 00:57:56 +07:00

536 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { fieldError, ResponseFieldError, requestErrorContext } from './request-error.js'
import { createEveningDetectiveServerClient } from './api/generated/crabs/evening_detective_server/index.js'
import {
ApiError,
SessionChangedError,
networkFailure,
type NetworkErrorCode,
} from './request-error.js'
import { createHash } from 'node:crypto'
export const INCOMPLETE_MATERIALS_WARNING = 'Получен неполный список материалов. Подключите экран к игре для уточнения'
export interface ScreenPackage {
formatVersion: 1
metadataVersion: string
server: string
game: { id: number; name: string }
media: {
cover: string | null
background: string | null
intro: string | null
conclusion: string | null
}
steps: { mode: number; text: string; timerLabel: string; timerDurationSeconds: number }[]
music: {
scenarioPlaylistId: number | null
gamePlaylistId: number | null
selectedPlaylistId: number | null
}
playlists: {
id: number
name: string
tracks: { id: number; name: string; position: number; assetId: string }[]
}[]
assets: { id: string; url: string }[]
}
export interface PackageRequest {
server: string
gameId: number
token: string
resources?: { cover: string; background: string; intro: string; conclusion: string }
signal?: AbortSignal
onIncomplete?: () => void
onRequestFailure?: (failure: {
path: string
reason: NetworkErrorCode | 'SESSION_CHANGED' | 'AUTH' | 'HTTP' | 'INVALID_JSON'
}) => void
}
function object(value: unknown, field?: string): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value))
throw fieldError(field, 'объект', value, 'Некорректный ответ API')
return value as Record<string, unknown>
}
function optionalObject(value: unknown, field?: string) {
return value == null ? {} : object(value, field)
}
function list(value: unknown, field?: string): unknown[] {
if (value == null) return []
if (!Array.isArray(value))
throw fieldError(field, 'массив', value, 'Некорректный список в ответе API')
return value
}
function text(value: unknown, field?: string): string {
if (value == null) return ''
if (typeof value !== 'string')
throw fieldError(field, 'строка', value, 'Некорректный текст в ответе API')
return value
}
function integer(value: unknown, fallback = 0, field?: string): number {
if (value == null) return fallback
if (typeof value === 'string' && /^\d+$/.test(value)) value = Number(value)
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0)
throw fieldError(field, 'неотрицательное целое число', value, 'Некорректное число в ответе API')
return value
}
function id(value: unknown, field?: string): number {
const result = integer(value, 0, field)
if (result === 0)
throw fieldError(
field,
'положительный идентификатор',
value,
'Отсутствует идентификатор в ответе API',
)
return result
}
function hash(value: string) {
return createHash('sha256').update(value).digest('hex')
}
function httpURL(value: string): URL {
const url = new URL(value)
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash)
throw new Error('Недопустимый адрес сервера или файла')
// Подписанные ссылки содержат секреты и не должны сохраняться в пакете.
if (
[...url.searchParams.keys()].some((key) =>
/token|signature|credential|password|authorization/i.test(key),
)
)
throw new Error('Ссылка на файл содержит секрет')
return url
}
function serverURL(value: string): string {
const url = httpURL(value)
if (url.search) throw new Error('Недопустимый адрес сервера')
return url.href.replace(/\/+$/, '')
}
const modes = [
'UNSPECIFIED',
'COVER',
'INTRO',
'INTRO_DONE',
'CONCLUSION',
'CONCLUSION_DONE',
'RESULTS',
'TIMER',
'TEXT',
]
function mode(value: unknown, field?: string): number {
const result =
typeof value === 'string' && value.startsWith('SCREEN_MODE_')
? modes.indexOf(value.slice('SCREEN_MODE_'.length))
: integer(value, 0, field)
if (result < 1 || result > 8)
throw fieldError(
field,
'режим экрана 1–8 или enum ScreenMode',
value,
'Неизвестный режим показа',
)
return result
}
export function resolveMediaURL(server: string, value: string): string {
const ref = value.trim()
if (ref.startsWith('//') || (/^[a-z][a-z0-9+.-]*:/i.test(ref) && !/^https?:\/\//i.test(ref)))
throw new Error('Недопустимый адрес файла')
const url = httpURL(
/^https?:\/\//i.test(ref)
? ref
: ref.startsWith('/')
? new URL(ref, server).href
: `${server}/api/files/${encodeURIComponent(ref)}`,
).href
return url
}
// Только проекция данных показа: сырые ответы API могут содержать участников и секреты.
export function buildScreenPackage(
server: string,
gameId: number,
responses: { game: unknown; plan: unknown; music: unknown },
resources?: PackageRequest['resources'],
): ScreenPackage {
server = serverURL(server)
id(gameId)
const game = object(object(responses.game, 'gameResponse').game, 'game')
if (id(game.ID, 'game.ID') !== gameId)
throw new ResponseFieldError(
'game.ID',
'идентификатор выбранной игры',
game.ID,
'API вернул другую игру',
)
const scenario = optionalObject(game.scenario, 'game.scenario')
const story = optionalObject(scenario.story, 'game.scenario.story')
const intro = optionalObject(story.introduction, 'game.scenario.story.introduction')
const conclusion = optionalObject(story.conclusion, 'game.scenario.story.conclusion')
const assets = new Map<string, { id: string; url: string }>()
function asset(value: unknown, field: string): string | null {
const ref = text(value, field).trim()
if (!ref) return null
let url: string
try {
url = resolveMediaURL(server, ref)
} catch {
throw new ResponseFieldError(
field,
'адрес HTTP(S) без секретов',
value,
'Недопустимый адрес файла или ссылка содержит секрет',
)
}
const assetId = hash(url)
assets.set(assetId, { id: assetId, url })
return assetId
}
const cover = resources
? asset(resources.cover, 'state.coverImage')
: asset(scenario.image, 'game.scenario.image')
const media = {
cover,
background: resources
? asset(resources.background, 'state.backgroundImage')
: (asset(story.background, 'game.scenario.story.background') ?? cover),
intro: resources
? asset(resources.intro, 'state.introAudio')
: asset(intro.audio, 'game.scenario.story.introduction.audio'),
conclusion: resources
? asset(resources.conclusion, 'state.conclusionVideo')
: asset(conclusion.video, 'game.scenario.story.conclusion.video'),
}
const plan = optionalObject(object(responses.plan, 'planResponse').plan, 'plan')
const steps = list(plan.steps, 'plan.steps').map((value, index) => {
const field = `plan.steps[${index}]`
const step = object(value, field)
const result = {
mode: mode(step.mode, `${field}.mode`),
text: text(step.text, `${field}.text`),
timerLabel: text(step.timerLabel, `${field}.timerLabel`),
timerDurationSeconds: integer(step.timerDurationSeconds, 0, `${field}.timerDurationSeconds`),
}
if (result.mode === 8 && !result.text.trim())
throw new ResponseFieldError(
`${field}.text`,
'непустая строка',
step.text,
'Пустой текст шага показа',
)
if (
result.mode === 7 &&
(result.timerDurationSeconds < 1 || result.timerDurationSeconds > 86400)
)
throw new ResponseFieldError(
`${field}.timerDurationSeconds`,
'целое число от 1 до 86400',
step.timerDurationSeconds,
'Некорректная длительность таймера',
)
return result
})
const musicResponse = object(responses.music, 'musicResponse')
const state = optionalObject(musicResponse.state, 'music.state')
const selected = state.playlist == null ? null : object(state.playlist, 'music.state.playlist')
const music = {
scenarioPlaylistId:
integer(
musicResponse.scenarioPlaylistId ?? scenario.music_playlist_id,
0,
musicResponse.scenarioPlaylistId == null
? 'game.scenario.music_playlist_id'
: 'music.scenarioPlaylistId',
) || null,
gamePlaylistId: integer(musicResponse.gamePlaylistId, 0, 'music.gamePlaylistId') || null,
selectedPlaylistId: selected ? id(selected.ID, 'music.state.playlist.ID') : null,
}
const available = new Map<number, { value: Record<string, unknown>; field: string }>()
for (const [index, value] of list(musicResponse.playlists, 'music.playlists').entries()) {
const field = `music.playlists[${index}]`
const playlist = object(value, field)
available.set(id(playlist.ID, `${field}.ID`), { value: playlist, field })
}
// Текущий плейлист берём из того же ответа, даже если его нет в библиотеке.
if (selected)
available.set(id(selected.ID, 'music.state.playlist.ID'), {
value: selected,
field: 'music.state.playlist',
})
const selectedIds = [
...new Set(Object.values(music).filter((value): value is number => value !== null)),
].sort((a, b) => a - b)
const playlists = selectedIds.map((playlistId) => {
const entry = available.get(playlistId)
if (!entry)
throw new ResponseFieldError(
'music.playlists',
'связанный плейлист',
undefined,
'Не найден связанный плейлист',
)
const playlist = entry.value
const field = entry.field
const tracks = list(playlist.tracks, `${field}.tracks`)
.map((value, index) => {
const trackField = `${field}.tracks[${index}]`
const track = object(value, trackField)
const assetId = asset(track.filename, `${trackField}.filename`)
if (!assetId)
throw new ResponseFieldError(
`${trackField}.filename`,
'непустая строка',
track.filename,
'Не указан файл трека',
)
return {
id: id(track.ID, `${trackField}.ID`),
name: text(track.name, `${trackField}.name`),
position: integer(track.position, 0, `${trackField}.position`),
assetId,
}
})
.sort((a, b) => a.position - b.position || a.id - b.id)
return { id: playlistId, name: text(playlist.name, `${field}.name`), tracks }
})
const data = {
formatVersion: 1 as const,
server,
game: { id: gameId, name: text(game.name, 'game.name') },
media,
steps,
music,
playlists,
assets: [...assets.values()].sort((a, b) => a.id.localeCompare(b.id)),
}
// Это отпечаток метаданных, а не подтверждение целостности ещё не скачанных файлов.
return { ...data, metadataVersion: hash(JSON.stringify(data)) }
}
// Вызывается основным процессом; токен не передаётся renderer и не входит в результат.
export async function prepareScreenPackage(
request: PackageRequest,
fetchAPI: typeof fetch = fetch,
): Promise<ScreenPackage> {
const server = serverURL(request.server)
id(request.gameId)
if (!request.token.trim() || /[\r\n]/.test(request.token))
throw new Error('Требуется действующий токен')
const signal = AbortSignal.any([
AbortSignal.timeout(30_000),
...(request.signal ? [request.signal] : []),
])
const client = createEveningDetectiveServerClient(async (requestData, meta) => {
const suffix =
meta.method === 'GetGameMusic'
? '/music'
: meta.method === 'GetScreenPlan'
? '/screen-plan'
: ''
const safePath = meta.method === 'GetFullScenario' ? '/api/scenarios/{id}/full' : `/api/games/{id}${suffix}`
let response: Response
try {
response = await fetchAPI(`${server}/${requestData.path}`, {
method: requestData.method,
body: requestData.body,
headers: { Authorization: `Bearer ${request.token}`, Accept: 'application/json' },
credentials: 'omit',
redirect: 'error',
signal,
})
} catch (error) {
const path = safePath
if (error instanceof ApiError || error instanceof SessionChangedError) {
request.onRequestFailure?.({
path,
reason:
error instanceof ApiError
? [401, 403].includes(error.status)
? 'AUTH'
: 'HTTP'
: 'SESSION_CHANGED',
})
throw requestErrorContext(error, meta.method, requestData.method, `/${requestData.path}`)
}
const failure = networkFailure(error, signal)
request.onRequestFailure?.({ path, reason: failure.code })
throw requestErrorContext(failure, meta.method, requestData.method, `/${requestData.path}`)
}
if (!response.ok) {
request.onRequestFailure?.({
path: safePath,
reason: [401, 403].includes(response.status) ? 'AUTH' : 'HTTP',
})
throw requestErrorContext(
new ApiError(response.status),
meta.method,
requestData.method,
`/${requestData.path}`,
)
}
try {
return await response.json()
} catch {
request.onRequestFailure?.({ path: safePath, reason: 'INVALID_JSON' })
throw requestErrorContext(
new Error('Некорректный JSON в ответе API'),
meta.method,
requestData.method,
`/${requestData.path}`,
)
}
})
const [game, plan, music] = await Promise.all([
client.GetGame({ ID: request.gameId }),
client.GetScreenPlan({ gameID: request.gameId }),
client.GetGameMusic({ gameID: request.gameId }),
])
let fullScenarioId: number | null = null
try {
const gameData = object(object(game, 'gameResponse').game, 'game')
const scenario = optionalObject(gameData.scenario, 'game.scenario')
if (!request.resources && gameData.scenario != null) {
if (scenario.ID != null) {
const scenarioId = id(scenario.ID, 'game.scenario.ID')
let response
let received = false
try {
response = await client.GetFullScenario({ ID: scenarioId })
received = true
} catch (error) {
if (signal.aborted || error instanceof SessionChangedError || (error instanceof ApiError && error.status === 401)) throw error
request.onIncomplete?.()
}
if (received) {
try {
const full = object(object(response, 'scenarioResponse').scenario, 'scenario')
if (id(full.ID, 'scenario.ID') !== scenarioId)
throw new ResponseFieldError('scenario.ID', 'идентификатор сценария выбранной игры', full.ID, 'API вернул другой сценарий')
optionalObject(full.story, 'scenario.story')
fullScenarioId = scenarioId
gameData.scenario = { ...scenario, ...full, story: full.story }
if (full.story == null) request.onIncomplete?.()
} catch (error) {
throw requestErrorContext(error, 'GetFullScenario', 'GET', `/api/scenarios/${scenarioId}/full`)
}
}
} else if (scenario.story == null) request.onIncomplete?.()
}
return buildScreenPackage(server, request.gameId, { game, plan, music }, request.resources)
} catch (error) {
const field = error instanceof ResponseFieldError ? error.field : 'game'
if (fullScenarioId !== null && field.startsWith('game.scenario.'))
throw requestErrorContext(error, 'GetFullScenario', 'GET', `/api/scenarios/${fullScenarioId}/full`)
const state = field.startsWith('state.')
const suffix = field.startsWith('plan')
? '/screen-plan'
: field.startsWith('music')
? '/music'
: ''
throw requestErrorContext(
error,
state
? 'GetScreenState'
: suffix === '/music'
? 'GetGameMusic'
: suffix
? 'GetScreenPlan'
: 'GetGame',
state ? 'POST' : 'GET',
state ? '/api/screens/state' : `/api/games/${request.gameId}${suffix}`,
)
}
}
// Восстанавливаем только разрешённые поля и проверяем отпечаток перед работой с диском.
export function parseScreenPackage(value: unknown): ScreenPackage {
const data = object(value)
if (data.formatVersion !== 1) throw new Error('Неизвестный формат пакета')
const game = object(data.game)
const media = object(data.media)
const music = object(data.music)
const assets = new Map<string, string>()
for (const value of list(data.assets)) {
const entry = object(value)
const key = text(entry.id)
const url = httpURL(text(entry.url)).href
if (key !== hash(url) || assets.has(key)) throw new Error('Некорректный ресурс пакета')
assets.set(key, url)
}
function ref(value: unknown): string {
if (value === null) return ''
const url = assets.get(text(value))
if (!url) throw new Error('Отсутствует ресурс пакета')
return url
}
const playlists = list(data.playlists).map((value) => {
const playlist = object(value)
return {
ID: id(playlist.id),
name: text(playlist.name),
tracks: list(playlist.tracks).map((value) => {
const track = object(value)
return {
ID: id(track.id),
name: text(track.name),
position: integer(track.position),
filename: ref(track.assetId),
}
}),
}
})
const selectedId = integer(music.selectedPlaylistId)
const selected = selectedId ? playlists.find((playlist) => playlist.ID === selectedId) : undefined
if (selectedId && !selected) throw new Error('Отсутствует выбранный плейлист')
const result = buildScreenPackage(
text(data.server),
id(game.id),
{
game: {
game: {
ID: game.id,
name: text(game.name),
scenario: {
image: ref(media.cover),
story: {
background: ref(media.background),
introduction: { audio: ref(media.intro) },
conclusion: { video: ref(media.conclusion) },
},
},
},
},
plan: {
plan: {
steps: list(data.steps).map((value) => {
const step = object(value)
return {
mode: step.mode,
text: step.text,
timerLabel: step.timerLabel,
timerDurationSeconds: step.timerDurationSeconds,
}
}),
},
},
music: {
scenarioPlaylistId: integer(music.scenarioPlaylistId),
gamePlaylistId: integer(music.gamePlaylistId),
state: { playlist: selected },
playlists,
},
},
{
cover: ref(media.cover),
background: ref(media.background),
intro: ref(media.intro),
conclusion: ref(media.conclusion),
},
)
if (result.metadataVersion !== data.metadataVersion || result.assets.length !== assets.size)
throw new Error('Повреждены метаданные пакета')
return result
}