up
This commit is contained in:
@@ -314,3 +314,57 @@
|
|||||||
Документация обновлена; локальные Markdown-ссылки и git diff --check — PASS.
|
Документация обновлена; локальные Markdown-ссылки и git diff --check — PASS.
|
||||||
- Границы: изменения только evening_detective_screen. Архивы не пересобирались;
|
- Границы: изменения только evening_detective_screen. Архивы не пересобирались;
|
||||||
физический проектор не проверялся. Commit/push не выполнялись.
|
физический проектор не проверялся. Commit/push не выполнялись.
|
||||||
|
|
||||||
|
## SCREEN-016 — Адрес сервера по умолчанию
|
||||||
|
|
||||||
|
- Статус: `DONE`.
|
||||||
|
- Дата: 2026-10-18.
|
||||||
|
- Результат: на чистом профиле подставляется https://evening-detective.crabs-games.art.
|
||||||
|
Этот известный адрес преобразуется в https://evening-detective-api.crabs-games.art
|
||||||
|
перед обращением к API. Сохранённый адрес имеет приоритет; остальные адреса,
|
||||||
|
включая localhost, не преобразуются. Руководство обновлено.
|
||||||
|
- Проверки: 102 теста, type-check, lint, lint:css и git diff --check — PASS.
|
||||||
|
Добавлены проверки чистого профиля, входа через адрес сайта, локального адреса
|
||||||
|
и восстановления сохранённых адресов после перезапуска.
|
||||||
|
- Границы: изменения только evening_detective_screen. Реальный вход с учётными
|
||||||
|
данными production не проверялся; установочные архивы не пересобирались.
|
||||||
|
Commit/push не выполнялись.
|
||||||
|
|
||||||
|
## SCREEN-017 — Повтор статуса подключения после обновления игр
|
||||||
|
|
||||||
|
- Статус: `DONE`.
|
||||||
|
- Дата: 2026-10-18.
|
||||||
|
- Результат: по снимку владельца подтверждены пять строк статуса внутри блока
|
||||||
|
«Игра и сервер». Внешний n-space заменён обычным flex-контейнером: устранена
|
||||||
|
зависимость условной разметки от flatten и одинаковых ключей обёрток библиотеки.
|
||||||
|
Поведение API и подключения не менялось.
|
||||||
|
- Проверки: type-check, lint, lint:css, сборка, git diff --check и Electron smoke — PASS.
|
||||||
|
В GUI добавлены пять последовательных обновлений с чередованием пустого и
|
||||||
|
непустого списка игр и проверкой единственной строки статуса. Подключение,
|
||||||
|
дозагрузка, план и воспроизведение проходят прежние сценарии.
|
||||||
|
- Ограничение: исходное накопление строк в изолированном профиле до изменения
|
||||||
|
не воспроизвелось; результат в пользовательском dev-окне требует перезапуска
|
||||||
|
и подтверждения владельцем. Изменения только evening_detective_screen;
|
||||||
|
архивы не пересобирались, commit/push не выполнялись.
|
||||||
|
|
||||||
|
## SCREEN-018 — Причина ошибки подключения игры
|
||||||
|
|
||||||
|
- Статус: `DONE`.
|
||||||
|
- Дата: 2026-10-18.
|
||||||
|
- Результат: ошибки авторизации и смены сессии при подготовке игры больше
|
||||||
|
не заменяются сообщением «Сервер недоступен». Сетевые ошибки называют запрос
|
||||||
|
(описание игры, план экранов, музыка), различают отмену, таймаут, DNS,
|
||||||
|
отказ/сброс соединения и ошибки сертификата. Неизвестные сообщения исключений
|
||||||
|
не отображаются и не журналируются.
|
||||||
|
- В диагностический журнал добавлено событие request-error: только разрешённый
|
||||||
|
шаблон пути и код причины, без URL, ID игры, токенов и содержимого ответа.
|
||||||
|
Общие типы транспортных ошибок используются загрузчиком описания и Online.
|
||||||
|
- Проверки: 107 тестов, type-check, lint, lint:css, сборка, Electron GUI smoke,
|
||||||
|
33 локальные Markdown-ссылки и git diff --check — PASS. Проверены ошибки
|
||||||
|
каждого запроса, отмена, таймаут, истёкшая авторизация, смена сессии и журнал
|
||||||
|
без секретов. Вход через Online в http://127.0.0.1:8090 успешен; последующий
|
||||||
|
список игр возвращает прежний HTTP 500 из-за устаревшей локальной схемы БД.
|
||||||
|
- Ограничения: причина исходного сетевого сбоя публичного API пока не установлена;
|
||||||
|
новая диагностика предназначена для следующего воспроизведения. Изменения
|
||||||
|
только evening_detective_screen, сервер и схема БД не менялись. Установочные
|
||||||
|
архивы не пересобирались; commit/push не выполнялись.
|
||||||
|
|||||||
@@ -79,6 +79,11 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова
|
|||||||
принудительной загрузки закройте показ и нажмите «Загрузить материалы».
|
принудительной загрузки закройте показ и нажмите «Загрузить материалы».
|
||||||
Ошибка или отмена ручного обновления сохраняет прежний полный пакет.
|
Ошибка или отмена ручного обновления сохраняет прежний полный пакет.
|
||||||
|
|
||||||
|
На чистом профиле адрес сервера заполнен: `https://evening-detective.crabs-games.art`.
|
||||||
|
Приложение направляет запросы этого сайта на `https://evening-detective-api.crabs-games.art`.
|
||||||
|
Сохранённый адрес имеет приоритет; для локального сервера можно вручную указать
|
||||||
|
`http://127.0.0.1:8090`.
|
||||||
|
|
||||||
## Проведение показа
|
## Проведение показа
|
||||||
|
|
||||||
В «План экранов» нажмите «Показать» у нужного шага, используйте «Предыдущий шаг» / «Следующий шаг»
|
В «План экранов» нажмите «Показать» у нужного шага, используйте «Предыдущий шаг» / «Следующий шаг»
|
||||||
@@ -232,3 +237,9 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова
|
|||||||
|
|
||||||
Разработчикам: [README](../README.md). Проверяющим:
|
Разработчикам: [README](../README.md). Проверяющим:
|
||||||
[сборки и полная приёмка](SCREEN-008_ACCEPTANCE.md).
|
[сборки и полная приёмка](SCREEN-008_ACCEPTANCE.md).
|
||||||
|
|
||||||
|
При ошибке подключения игры сообщение указывает запрос: описание игры, план
|
||||||
|
экранов или музыка. Таймаут, отмена, ошибка адреса и отказ соединения различаются.
|
||||||
|
Ошибка сессии требует повторного входа, а не проверки сети. В `diagnostic.jsonl`
|
||||||
|
событие `request-error` содержит только шаблон пути и разрешённый код причины;
|
||||||
|
пароли, токены, содержимое ответа и полные адреса туда не записываются.
|
||||||
|
|||||||
@@ -19,13 +19,21 @@ test('журнал сохраняет только разрешённые пол
|
|||||||
log.record('output-close', { reason: 'renderer', password: 'SECRET', error: new Error('SECRET') })
|
log.record('output-close', { reason: 'renderer', password: 'SECRET', error: new Error('SECRET') })
|
||||||
log.record('output-close', { reason: 'https://secret.example/SECRET' })
|
log.record('output-close', { reason: 'https://secret.example/SECRET' })
|
||||||
log.record('package', { action: 'download', status: 'failed', response: 'SECRET' })
|
log.record('package', { action: 'download', status: 'failed', response: 'SECRET' })
|
||||||
|
log.record('request-error', {
|
||||||
|
path: '/api/games/{id}/music',
|
||||||
|
reason: 'ENOTFOUND',
|
||||||
|
token: 'SECRET',
|
||||||
|
error: new Error('SECRET'),
|
||||||
|
})
|
||||||
|
log.record('request-error', { path: '/api/games/7?token=SECRET', reason: 'ENOTFOUND' })
|
||||||
|
log.record('request-error', { path: '/api/games/{id}', reason: 'SECRET' })
|
||||||
const content = await readFile(join(dir, 'diagnostic.jsonl'), 'utf8')
|
const content = await readFile(join(dir, 'diagnostic.jsonl'), 'utf8')
|
||||||
assert.equal(content.includes('SECRET'), false)
|
assert.equal(content.includes('SECRET'), false)
|
||||||
const lines = content
|
const lines = content
|
||||||
.trim()
|
.trim()
|
||||||
.split('\n')
|
.split('\n')
|
||||||
.map((s) => JSON.parse(s))
|
.map((s) => JSON.parse(s))
|
||||||
assert.equal(lines.length, 3)
|
assert.equal(lines.length, 4)
|
||||||
assert.equal(lines[0].version, '0.2.0-rc.1')
|
assert.equal(lines[0].version, '0.2.0-rc.1')
|
||||||
assert.deepEqual(Object.keys(lines[1]).sort(), ['event', 'reason', 'time'])
|
assert.deepEqual(Object.keys(lines[1]).sort(), ['event', 'reason', 'time'])
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { NETWORK_ERROR_CODES } from './request-error.js'
|
||||||
import { appendFileSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs'
|
import { appendFileSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ type Event =
|
|||||||
| 'media-error'
|
| 'media-error'
|
||||||
| 'connection'
|
| 'connection'
|
||||||
| 'package'
|
| 'package'
|
||||||
|
| 'request-error'
|
||||||
const fields: Partial<Record<Event, Record<string, readonly string[]>>> = {
|
const fields: Partial<Record<Event, Record<string, readonly string[]>>> = {
|
||||||
'output-close': {
|
'output-close': {
|
||||||
reason: [
|
reason: [
|
||||||
@@ -25,6 +27,10 @@ const fields: Partial<Record<Event, Record<string, readonly string[]>>> = {
|
|||||||
'unresponsive',
|
'unresponsive',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
'request-error': {
|
||||||
|
path: ['/api/games/{id}', '/api/games/{id}/screen-plan', '/api/games/{id}/music'],
|
||||||
|
reason: [...NETWORK_ERROR_CODES, 'AUTH', 'HTTP', 'INVALID_JSON', 'SESSION_CHANGED'],
|
||||||
|
},
|
||||||
'media-error': { channel: ['main', 'music', 'scene'] },
|
'media-error': { channel: ['main', 'music', 'scene'] },
|
||||||
connection: { status: ['disabled', 'waiting', 'connected', 'offline', 'expired'] },
|
connection: { status: ['disabled', 'waiting', 'connected', 'offline', 'expired'] },
|
||||||
package: { action: ['download', 'check', 'remove'], status: ['success', 'failed'] },
|
package: { action: ['download', 'check', 'remove'], status: ['success', 'failed'] },
|
||||||
@@ -40,6 +46,7 @@ const events: readonly string[] = [
|
|||||||
'media-error',
|
'media-error',
|
||||||
'connection',
|
'connection',
|
||||||
'package',
|
'package',
|
||||||
|
'request-error',
|
||||||
]
|
]
|
||||||
export class DiagnosticLog {
|
export class DiagnosticLog {
|
||||||
private disabled = false
|
private disabled = false
|
||||||
|
|||||||
+15
-8
@@ -290,14 +290,21 @@ app
|
|||||||
}
|
}
|
||||||
const packages = new PackageStore(join(app.getPath('userData'), 'packages'))
|
const packages = new PackageStore(join(app.getPath('userData'), 'packages'))
|
||||||
await packages.initialize()
|
await packages.initialize()
|
||||||
online = new Online(join(app.getPath('userData'), 'connection'), packages, controller, {
|
online = new Online(
|
||||||
available: () =>
|
join(app.getPath('userData'), 'connection'),
|
||||||
safeStorage.isEncryptionAvailable() &&
|
packages,
|
||||||
(process.platform !== 'linux' ||
|
controller,
|
||||||
!['basic_text', 'unknown'].includes(safeStorage.getSelectedStorageBackend())),
|
{
|
||||||
encrypt: (value) => safeStorage.encryptString(value),
|
available: () =>
|
||||||
decrypt: (value) => safeStorage.decryptString(value),
|
safeStorage.isEncryptionAvailable() &&
|
||||||
})
|
(process.platform !== 'linux' ||
|
||||||
|
!['basic_text', 'unknown'].includes(safeStorage.getSelectedStorageBackend())),
|
||||||
|
encrypt: (value) => safeStorage.encryptString(value),
|
||||||
|
decrypt: (value) => safeStorage.decryptString(value),
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
(failure) => diagnostics?.record('request-error', failure),
|
||||||
|
)
|
||||||
await online.initialize()
|
await online.initialize()
|
||||||
let previousConnection = online.state.connection
|
let previousConnection = online.state.connection
|
||||||
const logConnection = () => {
|
const logConnection = () => {
|
||||||
|
|||||||
+49
-1
@@ -35,6 +35,7 @@ async function fixture(t: TestContext, secure = true) {
|
|||||||
}
|
}
|
||||||
const model = {
|
const model = {
|
||||||
offline: false,
|
offline: false,
|
||||||
|
server: 'https://server.test',
|
||||||
introURL: 'https://media.test/intro.wav',
|
introURL: 'https://media.test/intro.wav',
|
||||||
musicOverride: null as Record<string, unknown> | null,
|
musicOverride: null as Record<string, unknown> | null,
|
||||||
mediaHandler: null as ((url: string) => Promise<Response>) | null,
|
mediaHandler: null as ((url: string) => Promise<Response>) | null,
|
||||||
@@ -82,7 +83,7 @@ async function fixture(t: TestContext, secure = true) {
|
|||||||
headers: { 'Content-Type': 'audio/wav' },
|
headers: { 'Content-Type': 'audio/wav' },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
assert.equal(url.origin, 'https://server.test')
|
assert.equal(url.origin, model.server)
|
||||||
if (path === '/api/auth/login')
|
if (path === '/api/auth/login')
|
||||||
return json({ accessToken: 'ACCESS-SECRET', refreshToken: 'REFRESH-SECRET' })
|
return json({ accessToken: 'ACCESS-SECRET', refreshToken: 'REFRESH-SECRET' })
|
||||||
if (path === '/api/auth/refresh') {
|
if (path === '/api/auth/refresh') {
|
||||||
@@ -264,6 +265,44 @@ async function fixture(t: TestContext, secure = true) {
|
|||||||
prepare,
|
prepare,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
test('чистый профиль предлагает адрес сайта по умолчанию', async (t) => {
|
||||||
|
const f = await fixture(t)
|
||||||
|
assert.equal(f.online.snapshot().server, 'https://evening-detective.crabs-games.art')
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const [input, expected] of [
|
||||||
|
['https://evening-detective.crabs-games.art/', 'https://evening-detective-api.crabs-games.art'],
|
||||||
|
['http://127.0.0.1:8090', 'http://127.0.0.1:8090'],
|
||||||
|
]) {
|
||||||
|
test(`адрес входа ${input}: API и сохранённый адрес ${expected}`, async (t) => {
|
||||||
|
const f = await fixture(t, false)
|
||||||
|
f.model.server = expected!
|
||||||
|
await f.online.act({
|
||||||
|
action: 'login',
|
||||||
|
server: input!,
|
||||||
|
email: 'test@example.test',
|
||||||
|
password: 'PASSWORD-SECRET',
|
||||||
|
})
|
||||||
|
assert.equal(f.online.snapshot().authenticated, true)
|
||||||
|
assert.equal(f.online.snapshot().server, expected)
|
||||||
|
assert.ok(f.requests.some((r) => r.path === '/api/auth/login'))
|
||||||
|
await f.online.close()
|
||||||
|
const restored = new Online(
|
||||||
|
join(f.directory, 'connection'),
|
||||||
|
f.store,
|
||||||
|
new Controller(() => {}),
|
||||||
|
f.secrets,
|
||||||
|
f.fetchAPI,
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
await restored.initialize()
|
||||||
|
assert.equal(restored.snapshot().server, expected)
|
||||||
|
} finally {
|
||||||
|
await restored.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function objectMusic(version: number, index = 0) {
|
function objectMusic(version: number, index = 0) {
|
||||||
return {
|
return {
|
||||||
stateVersion: String(version),
|
stateVersion: String(version),
|
||||||
@@ -1189,3 +1228,12 @@ test('публикация текста после смены игры не от
|
|||||||
assert.equal(f.controller.state.catalog.key, null)
|
assert.equal(f.controller.state.catalog.key, null)
|
||||||
assert.equal(f.controller.state.scene.stepIndex, null)
|
assert.equal(f.controller.state.scene.stepIndex, null)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('истёкшая авторизация при подключении игры не маскируется сетевой ошибкой', async (t) => {
|
||||||
|
const f = await fixture(t)
|
||||||
|
await f.login()
|
||||||
|
f.model.accessExpired = true
|
||||||
|
f.model.refreshExpired = true
|
||||||
|
await assert.rejects(f.online.act({ action: 'connect', gameId: 7 }), /Сессия истекла/)
|
||||||
|
assert.equal(f.online.snapshot().authenticated, false)
|
||||||
|
})
|
||||||
|
|||||||
+22
-27
@@ -1,6 +1,7 @@
|
|||||||
|
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'
|
||||||
import type { OnlineIntent, OnlineState } from '../src/types/online.js'
|
import { DEFAULT_SERVER, type OnlineIntent, type OnlineState } from '../src/types/online.js'
|
||||||
import type {
|
import type {
|
||||||
Intent,
|
Intent,
|
||||||
PlanStatus,
|
PlanStatus,
|
||||||
@@ -39,20 +40,6 @@ interface Credentials {
|
|||||||
refresh: string
|
refresh: string
|
||||||
screen: ScreenSession | null
|
screen: ScreenSession | null
|
||||||
}
|
}
|
||||||
class NetworkError extends Error {}
|
|
||||||
class ApiError extends Error {
|
|
||||||
constructor(public status: number) {
|
|
||||||
super(
|
|
||||||
status === 401
|
|
||||||
? 'Сессия истекла. Войдите снова'
|
|
||||||
: status === 403
|
|
||||||
? 'Нет доступа к действию'
|
|
||||||
: status === 400
|
|
||||||
? 'Действие пока недоступно для этой игры'
|
|
||||||
: `Ошибка сервера: HTTP ${status}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function serverURL(value: unknown): string {
|
function serverURL(value: unknown): string {
|
||||||
const url = new URL(text(value))
|
const url = new URL(text(value))
|
||||||
if (
|
if (
|
||||||
@@ -64,7 +51,9 @@ function serverURL(value: unknown): string {
|
|||||||
(url.pathname !== '/' && url.pathname !== '')
|
(url.pathname !== '/' && url.pathname !== '')
|
||||||
)
|
)
|
||||||
throw new Error('Укажите адрес HTTP(S) сервера без пути и параметров')
|
throw new Error('Укажите адрес HTTP(S) сервера без пути и параметров')
|
||||||
return url.origin
|
return url.origin === DEFAULT_SERVER
|
||||||
|
? 'https://evening-detective-api.crabs-games.art'
|
||||||
|
: url.origin
|
||||||
}
|
}
|
||||||
function token(value: unknown): string {
|
function token(value: unknown): string {
|
||||||
const s = text(value)
|
const s = text(value)
|
||||||
@@ -74,7 +63,7 @@ function token(value: unknown): string {
|
|||||||
}
|
}
|
||||||
export class Online {
|
export class Online {
|
||||||
readonly state: OnlineState = {
|
readonly state: OnlineState = {
|
||||||
server: '',
|
server: DEFAULT_SERVER,
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
remembered: false,
|
remembered: false,
|
||||||
busy: false,
|
busy: false,
|
||||||
@@ -119,6 +108,7 @@ export class Online {
|
|||||||
gameId,
|
gameId,
|
||||||
token: this.credentials.access,
|
token: this.credentials.access,
|
||||||
signal: this.materialAbort.signal,
|
signal: this.materialAbort.signal,
|
||||||
|
onRequestFailure: this.onRequestFailure,
|
||||||
},
|
},
|
||||||
(url, init) => {
|
(url, init) => {
|
||||||
const parsed = new URL(String(url))
|
const parsed = new URL(String(url))
|
||||||
@@ -489,7 +479,7 @@ export class Online {
|
|||||||
body: JSON.stringify(plan),
|
body: JSON.stringify(plan),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
if (generation !== this.generation) throw new Error('Сессия изменилась')
|
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 {
|
||||||
this.planBusy--
|
this.planBusy--
|
||||||
@@ -839,6 +829,9 @@ export class Online {
|
|||||||
private controller: Controller,
|
private controller: Controller,
|
||||||
private secrets: SecretStorage,
|
private secrets: SecretStorage,
|
||||||
private fetchAPI: typeof fetch = fetch,
|
private fetchAPI: typeof fetch = fetch,
|
||||||
|
private onRequestFailure: NonNullable<
|
||||||
|
import('./screen-package.js').PackageRequest['onRequestFailure']
|
||||||
|
> = () => {},
|
||||||
) {
|
) {
|
||||||
controller.onMusicEnded = (report) => {
|
controller.onMusicEnded = (report) => {
|
||||||
if (!this.screen) return false
|
if (!this.screen) return false
|
||||||
@@ -1000,18 +993,19 @@ export class Online {
|
|||||||
this.state.connection = 'disabled'
|
this.state.connection = 'disabled'
|
||||||
}
|
}
|
||||||
private async raw(server: string, path: string, init: RequestInit = {}): Promise<Response> {
|
private async raw(server: string, path: string, init: RequestInit = {}): Promise<Response> {
|
||||||
|
const signal = AbortSignal.any([
|
||||||
|
AbortSignal.timeout(30_000),
|
||||||
|
...(init.signal ? [init.signal] : []),
|
||||||
|
])
|
||||||
try {
|
try {
|
||||||
return await this.fetchAPI(`${server}${path}`, {
|
return await this.fetchAPI(`${server}${path}`, {
|
||||||
...init,
|
...init,
|
||||||
redirect: 'error',
|
redirect: 'error',
|
||||||
credentials: 'omit',
|
credentials: 'omit',
|
||||||
signal: AbortSignal.any([
|
signal,
|
||||||
AbortSignal.timeout(30_000),
|
|
||||||
...(init.signal ? [init.signal] : []),
|
|
||||||
]),
|
|
||||||
})
|
})
|
||||||
} catch {
|
} catch (error) {
|
||||||
throw new NetworkError('Сервер недоступен или превышено время ожидания')
|
throw networkFailure(error, signal)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
private async json(response: Response): Promise<Record<string, unknown>> {
|
private async json(response: Response): Promise<Record<string, unknown>> {
|
||||||
@@ -1032,7 +1026,7 @@ export class Online {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ refreshToken: c.refresh }),
|
body: JSON.stringify({ refreshToken: c.refresh }),
|
||||||
})
|
})
|
||||||
if (generation !== this.generation) throw new Error('Сессия изменилась')
|
if (generation !== this.generation) throw new SessionChangedError()
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
this.credentials = null
|
this.credentials = null
|
||||||
this.state.authenticated = false
|
this.state.authenticated = false
|
||||||
@@ -1040,7 +1034,7 @@ export class Online {
|
|||||||
throw new ApiError(401)
|
throw new ApiError(401)
|
||||||
}
|
}
|
||||||
const result = await this.json(response)
|
const result = await this.json(response)
|
||||||
if (generation !== this.generation) throw new Error('Сессия изменилась')
|
if (generation !== this.generation) throw new SessionChangedError()
|
||||||
this.credentials = {
|
this.credentials = {
|
||||||
...c,
|
...c,
|
||||||
access: token(result.accessToken),
|
access: token(result.accessToken),
|
||||||
@@ -1062,7 +1056,7 @@ export class Online {
|
|||||||
return this.raw(c.server, path, { ...init, headers })
|
return this.raw(c.server, path, { ...init, headers })
|
||||||
}
|
}
|
||||||
let response = await request(c.access)
|
let response = await request(c.access)
|
||||||
if (generation !== this.generation) throw new Error('Сессия изменилась')
|
if (generation !== this.generation) throw new SessionChangedError()
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
await response.body?.cancel()
|
await response.body?.cancel()
|
||||||
await this.refresh(c.access, generation)
|
await this.refresh(c.access, generation)
|
||||||
@@ -1195,6 +1189,7 @@ export class Online {
|
|||||||
gameId,
|
gameId,
|
||||||
token: this.credentials.access,
|
token: this.credentials.access,
|
||||||
signal: this.cancellation.signal,
|
signal: this.cancellation.signal,
|
||||||
|
onRequestFailure: this.onRequestFailure,
|
||||||
},
|
},
|
||||||
(url, init) => {
|
(url, init) => {
|
||||||
const parsed = new URL(String(url))
|
const parsed = new URL(String(url))
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { test } from 'node:test'
|
||||||
|
import { networkFailure } from './request-error.js'
|
||||||
|
|
||||||
|
test('безопасная причина соединения извлекается из вложенных Node-ошибок', () => {
|
||||||
|
for (const [code, message] of [
|
||||||
|
['ECONNREFUSED', 'Сервер отклонил соединение'],
|
||||||
|
['ECONNRESET', 'Соединение с сервером прервано'],
|
||||||
|
['UND_ERR_CONNECT_TIMEOUT', 'Превышено время ожидания соединения с сервером'],
|
||||||
|
['CERT_HAS_EXPIRED', 'Ошибка защищённого соединения с сервером'],
|
||||||
|
['SECRET', 'Сервер недоступен'],
|
||||||
|
]) {
|
||||||
|
const error = new TypeError('SECRET', {
|
||||||
|
cause: new AggregateError([Object.assign(new Error('SECRET'), { code })]),
|
||||||
|
})
|
||||||
|
const result = networkFailure(error, new AbortController().signal)
|
||||||
|
assert.equal(result.message, message)
|
||||||
|
assert.equal(result.code, code === 'SECRET' ? 'NETWORK' : code)
|
||||||
|
assert.ok(!result.message.includes('SECRET'))
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
export const NETWORK_ERROR_CODES = [
|
||||||
|
'NETWORK',
|
||||||
|
'TIMEOUT',
|
||||||
|
'ABORTED',
|
||||||
|
'ENOTFOUND',
|
||||||
|
'EAI_AGAIN',
|
||||||
|
'ECONNREFUSED',
|
||||||
|
'ECONNRESET',
|
||||||
|
'ETIMEDOUT',
|
||||||
|
'UND_ERR_CONNECT_TIMEOUT',
|
||||||
|
'CERT_HAS_EXPIRED',
|
||||||
|
'DEPTH_ZERO_SELF_SIGNED_CERT',
|
||||||
|
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
|
||||||
|
'ERR_TLS_CERT_ALTNAME_INVALID',
|
||||||
|
] as const
|
||||||
|
export type NetworkErrorCode = (typeof NETWORK_ERROR_CODES)[number]
|
||||||
|
export class NetworkError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public code: NetworkErrorCode = 'NETWORK',
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class SessionChangedError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('Сессия изменилась. Повторите подключение игры')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(public status: number) {
|
||||||
|
super(
|
||||||
|
status === 401
|
||||||
|
? 'Сессия истекла. Войдите снова'
|
||||||
|
: status === 403
|
||||||
|
? 'Нет доступа к действию'
|
||||||
|
: status === 400
|
||||||
|
? 'Действие пока недоступно для этой игры'
|
||||||
|
: `Ошибка сервера: HTTP ${status}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function causeCode(error: unknown, depth = 0): NetworkErrorCode | undefined {
|
||||||
|
if (!error || typeof error !== 'object' || depth > 3) return
|
||||||
|
const value = error as { code?: unknown; cause?: unknown; errors?: unknown[] }
|
||||||
|
if (NETWORK_ERROR_CODES.includes(value.code as NetworkErrorCode))
|
||||||
|
return value.code as NetworkErrorCode
|
||||||
|
return (
|
||||||
|
causeCode(value.cause, depth + 1) ??
|
||||||
|
(Array.isArray(value.errors)
|
||||||
|
? value.errors.map((e) => causeCode(e, depth + 1)).find(Boolean)
|
||||||
|
: undefined)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
export function networkFailure(error: unknown, signal: AbortSignal): NetworkError {
|
||||||
|
if (signal.aborted) {
|
||||||
|
return signal.reason?.name === 'TimeoutError'
|
||||||
|
? new NetworkError('Превышено время ожидания ответа сервера', 'TIMEOUT')
|
||||||
|
: new NetworkError('Подготовка отменена', 'ABORTED')
|
||||||
|
}
|
||||||
|
if (error instanceof NetworkError) return error
|
||||||
|
const code = causeCode(error) ?? 'NETWORK'
|
||||||
|
const message =
|
||||||
|
code === 'ENOTFOUND' || code === 'EAI_AGAIN'
|
||||||
|
? 'Не удалось определить адрес сервера'
|
||||||
|
: code.includes('CERT') || code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE'
|
||||||
|
? 'Ошибка защищённого соединения с сервером'
|
||||||
|
: code === 'ETIMEDOUT' || code === 'UND_ERR_CONNECT_TIMEOUT'
|
||||||
|
? 'Превышено время ожидания соединения с сервером'
|
||||||
|
: code === 'ECONNREFUSED'
|
||||||
|
? 'Сервер отклонил соединение'
|
||||||
|
: code === 'ECONNRESET'
|
||||||
|
? 'Соединение с сервером прервано'
|
||||||
|
: 'Сервер недоступен'
|
||||||
|
return new NetworkError(message, code)
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { test } from 'node:test'
|
import { test } from 'node:test'
|
||||||
|
import { ApiError, SessionChangedError } from './request-error.js'
|
||||||
import { buildScreenPackage, prepareScreenPackage } from './screen-package.js'
|
import { buildScreenPackage, prepareScreenPackage } from './screen-package.js'
|
||||||
|
|
||||||
const server = 'https://example.test'
|
const server = 'https://example.test'
|
||||||
@@ -200,7 +201,7 @@ test('отказы API и некорректный JSON не выдают час
|
|||||||
prepareScreenPackage({ server, gameId: 7, token: 'SECRET' }, async () => {
|
prepareScreenPackage({ server, gameId: 7, token: 'SECRET' }, async () => {
|
||||||
throw new Error('SECRET')
|
throw new Error('SECRET')
|
||||||
}),
|
}),
|
||||||
/^Error: Сервер недоступен$/,
|
/^Error: Описание игры: Сервер недоступен$/,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -236,3 +237,69 @@ test('отмена и недопустимые адреса обрабатыва
|
|||||||
)
|
)
|
||||||
await assert.rejects(prepareScreenPackage({ server, gameId: 7, token: '' }), /токен/)
|
await assert.rejects(prepareScreenPackage({ server, gameId: 7, token: '' }), /токен/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('сетевая ошибка называет запрос и журналирует только безопасную причину', async () => {
|
||||||
|
for (const suffix of ['', '/screen-plan', '/music']) {
|
||||||
|
const failures: { path: string; reason: string }[] = []
|
||||||
|
const data = fixture()
|
||||||
|
await assert.rejects(
|
||||||
|
prepareScreenPackage(
|
||||||
|
{ server, gameId: 7, token: 'SECRET', onRequestFailure: (f) => failures.push(f) },
|
||||||
|
async (input) => {
|
||||||
|
const url = String(input)
|
||||||
|
if (url === `${server}/api/games/7${suffix}`)
|
||||||
|
throw new TypeError('SECRET', {
|
||||||
|
cause: Object.assign(new Error('SECRET'), { code: 'ENOTFOUND' }),
|
||||||
|
})
|
||||||
|
return Response.json(
|
||||||
|
url.endsWith('/music')
|
||||||
|
? data.music
|
||||||
|
: url.endsWith('/screen-plan')
|
||||||
|
? data.plan
|
||||||
|
: data.game,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
new RegExp(
|
||||||
|
`^Error: ${suffix === '/music' ? 'Музыка игры' : suffix === '/screen-plan' ? 'План экранов' : 'Описание игры'}: Не удалось определить адрес сервера$`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert.deepEqual(failures, [{ path: `/api/games/{id}${suffix}`, reason: 'ENOTFOUND' }])
|
||||||
|
assert.ok(!JSON.stringify(failures).includes('SECRET'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('подготовка сохраняет ошибки авторизации и смены сессии', async () => {
|
||||||
|
for (const error of [new ApiError(401), new ApiError(403), new SessionChangedError()]) {
|
||||||
|
const data = fixture()
|
||||||
|
await assert.rejects(
|
||||||
|
prepareScreenPackage({ server, gameId: 7, token: 'SECRET' }, async (input) => {
|
||||||
|
const url = String(input)
|
||||||
|
if (url.endsWith('/screen-plan')) throw error
|
||||||
|
return Response.json(url.endsWith('/music') ? data.music : data.game)
|
||||||
|
}),
|
||||||
|
(e) => e === error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('таймаут и отмена подготовки различаются без утечки причины', async () => {
|
||||||
|
for (const [name, message, reason] of [
|
||||||
|
['TimeoutError', 'Превышено время ожидания ответа сервера', 'TIMEOUT'],
|
||||||
|
['AbortError', 'Подготовка отменена', 'ABORTED'],
|
||||||
|
]) {
|
||||||
|
const signal = AbortSignal.abort(new DOMException('SECRET', name))
|
||||||
|
const failures: { reason: string }[] = []
|
||||||
|
await assert.rejects(
|
||||||
|
prepareScreenPackage(
|
||||||
|
{ server, gameId: 7, token: 'SECRET', signal, onRequestFailure: (f) => failures.push(f) },
|
||||||
|
async (_input, init) => {
|
||||||
|
init?.signal?.throwIfAborted()
|
||||||
|
return Response.json({})
|
||||||
|
},
|
||||||
|
),
|
||||||
|
new RegExp(`^Error: Описание игры: ${message}$`),
|
||||||
|
)
|
||||||
|
assert.ok(failures.every((f) => f.reason === reason))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
import {
|
||||||
|
ApiError,
|
||||||
|
NetworkError,
|
||||||
|
SessionChangedError,
|
||||||
|
networkFailure,
|
||||||
|
type NetworkErrorCode,
|
||||||
|
} from './request-error.js'
|
||||||
import { createHash } from 'node:crypto'
|
import { createHash } from 'node:crypto'
|
||||||
|
|
||||||
export interface ScreenPackage {
|
export interface ScreenPackage {
|
||||||
@@ -30,6 +37,10 @@ export interface PackageRequest {
|
|||||||
gameId: number
|
gameId: number
|
||||||
token: string
|
token: string
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
|
onRequestFailure?: (failure: {
|
||||||
|
path: string
|
||||||
|
reason: NetworkErrorCode | 'SESSION_CHANGED' | 'AUTH' | 'HTTP' | 'INVALID_JSON'
|
||||||
|
}) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function object(value: unknown): Record<string, unknown> {
|
function object(value: unknown): Record<string, unknown> {
|
||||||
@@ -238,12 +249,35 @@ export async function prepareScreenPackage(
|
|||||||
redirect: 'error',
|
redirect: 'error',
|
||||||
signal,
|
signal,
|
||||||
})
|
})
|
||||||
} catch {
|
} catch (error) {
|
||||||
throw new Error(
|
const path = `/api/games/{id}${suffix}`
|
||||||
signal.aborted ? 'Подготовка отменена или превышено время ожидания' : 'Сервер недоступен',
|
if (error instanceof ApiError || error instanceof SessionChangedError) {
|
||||||
)
|
request.onRequestFailure?.({
|
||||||
|
path,
|
||||||
|
reason:
|
||||||
|
error instanceof ApiError
|
||||||
|
? [401, 403].includes(error.status)
|
||||||
|
? 'AUTH'
|
||||||
|
: 'HTTP'
|
||||||
|
: 'SESSION_CHANGED',
|
||||||
|
})
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
const failure = networkFailure(error, signal)
|
||||||
|
request.onRequestFailure?.({ path, reason: failure.code })
|
||||||
|
const name =
|
||||||
|
suffix === '/music'
|
||||||
|
? 'Музыка игры'
|
||||||
|
: suffix === '/screen-plan'
|
||||||
|
? 'План экранов'
|
||||||
|
: 'Описание игры'
|
||||||
|
throw new NetworkError(`${name}: ${failure.message}`, failure.code)
|
||||||
}
|
}
|
||||||
if (!response.ok)
|
if (!response.ok) {
|
||||||
|
request.onRequestFailure?.({
|
||||||
|
path: `/api/games/{id}${suffix}`,
|
||||||
|
reason: [401, 403].includes(response.status) ? 'AUTH' : 'HTTP',
|
||||||
|
})
|
||||||
throw new Error(
|
throw new Error(
|
||||||
response.status === 401
|
response.status === 401
|
||||||
? 'Требуется авторизация'
|
? 'Требуется авторизация'
|
||||||
@@ -251,9 +285,11 @@ export async function prepareScreenPackage(
|
|||||||
? 'Нет доступа к игре'
|
? 'Нет доступа к игре'
|
||||||
: `Ошибка API: HTTP ${response.status}`,
|
: `Ошибка API: HTTP ${response.status}`,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
return await response.json()
|
return await response.json()
|
||||||
} catch {
|
} catch {
|
||||||
|
request.onRequestFailure?.({ path: `/api/games/{id}${suffix}`, reason: 'INVALID_JSON' })
|
||||||
throw new Error('Некорректный JSON в ответе API')
|
throw new Error('Некорректный JSON в ответе API')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ module.exports = async function onlineSmoke({
|
|||||||
displayId,
|
displayId,
|
||||||
root,
|
root,
|
||||||
}) {
|
}) {
|
||||||
|
let gamesCalls = 0
|
||||||
|
let varyGames = false
|
||||||
let version = 1
|
let version = 1
|
||||||
let offline = false
|
let offline = false
|
||||||
let polls = 0
|
let polls = 0
|
||||||
@@ -47,8 +49,15 @@ module.exports = async function onlineSmoke({
|
|||||||
return send({ accessToken: 'TEST-ACCESS', refreshToken: 'TEST-REFRESH' })
|
return send({ accessToken: 'TEST-ACCESS', refreshToken: 'TEST-REFRESH' })
|
||||||
}
|
}
|
||||||
if (path === '/api/users/me') return send({ user: { roles: ['organizer'] } })
|
if (path === '/api/users/me') return send({ user: { roles: ['organizer'] } })
|
||||||
if (path === '/api/games')
|
if (path === '/api/games') {
|
||||||
return send({ games: [{ ID: 6, name: 'HTTP smoke', status: 'finish' }] })
|
gamesCalls++
|
||||||
|
return send({
|
||||||
|
games:
|
||||||
|
varyGames && gamesCalls % 2 === 0
|
||||||
|
? []
|
||||||
|
: [{ ID: 6, name: 'HTTP smoke', status: 'finish' }],
|
||||||
|
})
|
||||||
|
}
|
||||||
if (path === '/api/games/6')
|
if (path === '/api/games/6')
|
||||||
return send({
|
return send({
|
||||||
game: {
|
game: {
|
||||||
@@ -173,6 +182,48 @@ module.exports = async function onlineSmoke({
|
|||||||
),
|
),
|
||||||
'server game selector',
|
'server game selector',
|
||||||
)
|
)
|
||||||
|
varyGames = true
|
||||||
|
for (let refresh = 0; refresh < 5; refresh++) {
|
||||||
|
await poll(
|
||||||
|
() =>
|
||||||
|
evaluate(
|
||||||
|
`!Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === 'Обновить список игр')?.disabled`,
|
||||||
|
),
|
||||||
|
'refresh games button ready',
|
||||||
|
)
|
||||||
|
const previousCalls = gamesCalls
|
||||||
|
await evaluate(
|
||||||
|
`Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === 'Обновить список игр').click()`,
|
||||||
|
)
|
||||||
|
await poll(
|
||||||
|
async () =>
|
||||||
|
gamesCalls > previousCalls && !(await evaluate('window.screenBridge.onlineState()')).busy,
|
||||||
|
'refresh games complete',
|
||||||
|
)
|
||||||
|
await poll(
|
||||||
|
() =>
|
||||||
|
evaluate(
|
||||||
|
`document.querySelector('.connection-content')?.textContent.includes('Управление не подключено')`,
|
||||||
|
),
|
||||||
|
'connection status rendered',
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
await evaluate(
|
||||||
|
`document.querySelector('.connection-content').textContent.split('Управление не подключено').length - 1`,
|
||||||
|
),
|
||||||
|
1,
|
||||||
|
'refresh does not duplicate status',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
await poll(
|
||||||
|
() =>
|
||||||
|
evaluate(
|
||||||
|
`!Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === 'Обновить список игр')?.disabled`,
|
||||||
|
),
|
||||||
|
'last refresh complete',
|
||||||
|
)
|
||||||
|
varyGames = false
|
||||||
|
await act({ action: 'games' })
|
||||||
await evaluate(
|
await evaluate(
|
||||||
`Array.from(document.querySelectorAll('.n-base-selection')).find(e => e.textContent.includes('Выберите игру для подготовки')).click()`,
|
`Array.from(document.querySelectorAll('.n-base-selection')).find(e => e.textContent.includes('Выберите игру для подготовки')).click()`,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { NAlert, NButton, NCard, NInput, NSelect, NSpace, useMessage } from 'naive-ui'
|
import { NAlert, NButton, NCard, NInput, NSelect, NSpace, useMessage } from 'naive-ui'
|
||||||
import { onMounted, onUnmounted, ref } from 'vue'
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
import type { OnlineIntent, OnlineState } from '../types/online'
|
import { DEFAULT_SERVER, type OnlineIntent, type OnlineState } from '../types/online'
|
||||||
|
|
||||||
defineProps<{ outputOpen: boolean; selectedKey: string | null; selectedName: string | null }>()
|
defineProps<{ outputOpen: boolean; selectedKey: string | null; selectedName: string | null }>()
|
||||||
const bridge = window.screenBridge
|
const bridge = window.screenBridge
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const isReady = ref(false)
|
const isReady = ref(false)
|
||||||
const state = ref<OnlineState>({
|
const state = ref<OnlineState>({
|
||||||
server: '',
|
server: DEFAULT_SERVER,
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
remembered: false,
|
remembered: false,
|
||||||
busy: false,
|
busy: false,
|
||||||
@@ -23,7 +23,7 @@ const state = ref<OnlineState>({
|
|||||||
preparation: { active: false, completed: 0, total: 0, downloadedBytes: 0, error: '' },
|
preparation: { active: false, completed: 0, total: 0, downloadedBytes: 0, error: '' },
|
||||||
packagesRevision: 0,
|
packagesRevision: 0,
|
||||||
})
|
})
|
||||||
const server = ref('')
|
const server = ref(DEFAULT_SERVER)
|
||||||
const email = ref('')
|
const email = ref('')
|
||||||
const password = ref('')
|
const password = ref('')
|
||||||
const gameId = ref<number | null>(null)
|
const gameId = ref<number | null>(null)
|
||||||
@@ -90,7 +90,7 @@ onUnmounted(() => clearInterval(timer))
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<n-card v-if="bridge && isReady" title="Игра и сервер">
|
<n-card v-if="bridge && isReady" title="Игра и сервер">
|
||||||
<n-space vertical>
|
<div class="connection-content">
|
||||||
<template v-if="!state.authenticated">
|
<template v-if="!state.authenticated">
|
||||||
<label for="server">Адрес сервера</label>
|
<label for="server">Адрес сервера</label>
|
||||||
<n-input
|
<n-input
|
||||||
@@ -248,6 +248,18 @@ onUnmounted(() => clearInterval(timer))
|
|||||||
</p>
|
</p>
|
||||||
<n-button v-if="state.canCancel" @click="cancel">Отменить загрузку</n-button>
|
<n-button v-if="state.canCancel" @click="cancel">Отменить загрузку</n-button>
|
||||||
</template>
|
</template>
|
||||||
</n-space>
|
</div>
|
||||||
</n-card>
|
</n-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.connection-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-content > button {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { ScreenStep } from './screen.js'
|
import type { ScreenStep } from './screen.js'
|
||||||
|
|
||||||
|
export const DEFAULT_SERVER = 'https://evening-detective.crabs-games.art'
|
||||||
|
|
||||||
export type OnlineIntent =
|
export type OnlineIntent =
|
||||||
| { action: 'plan-save'; key: string; steps: ScreenStep[] }
|
| { action: 'plan-save'; key: string; steps: ScreenStep[] }
|
||||||
| { action: 'plan-refresh' }
|
| { action: 'plan-refresh' }
|
||||||
|
|||||||
Reference in New Issue
Block a user