This commit is contained in:
2026-10-18 14:15:43 +07:00
parent 0cd736f682
commit 913b237cb8
14 changed files with 1042 additions and 100 deletions
+73 -4
View File
@@ -8,6 +8,7 @@ import {
screen,
session,
safeStorage,
shell,
powerMonitor,
powerSaveBlocker,
} from 'electron'
@@ -43,6 +44,7 @@ let closingWindow: BrowserWindow | null = null
let openingOutput = false
let outputEpoch = 0
let quitting = false
let startup: BrowserWindow | null = null
let selectedDisplay: number | null = null
let online: Online | null = null
let settings: WindowSettings
@@ -269,13 +271,40 @@ async function show(displayId: number) {
// Две копии приложения не должны одновременно менять указатели пакетов.
if (!app.requestSingleInstanceLock()) app.exit(0)
app.on('second-instance', () => {
control?.show()
control?.focus()
const window = control ?? startup
window?.show()
window?.focus()
})
app.on('before-quit', () => {
quitting = true
})
app
.whenReady()
.then(async () => {
startup = new BrowserWindow({
width: 440,
height: 200,
resizable: false,
title: 'Проверка файлов',
webPreferences: { contextIsolation: true, sandbox: true, nodeIntegration: false },
})
const startupWindow = startup
startupWindow.setMenu(null)
startupWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
startupWindow.webContents.on('will-navigate', (event) => event.preventDefault())
startupWindow.on('closed', () => {
if (startup === startupWindow) {
startup = null
app.quit()
}
})
await startupWindow.loadURL(
'data:text/html;charset=utf-8,' +
encodeURIComponent(
`<!doctype html><html lang="ru"><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'"><title>Проверка файлов</title><style>body{background:#18181c;color:#eee;font:16px system-ui;padding:24px}progress{width:100%;accent-color:#63e2b7}p{margin:12px 0}</style><p id="message" role="status">Проверка загруженных файлов…</p><progress id="progress"></progress><p id="count">Поиск и проверка пакетов…</p></html>`,
),
)
diagnostics = new DiagnosticLog(join(app.getPath('userData'), 'logs'))
diagnostics.record('app-start', {
version: app.getVersion(),
@@ -295,7 +324,29 @@ app
const packages = new PackageStore(join(app.getPath('userData'), 'packages'), {
fetch: fetchNetwork,
})
await packages.initialize()
let lastProgressUpdate = 0
await packages.initialize((progress) => {
if (startupWindow.isDestroyed()) return
const now = Date.now()
if (
progress.total !== null &&
progress.completed !== progress.total &&
now - lastProgressUpdate < 100
)
return
lastProgressUpdate = now
const { completed, total } = progress
void startupWindow.webContents
.executeJavaScript(
`(() => {
const bar = document.getElementById('progress');
const count = document.getElementById('count');
${total === null ? "bar.removeAttribute('value');" : `bar.max = ${Math.max(total, 1)}; bar.value = ${completed}; count.textContent = 'Проверено файлов: ${completed} из ${total}';`}
})()`,
)
.catch(() => {})
})
if (quitting || startupWindow.isDestroyed()) return
online = new Online(
join(app.getPath('userData'), 'connection'),
packages,
@@ -324,6 +375,7 @@ app
},
)
await online.initialize()
if (quitting || startupWindow.isDestroyed()) return
let previousConnection = online.state.connection
const logConnection = () => {
const status = online!.state.connection
@@ -425,6 +477,8 @@ app
keepControlOnPrimary()
if (settings.value.control?.maximized) control!.maximize()
control!.show()
startup = null
if (!startupWindow.isDestroyed()) startupWindow.destroy()
})
const scheduleSave = () => {
clearTimeout(saveTimer)
@@ -503,6 +557,15 @@ app
selectingPackage = false
}
})
ipcMain.handle('open-materials-folder', async (event, key: unknown) => {
requireControl(event)
if (typeof key !== 'string' || key !== controller.state.catalog.key)
throw new Error('Выберите текущую игру')
for (const path of await packages.folders(key)) {
const error = await shell.openPath(path)
if (error) throw new Error('Не удалось открыть папку материалов')
}
})
let localSession = ''
let localSequence = 0
const pendingCommands = new Set<string>()
@@ -637,6 +700,12 @@ app
.catch(() => {
diagnostics?.record('startup-error')
console.error('SCREEN_STARTUP_FAILED')
app.quit()
if (startup && !startup.isDestroyed()) {
void startup.webContents
.executeJavaScript(
`document.getElementById('message').textContent = 'Не удалось проверить файлы. Проверьте доступ к папке данных приложения и перезапустите его.'; document.getElementById('progress').hidden = true; document.getElementById('count').textContent = '';`,
)
.catch(() => app.quit())
} else app.quit()
})
app.on('window-all-closed', () => app.quit())
+210 -7
View File
@@ -47,6 +47,8 @@ async function fixture(t: TestContext, secure = true) {
playlistError: 0,
upload: null as { filename: string; data: Buffer } | null,
bindings: [] as Record<string, unknown>[],
unbindError: 0,
createScreenError: 0,
offline: false,
server: 'https://server.test',
introURL: 'https://media.test/intro.wav',
@@ -109,7 +111,14 @@ async function fixture(t: TestContext, secure = true) {
model.refreshed = true
return json({ accessToken: 'ACCESS-ROTATED', refreshToken: 'REFRESH-ROTATED' })
}
if (path === '/api/screens') return json({ screenID: '12', accessToken: 'SCREEN-SECRET' })
if (path === '/api/screens')
return model.createScreenError
? json({}, model.createScreenError)
: json({ screenID: '12', accessToken: 'SCREEN-SECRET' })
if (path.endsWith('/unbind')) {
assert.equal(auth, 'Bearer ACCESS-SECRET')
return json({}, model.unbindError || 200)
}
if (path === '/api/screens/state') {
assert.equal(auth, null)
if (model.stateResponse) return model.stateResponse()
@@ -150,10 +159,10 @@ async function fixture(t: TestContext, secure = true) {
{ ID: 7, name: model.title, status: 'finish', teams: [{ password: 'TEAM-SECRET' }] },
],
})
if (path === '/api/games/7')
if (path === '/api/games/7' || path === '/api/games/8')
return json({
game: {
ID: 7,
ID: Number(path.split('/').at(-1)),
name: model.title,
scenario: model.omitScenario
? null
@@ -176,6 +185,8 @@ async function fixture(t: TestContext, secure = true) {
}
return json({ plan: model.plan.steps.length ? model.plan : null })
}
if (path === '/api/games/8/screen-plan') return json({ plan: null })
if (path === '/api/games/8/music') return json({ playlists: [] })
if (path === '/api/games/7/screen-plan/step') {
if (model.stepResponse) return model.stepResponse()
model.planTransitions++
@@ -325,6 +336,200 @@ test('чистый профиль предлагает адрес сайта п
assert.equal(f.online.snapshot().server, 'https://evening-detective.crabs-games.art')
})
test('список материалов показывает отдельную загрузку, ошибку, повтор и кеш после перезапуска', async (t) => {
const f = await fixture(t)
await f.login()
await f.online.act({ action: 'connect', gameId: 7 })
const key = f.controller.state.catalog.key!
const materials = f.online.snapshot().materials
assert.ok(materials.length >= 3)
assert.ok(materials.every((m) => m.status === 'missing'))
const intro = materials.find((m) => m.name === 'Вступление')!
assert.ok(intro)
assert.deepEqual(intro.usages, [
{ groupId: 'game', groupName: 'Игра', name: 'Вступление', order: 2 },
])
const music = materials.filter((m) => m.usages.some((u) => u.groupId.startsWith('playlist-')))
assert.equal(music.length, 2)
assert.deepEqual(music.flatMap((m) => m.usages.map((u) => u.order)).sort(), [0, 1])
f.model.failMedia = true
await assert.rejects(f.online.act({ action: 'download-material', key, assetId: intro.id }))
assert.equal(f.online.snapshot().materials.find((m) => m.id === intro.id)!.status, 'error')
f.model.failMedia = false
let release!: () => void
let requested!: () => void
const started = new Promise<void>((resolve) => {
requested = resolve
})
f.model.mediaHandler = async () => {
requested()
await new Promise<void>((resolve) => {
release = resolve
})
return new Response(Buffer.from('RIFFtest-WAVE'), { headers: { 'Content-Type': 'audio/wav' } })
}
const download = f.online.act({ action: 'download-material', key, assetId: intro.id })
await started
assert.equal(f.online.snapshot().materials.find((m) => m.id === intro.id)!.status, 'loading')
release()
await download
assert.equal(f.online.snapshot().materials.find((m) => m.id === intro.id)!.status, 'ready')
assert.ok(f.online.snapshot().materials.some((m) => m.status === 'missing'))
assert.equal(f.controller.state.catalog.media.find((m) => m.id === 'intro')!.ready, true)
assert.deepEqual(await f.store.folders(key), [join(f.directory, 'packages', key, 'cache')])
await assert.rejects(f.store.folders('../'), /Некорректный/)
await assert.rejects(
f.online.act({ action: 'download-material', key: 'other', assetId: intro.id }),
)
await assert.rejects(f.online.act({ action: 'download-material', key, assetId: 'unknown' }))
await f.online.close()
await f.store.initialize()
await f.online.initialize()
assert.equal(f.online.snapshot().materials.find((m) => m.id === intro.id)!.status, 'ready')
const file = await f.store.localFile(key, intro.id)
await rm(file.path)
await f.online.select(key)
assert.equal(f.online.snapshot().materials.find((m) => m.id === intro.id)!.status, 'missing')
await f.online.select(null)
assert.deepEqual(f.online.snapshot().materials, [])
})
test('готовый пакет показывает все материалы загруженными без входа', async (t) => {
const f = await fixture(t)
await f.prepare()
await f.online.act({ action: 'logout' })
assert.ok(f.online.snapshot().materials.length > 0)
assert.ok(f.online.snapshot().materials.every((m) => m.status === 'ready'))
})
test('один файл сохраняет назначения в группе игры и плейлиста без дублирования счётчика', async (t) => {
const f = await fixture(t)
await f.login()
f.model.musicOverride = {
gamePlaylistId: 1,
playlists: [
{
ID: 1,
name: 'Общий плейлист',
tracks: [{ ID: 1, name: 'Общий трек', filename: f.model.introURL, position: 0 }],
},
],
}
await f.online.act({ action: 'connect', gameId: 7 })
const id = f.online
.snapshot()
.materials.find((m) => m.usages.some((u) => u.name === 'Вступление'))!.id
const materials = f.online.snapshot().materials
assert.equal(materials.filter((m) => m.id === id).length, 1)
assert.deepEqual(
materials.find((m) => m.id === id)!.usages.map((u) => u.groupId),
['game', 'playlist-1'],
)
})
for (const gameId of [7, 8]) {
test(`переподключение к игре ${gameId} сначала отвязывает прежний экран`, async (t) => {
const f = await fixture(t)
await f.prepare()
assert.equal(
f.requests.some((r) => r.path.endsWith('/unbind')),
false,
)
const start = f.requests.length
await f.online.act({ action: 'connect', gameId })
const paths = f.requests.slice(start).map((r) => r.path)
assert.equal(paths[0], '/api/screens/12/unbind')
assert.ok(paths.indexOf('/api/screens') > 0)
assert.ok(paths.indexOf('/api/screens/12/bind') > paths.indexOf('/api/screens'))
assert.equal(f.model.bindings.at(-1)!.gameID, gameId)
assert.equal(f.online.snapshot().connection, 'waiting')
})
}
test('ошибка отвязки сохраняет прежнюю сессию и не создаёт новую', async (t) => {
const f = await fixture(t)
await f.prepare()
f.model.unbindError = 500
const start = f.requests.length
await assert.rejects(f.online.act({ action: 'connect' }), /Отключение экрана/)
assert.equal(f.online.snapshot().screenId, '12')
assert.equal(
f.requests.slice(start).some((r) => r.path === '/api/screens'),
false,
)
f.model.unbindError = 0
await f.online.act({ action: 'connect' })
assert.equal(f.online.snapshot().connection, 'waiting')
})
test('отсутствующий прежний экран не мешает переподключению', async (t) => {
const f = await fixture(t)
await f.prepare()
f.model.unbindError = 404
await f.online.act({ action: 'connect' })
assert.equal(f.online.snapshot().connection, 'waiting')
})
test('ошибка создания после отвязки оставляет экран отключённым после перезапуска', async (t) => {
const f = await fixture(t)
await f.prepare()
f.model.createScreenError = 500
await assert.rejects(f.online.act({ action: 'connect' }))
assert.equal(f.online.snapshot().connection, 'disabled')
assert.equal(f.online.snapshot().screenId, null)
await f.online.close()
await f.online.initialize()
assert.equal(f.online.snapshot().screenId, null)
assert.equal(f.online.snapshot().connection, 'disabled')
})
test('сброс текущей игры отключает управление, сохраняет материалы и вход', async (t) => {
const f = await fixture(t)
await f.prepare()
const packages = f.store.list().map((pkg) => pkg.key)
await f.online.select(null)
assert.equal(f.controller.state.catalog.key, null)
assert.equal(f.online.snapshot().currentGameId, null)
assert.equal(f.online.snapshot().connection, 'disabled')
assert.equal(f.online.snapshot().screenId, null)
assert.equal(f.online.snapshot().authenticated, true)
assert.deepEqual(
f.store.list().map((pkg) => pkg.key),
packages,
)
await f.online.close()
await f.online.initialize()
assert.equal(f.controller.state.catalog.key, null)
assert.equal(f.online.snapshot().currentGameId, null)
})
test('статус запроса виден во время загрузки и очищается после успеха или ошибки', async (t) => {
const f = await fixture(t)
await f.login()
const loading = f.online.act({ action: 'playlists' })
assert.equal(f.online.snapshot().busy, true)
assert.equal(f.online.snapshot().busyMessage, 'Загрузка списка плейлистов…')
await loading
assert.equal(f.online.snapshot().busyMessage, '')
f.model.playlistError = 500
const failing = f.online.act({ action: 'playlists' })
assert.equal(f.online.snapshot().busyMessage, 'Загрузка списка плейлистов…')
await assert.rejects(failing)
assert.equal(f.online.snapshot().busy, false)
assert.equal(f.online.snapshot().busyMessage, '')
})
test('состояние сообщает ID выбранной игры только для текущего сервера', async (t) => {
const f = await fixture(t)
assert.equal(f.online.snapshot().currentGameId, null)
await f.login()
await f.online.act({ action: 'download', gameId: 7 })
await f.online.select(f.store.list()[0]!.key)
assert.equal(f.online.snapshot().currentGameId, 7)
f.online.state.server = 'https://other.test'
assert.equal(f.online.snapshot().currentGameId, null)
})
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'],
@@ -793,12 +998,10 @@ test('назначение звука подтверждается сервер
await f.online.act({ action: 'connect' })
assert.equal(f.online.state.soundEnabled, true)
await f.online.poll()
const unbindsBeforeDisconnect = f.requests.filter((r) => r.path.endsWith('/unbind')).length
await f.online.act({ action: 'disconnect' })
assert.equal(f.online.state.soundEnabled, null)
assert.equal(
f.requests.some((r) => r.path.endsWith('/unbind')),
false,
)
assert.equal(f.requests.filter((r) => r.path.endsWith('/unbind')).length, unbindsBeforeDisconnect)
})
test('план: сохранение и удаление на сервере, восстановление копии офлайн, ошибки валидации и доступа', async (t) => {
+111 -3
View File
@@ -4,7 +4,12 @@ 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'
import { DEFAULT_SERVER, type OnlineIntent, type OnlineState } from '../src/types/online.js'
import {
DEFAULT_SERVER,
type GameMaterial,
type OnlineIntent,
type OnlineState,
} from '../src/types/online.js'
import type {
Intent,
PlanStatus,
@@ -67,9 +72,12 @@ function token(value: unknown): string {
export class Online {
readonly state: OnlineState = {
server: DEFAULT_SERVER,
currentGameId: null,
materials: [],
authenticated: false,
remembered: false,
busy: false,
busyMessage: '',
canCancel: false,
games: [],
playlists: [],
@@ -141,6 +149,7 @@ export class Online {
const local = await this.packages.remember(description)
if (generation !== this.generation) return
this.controller.selectPackage(local, true)
await this.checkMaterials(local.package)
this.state.packagesRevision++
}
this.materialSignature = signature
@@ -831,7 +840,55 @@ export class Online {
return this.state.busy
}
snapshot(): OnlineState {
return structuredClone(this.state)
const pkg = this.selectedGame()?.package
const names = new Map<string, string[]>()
const usages = new Map<string, GameMaterial['usages']>()
const add = (
id: string | null,
name: string,
order: number,
groupId = 'game',
groupName = 'Игра',
) => {
if (!id) return
names.set(id, [
...(names.get(id) ?? []),
groupId === 'game' ? name : `${groupName} · ${name}`,
])
usages.set(id, [...(usages.get(id) ?? []), { groupId, groupName, name, order }])
}
if (pkg) {
add(pkg.media.cover, 'Обложка', 0)
add(pkg.media.background, 'Фон', 1)
add(pkg.media.intro, 'Вступление', 2)
add(pkg.media.conclusion, 'Заключение', 3)
for (const playlist of pkg.playlists)
for (const track of playlist.tracks)
add(track.assetId, track.name, track.position, `playlist-${playlist.id}`, playlist.name)
}
const key = this.controller.state.catalog.key
return structuredClone({
...this.state,
currentGameId: pkg?.server === this.state.server ? pkg.game.id : null,
materials:
pkg && key
? pkg.assets.map((asset) => ({
id: asset.id,
name: names.get(asset.id)?.join(', ') ?? 'Материал игры',
usages: usages.get(asset.id) ?? [
{ groupId: 'game', groupName: 'Игра', name: 'Материал игры', order: 4 },
],
...this.packages.assetStatus(key, asset.id),
...(this.downloadKey === key && this.state.progress?.assetId === asset.id
? { status: 'loading' as const }
: {}),
}))
: [],
})
}
private async checkMaterials(pkg: ScreenPackage) {
const key = packageKey(pkg.server, pkg.game.id)
for (const asset of pkg.assets) await this.packages.checkAsset(key, asset.id)
}
private async atomic(name: string, data: string | Buffer) {
await mkdir(this.directory, { recursive: true, mode: 0o700 })
@@ -929,6 +986,7 @@ export class Online {
}
this.detach()
this.controller.selectPackage(pkg)
if (pkg) await this.checkMaterials(pkg.package)
this.plan = { steps: structuredClone(this.controller.state.catalog.steps), currentStep: 0 }
this.planNotice = ''
this.lastPlan = -Infinity
@@ -1098,6 +1156,33 @@ export class Online {
if (this.busy) throw new Error('Подождите завершения текущего действия')
const isBackgroundRefresh = data.action === 'playlists'
this.state.busy = true
const messages: Record<OnlineIntent['action'], string> = {
playlists: 'Загрузка списка плейлистов…',
'download-material': 'Загрузка файла игры…',
'playlist-create': 'Создание плейлиста…',
'playlist-rename': 'Сохранение названия плейлиста…',
'playlist-delete': 'Удаление плейлиста…',
'playlist-upload': 'Загрузка трека на сервер…',
'playlist-use': 'Подключение плейлиста к игре…',
'playlist-track-delete': 'Удаление трека…',
'playlist-reorder': 'Сохранение порядка треков…',
'plan-save': 'Сохранение плана экранов…',
'plan-refresh': 'Загрузка плана экранов…',
login: 'Вход на сервер…',
logout: 'Выход из аккаунта…',
games: 'Загрузка списка игр…',
connect: 'Подключение игры…',
disconnect: 'Отключение игры…',
download: 'Загрузка материалов игры…',
check: 'Проверка материалов игры…',
'retry-materials': 'Подготовка материалов игры…',
remove: 'Удаление локальных материалов…',
sound: 'Изменение назначения звука…',
results: 'Загрузка результатов игры…',
cancel: 'Отмена загрузки…',
}
this.state.busyMessage =
messages[data.action as OnlineIntent['action']] ?? 'Выполнение запроса…'
if (!isBackgroundRefresh) {
this.state.error = ''
this.state.notice = ''
@@ -1110,6 +1195,7 @@ export class Online {
throw new Error(message)
} finally {
this.state.busy = false
this.state.busyMessage = ''
this.state.progress = null
}
return this.snapshot()
@@ -1117,6 +1203,17 @@ export class Online {
private async perform(data: Record<string, unknown>) {
const action = data.action as OnlineIntent['action']
switch (action) {
case 'download-material': {
const pkg = this.selectedGame()
const assetId = text(data.assetId)
if (!pkg || data.key !== pkg.key || !pkg.package.assets.some((a) => a.id === assetId))
throw new Error('Материал не принадлежит текущей игре')
const generation = this.generation
await this.packages.ensureAsset(pkg.package, assetId, this.materialAbort.signal)
if (generation === this.generation && this.controller.state.catalog.key === pkg.key)
this.controller.markAssetReady(assetId)
return
}
case 'playlists':
return this.loadPlaylists()
case 'playlist-create':
@@ -1267,11 +1364,22 @@ export class Online {
let pkg = this.selectedGame()
const gameId = data.gameId == null ? pkg?.package.game.id : integer(data.gameId, 1)
if (!gameId) throw new Error('Выберите игру на сервере')
this.detach()
if (this.screen) {
this.state.busyMessage = 'Отключение предыдущего подключения…'
try {
await this.client().UnbindScreen({ screenID: this.screen.id })
} catch (error) {
if (!(error instanceof ApiError && error.status === 404)) throw error
}
this.detach()
await this.persistSecrets()
} else this.detach()
this.state.busyMessage = 'Подключение игры…'
if (!pkg || pkg.package.game.id !== gameId || pkg.package.server !== this.state.server) {
pkg = await this.packages.remember(await this.description(gameId))
this.controller.selectPackage(pkg)
this.plan = { steps: this.controller.state.catalog.steps, currentStep: 0 }
await this.checkMaterials(pkg.package)
this.state.packagesRevision++
await this.saveSelection()
}
+82 -1
View File
@@ -7,7 +7,12 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test, type TestContext } from 'node:test'
import { buildScreenPackage, parseScreenPackage } from './screen-package.js'
import { PackageStore, packageKey, type DownloadProgress } from './package-store.js'
import {
PackageStore,
packageKey,
type DownloadProgress,
type VerificationProgress,
} from './package-store.js'
import { packageMediaResponse } from './package-media.js'
const highSpace = 1n << 40n
@@ -65,6 +70,82 @@ async function fixture(t: TestContext) {
return { root, dir, state, calls, delays, dependencies, store, pkg, key }
}
test('запуск без сети проверяет отдельный кеш всех игр и сообщает прогресс', async (t) => {
const f = await fixture(t)
const other = description('https://other.test')
const otherKey = packageKey(other.server, other.game.id)
for (const pkg of [f.pkg, other]) {
await f.store.remember(pkg)
await f.store.ensureAsset(pkg, pkg.assets[0]!.id, new AbortController().signal)
}
const calls = f.calls.length
f.state.handler = async () => {
throw new Error('Нет сети')
}
const restored = new PackageStore(f.root, f.dependencies)
const progress: VerificationProgress[] = []
await restored.initialize((value) => progress.push(value))
assert.equal(f.calls.length, calls)
for (const [pkg, key] of [
[f.pkg, f.key],
[other, otherKey],
] as const) {
assert.equal(restored.assetStatus(key, pkg.assets[0]!.id).status, 'ready')
assert.equal(restored.assetStatus(key, pkg.assets[1]!.id).status, 'missing')
}
assert.equal(progress[0]!.total, null)
assert.deepEqual(progress.at(-1), { completed: 4, total: 4 })
assert.deepEqual(
progress.slice(1).map((p) => p.completed),
[0, 1, 2, 3, 4],
)
})
test('перезапуск выявляет подмену кеша того же размера, удаление и симлинк', async (t) => {
const f = await fixture(t)
await f.store.remember(f.pkg)
const id = f.pkg.assets[0]!.id
await f.store.ensureAsset(f.pkg, id, new AbortController().signal)
const file = await f.store.localFile(f.key, id)
await writeFile(file.path, Buffer.alloc(file.size, 42))
await f.store.initialize()
assert.equal(f.store.assetStatus(f.key, id).status, 'missing')
await f.store.ensureAsset(f.pkg, id, new AbortController().signal)
await rm(file.path)
await symlink(join(f.dir, 'outside'), file.path)
await f.store.initialize()
assert.equal(f.store.assetStatus(f.key, id).status, 'missing')
await rm(file.path)
await f.store.initialize()
assert.equal(f.store.assetStatus(f.key, id).status, 'missing')
})
test('пустое хранилище завершает стартовую проверку с нулевым счётчиком', async (t) => {
const f = await fixture(t)
const progress: VerificationProgress[] = []
await f.store.initialize((p) => progress.push(p))
assert.deepEqual(progress, [
{ completed: 0, total: null },
{ completed: 0, total: 0 },
])
})
test('открытие папки выбирает реальные файлы полного пакета и восстановленного кеша', async (t) => {
const f = await fixture(t)
await f.store.remember(f.pkg)
await assert.rejects(f.store.folders(f.key), /ещё не загружены/)
const local = await f.store.download(f.pkg)
const assets = join(f.root, f.key, `v-${local.version}`, 'assets')
assert.deepEqual(await f.store.folders(f.key), [assets])
const id = f.pkg.assets[0]!.id
await rm((await f.store.localFile(f.key, id)).path)
await f.store.ensureAsset(f.pkg, id, new AbortController().signal)
assert.deepEqual(
new Set(await f.store.folders(f.key)),
new Set([assets, join(f.root, f.key, 'cache')]),
)
})
test('загружает, проверяет с диска, восстанавливает после перезапуска и выдаёт офлайн Range', async (t) => {
const f = await fixture(t)
const progress: DownloadProgress[] = []
+89 -16
View File
@@ -1,7 +1,7 @@
import { createHash, randomUUID } from 'node:crypto'
import { createReadStream } from 'node:fs'
import { lstat, mkdir, open, readFile, readdir, rename, rm, statfs } from 'node:fs/promises'
import { extname, join, resolve } from 'node:path'
import { dirname, extname, join, resolve } from 'node:path'
import { setTimeout as wait } from 'node:timers/promises'
import { parseScreenPackage, type ScreenPackage } from './screen-package.js'
@@ -48,6 +48,10 @@ export interface DownloadProgress {
totalFiles: number
totalBytes: number | null
}
export interface VerificationProgress {
completed: number
total: number | null
}
interface Dependencies {
fetch?: typeof fetch
freeBytes?: () => Promise<bigint>
@@ -89,6 +93,7 @@ export class PackageStore {
private readonly repaired = new Set<string>()
private readonly checked = new Map<string, string>()
private readonly loading = new Map<string, Promise<void>>()
private readonly assetErrors = new Map<string, string>()
constructor(
root: string,
private readonly dependencies: Dependencies = {},
@@ -110,6 +115,21 @@ export class PackageStore {
}
return path
}
async folders(key: string) {
checkHex(key)
const pkg = this.list().find((pkg) => pkg.key === key)
if (!pkg) throw new Error('Игра не найдена')
const folders = new Set<string>()
for (const asset of pkg.package.assets) {
try {
folders.add(dirname((await this.localFile(key, asset.id)).path))
} catch {
/* Отсутствующий файл не должен вести пользователя в пустую папку. */
}
}
if (!folders.size) throw new Error('Материалы игры ещё не загружены')
return [...folders]
}
private async exclusive<T>(action: () => Promise<T>): Promise<T> {
if (this.busy) throw new Error('Хранилище пакетов занято')
this.busy = true
@@ -152,13 +172,17 @@ export class PackageStore {
}
}
}
async initialize(): Promise<void> {
async initialize(onProgress: (progress: VerificationProgress) => void = () => {}): Promise<void> {
onProgress({ completed: 0, total: null })
await this.exclusive(async () => {
await this.path()
await mkdir(this.root, { recursive: true })
await this.path()
this.ready.clear()
this.descriptions.clear()
this.checked.clear()
this.repaired.clear()
this.assetErrors.clear()
for (const key of await readdir(this.root)) {
if (!hex.test(key)) continue
try {
@@ -194,6 +218,16 @@ export class PackageStore {
}
}
}
const packages = this.list()
const total = packages.reduce((count, pkg) => count + pkg.package.assets.length, 0)
let completed = 0
onProgress({ completed, total })
for (const pkg of packages) {
for (const asset of pkg.package.assets) {
await this.checkAsset(pkg.key, asset.id)
onProgress({ completed: ++completed, total })
}
}
})
}
private async load(key: string): Promise<LocalPackage> {
@@ -219,6 +253,7 @@ export class PackageStore {
if (packageKey(pkg.server, pkg.game.id) !== key || raw.files.length !== pkg.assets.length)
throw new Error('Неполный пакет')
const files: StoredFile[] = []
const signatures = new Map<string, string>()
for (let i = 0; i < pkg.assets.length; i++) {
const file = raw.files[i] as StoredFile
if (
@@ -231,13 +266,19 @@ export class PackageStore {
)
throw new Error('Повреждён список файлов')
const path = await this.path(key, `v-${version}`, 'assets', file.id)
if (!(await lstat(path)).isFile()) throw new Error('Ресурс не является файлом')
const info = await lstat(path)
if (!info.isFile()) throw new Error('Ресурс не является файлом')
const actual = await checksum(path)
if (actual.size !== file.size || actual.sha256 !== file.sha256)
throw new Error('Повреждён файл пакета')
files.push({ id: file.id, ...actual, mime: file.mime })
signatures.set(
`${key}/${file.id}`,
`${info.size}:${info.mtimeMs}:${info.ctimeMs}:${file.sha256}`,
)
}
if (versionOf(pkg, files) !== version) throw new Error('Повреждена версия пакета')
for (const [token, signature] of signatures) this.checked.set(token, signature)
return { formatVersion: 1, key, version, package: pkg, files }
}
list(): LocalPackage[] {
@@ -265,6 +306,12 @@ export class PackageStore {
await rm(dir, { recursive: true, force: true })
this.ready.delete(key)
this.descriptions.delete(key)
for (const token of this.checked.keys())
if (token.startsWith(`${key}/`)) this.checked.delete(token)
for (const token of this.repaired)
if (token.startsWith(`${key}/`)) this.repaired.delete(token)
for (const token of this.assetErrors.keys())
if (token.startsWith(`${key}/`)) this.assetErrors.delete(token)
})
}
private descriptionPackage(pkg: ScreenPackage): LocalPackage {
@@ -348,6 +395,37 @@ export class PackageStore {
?.files.some((f) => f.id === assetId && !this.repaired.has(`${key}/${assetId}`))
)
}
assetStatus(key: string, assetId: string) {
const token = `${key}/${assetId}`
const error = this.assetErrors.get(token) ?? ''
const status = this.loading.has(token)
? 'loading'
: this.canRead(key, assetId)
? 'ready'
: error
? 'error'
: 'missing'
return { status, error } as const
}
async checkAsset(key: string, assetId: string): Promise<boolean> {
const token = `${key}/${assetId}`
try {
const file = await this.localFile(key, assetId)
const stat = await lstat(file.path)
const signature = `${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}:${file.sha256}`
if (this.checked.get(token) === signature) return true
const actual = await checksum(file.path)
if (actual.size === file.size && actual.sha256 === file.sha256) {
this.checked.set(token, signature)
return true
}
} catch {
/* Отсутствующие и повреждённые файлы доступны для повторной загрузки. */
}
this.checked.delete(token)
this.repaired.add(token)
return false
}
async ensureAsset(
pkg: ScreenPackage,
assetId: string,
@@ -361,21 +439,10 @@ export class PackageStore {
const token = `${key}/${assetId}`
const existing = this.loading.get(token)
if (existing) return existing
this.assetErrors.delete(token)
const job = (async () => {
signal.throwIfAborted()
try {
const file = await this.localFile(key, assetId)
const stat = await lstat(file.path)
const signature = `${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}:${file.sha256}`
if (this.checked.get(token) === signature) return
const actual = await checksum(file.path)
if (actual.size === file.size && actual.sha256 === file.sha256) {
this.checked.set(token, signature)
return
}
} catch {
/* Отсутствующий или повреждённый файл скачивается заново. */
}
if (await this.checkAsset(key, assetId)) return
signal.throwIfAborted()
await this.remembering.get(key)
if (!this.descriptions.has(key)) await this.remember(pkg)
@@ -409,6 +476,8 @@ export class PackageStore {
await rename(metadata, await this.path(key, 'cache', `${assetId}.json`))
// Восстановленный кеш заменяет повреждённый ресурс старого пакета.
this.repaired.add(token)
const info = await lstat(await this.path(key, 'cache', assetId))
this.checked.set(token, `${info.size}:${info.mtimeMs}:${info.ctimeMs}:${file.sha256}`)
} finally {
await rm(path, { force: true })
await rm(metadata, { force: true })
@@ -417,6 +486,10 @@ export class PackageStore {
this.loading.set(token, job)
try {
await job
} catch (error) {
if (!signal.aborted)
this.assetErrors.set(token, error instanceof Error ? error.message : 'Файл недоступен')
throw error
} finally {
if (this.loading.get(token) === job) this.loading.delete(token)
}
+1
View File
@@ -21,4 +21,5 @@ contextBridge.exposeInMainWorld('screenBridge', {
show: (id: number) => ipcRenderer.invoke('show', id),
packages: () => ipcRenderer.invoke('packages'),
selectPackage: (key: string | null) => ipcRenderer.invoke('select-package', key),
openMaterialsFolder: (key: string) => ipcRenderer.invoke('open-materials-folder', key),
})
+1
View File
@@ -155,6 +155,7 @@ export function requestErrorContext(
GetGames: 'Список игр',
CreateScreenSession: 'Создание экрана',
BindScreen: 'Подключение экрана',
UnbindScreen: 'Отключение экрана',
SetScreenSound: 'Назначение звука',
SetGameMusic: 'Управление музыкой',
SetScreenMode: 'Режим экрана',
+37 -20
View File
@@ -245,10 +245,22 @@ module.exports = async function onlineSmoke({
})
await session.defaultSession.closeAllConnections()
const act = (intent) => evaluate(`window.screenBridge.onlineAction(${JSON.stringify(intent)})`)
const checkBadges = (authenticated, connected) =>
poll(
() => evaluate(`(() => {
const auth = document.querySelectorAll('.n-card-header [data-testid="auth-status"]')
const game = document.querySelectorAll('.n-card-header [data-testid="game-connection-status"]')
return auth.length === 1 && game.length === 1
&& auth[0].textContent.trim() === '${authenticated ? 'Вход выполнен' : 'Не выполнен'}'
&& game[0].textContent.trim() === '${connected ? 'Подключено' : 'Не подключено'}'
})()`),
'auth and game badges reflect separate states',
)
try {
await evaluate('window.screenBridge.closeOutput()')
// Вход через настоящую форму пульта, загрузка через защищённый IPC.
await poll(() => evaluate('!!document.querySelector("#server")'), 'login form')
await checkBadges(false, false)
await evaluate(`(() => {
for (const [id, value] of Object.entries(${JSON.stringify({ server: url, email: 'organizer@example.test', password: 'test-password' })})) {
const element = document.querySelector('#' + id)
@@ -282,7 +294,7 @@ module.exports = async function onlineSmoke({
)
assert.deepEqual(
await evaluate(
`Array.from(document.querySelectorAll('.n-card-header__main')).map(e => e.textContent.trim())`,
`Array.from(document.querySelectorAll('.n-card-header__main')).map(e => (e.querySelector('.server-heading > span:first-child') ?? e).textContent.trim())`,
),
['Сервер', 'Игра', 'Экран', 'План экранов', 'План музыки'],
)
@@ -317,38 +329,25 @@ module.exports = async function onlineSmoke({
await poll(
() =>
evaluate(
`!Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === 'Обновить список игр')?.disabled`,
`!document.querySelector('button[aria-label="Обновить список игр"]')?.disabled`,
),
'refresh games button ready',
)
const previousCalls = gamesCalls
await evaluate(
`Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === 'Обновить список игр').click()`,
`document.querySelector('button[aria-label="Обновить список игр"]').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 checkBadges(true, false)
}
await poll(
() =>
evaluate(
`!Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === 'Обновить список игр')?.disabled`,
`!document.querySelector('button[aria-label="Обновить список игр"]')?.disabled`,
),
'last refresh complete',
)
@@ -409,7 +408,7 @@ module.exports = async function onlineSmoke({
await poll(
() =>
evaluate(`(() => {
const button = Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === 'Подключить')
const button = Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === 'Подключиться')
if (!button || button.disabled) return false
button.click(); return true
})()`),
@@ -433,6 +432,11 @@ module.exports = async function onlineSmoke({
async () => (await evaluate('window.screenBridge.onlineState()')).connection === 'connected',
'connected with output closed',
)
await checkBadges(true, true)
await poll(
() => evaluate(`document.querySelector('.connection-content .n-base-selection')?.textContent.includes('HTTP smoke')`),
'current game automatically selected',
)
assert.equal((await state()).outputOpen, false)
await evaluate(`window.screenBridge.show(${displayId})`)
await poll(
@@ -469,6 +473,7 @@ module.exports = async function onlineSmoke({
async () => (await evaluate('window.screenBridge.onlineState()')).connection === 'offline',
'offline indication',
)
await checkBadges(true, false)
await command({ action: 'play', channel: 'main' })
offline = false
currentCommand = 'SCREEN_COMMAND_STOP'
@@ -830,7 +835,7 @@ module.exports = async function onlineSmoke({
await poll(
() =>
evaluate(`(() => {
const button = Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === 'Подключить')
const button = Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === 'Подключиться')
if (!button || button.disabled) return false
button.click(); return true
})()`),
@@ -843,6 +848,7 @@ module.exports = async function onlineSmoke({
assert.equal((await state()).catalog.name, 'HTTP smoke')
await act({ action: 'logout' })
await checkBadges(false, false)
assert.equal((await evaluate('window.screenBridge.onlineState()')).authenticated, false)
await poll(
() =>
@@ -851,6 +857,17 @@ module.exports = async function onlineSmoke({
),
'current local game stays visible after logout',
)
const packageKeys = (await evaluate('window.screenBridge.packages()')).map((pkg) => pkg.key)
await evaluate(`Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === 'Сбросить').click()`)
await poll(
async () => (await state()).catalog.key === null,
'reset clears current game',
)
await poll(
() => evaluate(`!document.querySelector('[data-testid="control-game"]')`),
'reset hides current game row',
)
assert.deepEqual((await evaluate('window.screenBridge.packages()')).map((pkg) => pkg.key), packageKeys)
assert.ok(requests.includes('/api/auth/login'), 'login through proxy')
assert.ok(requests.includes('/api/files/cached-intro.wav'), 'redirected media through proxy')
console.log('SCREEN_PROXY_SMOKE_PASS')
+8 -2
View File
@@ -200,7 +200,13 @@ async function poll(check, label) {
app.whenReady().then(async () => {
try {
await seeded
const control = await poll(() => BrowserWindow.getAllWindows()[0], 'control')
const control = await poll(
() =>
BrowserWindow.getAllWindows().find(
(w) => w.webContents.getURL() === 'screen-app://app/index.html',
),
'control',
)
if (process.platform === 'linux')
assert.ok(control.getNativeWindowHandle().readUInt32LE(0) > 0xffff, 'native X11 window ID')
control.webContents.on('console-message', (_event, details) =>
@@ -210,7 +216,7 @@ app.whenReady().then(async () => {
await poll(
async () =>
!control.webContents.isLoading() &&
(await evaluate('!!window.screenBridge && !!document.querySelector("h1")')),
(await evaluate('!!window.screenBridge && !!document.querySelector("main.control")')),
'control UI',
)
if (manual) {
+399 -44
View File
@@ -1,7 +1,24 @@
<script setup lang="ts">
import { NAlert, NButton, NCard, NInput, NSelect } from 'naive-ui'
import { ref, watch } from 'vue'
import { DEFAULT_SERVER, type OnlineIntent, type OnlineState } from '../types/online'
import {
NAlert,
NButton,
NCard,
NCollapse,
NCollapseItem,
NInput,
NSelect,
NSpin,
NTag,
NTable,
useMessage,
} from 'naive-ui'
import { computed, ref, watch } from 'vue'
import {
DEFAULT_SERVER,
type GameMaterial,
type OnlineIntent,
type OnlineState,
} from '../types/online'
const props = defineProps<{
state: OnlineState
@@ -11,17 +28,61 @@ const props = defineProps<{
selectedName: string | null
canConnectCurrent: boolean
}>()
const emit = defineEmits<{ action: [intent: OnlineIntent]; cancel: [] }>()
const emit = defineEmits<{ action: [intent: OnlineIntent]; cancel: []; resetGame: [] }>()
const bridge = window.screenBridge
const message = useMessage()
async function openMaterialsFolder() {
if (!bridge || !props.selectedKey) return
try {
await bridge.openMaterialsFolder(props.selectedKey)
} catch (error) {
message.error(error instanceof Error ? error.message : 'Не удалось открыть папку материалов')
}
}
const materialLabels = {
missing: 'Не загружен',
loading: 'Загружается…',
ready: 'Загружен',
error: 'Ошибка',
}
const loadedMaterials = computed(
() => props.state.materials.filter((m) => m.status === 'ready').length,
)
const materialGroups = computed(() => {
const groups = new Map<
string,
{ id: string; name: string; rows: { material: GameMaterial; name: string; order: number }[] }
>()
for (const material of props.state.materials) {
for (const usage of material.usages) {
let group = groups.get(usage.groupId)
if (!group) {
group = { id: usage.groupId, name: usage.groupName, rows: [] }
groups.set(group.id, group)
}
group.rows.push({ material, name: usage.name, order: usage.order })
}
}
return [...groups.values()]
.sort((a, b) => Number(b.id === 'game') - Number(a.id === 'game'))
.map((group) => ({ ...group, rows: group.rows.sort((a, b) => a.order - b.order) }))
})
const server = ref(DEFAULT_SERVER)
const email = ref('')
const password = ref('')
const gameId = ref<number | null>(null)
const showServerContent = computed(
() =>
!props.state.authenticated ||
!props.state.remembered ||
props.state.busy ||
!!props.state.error ||
!!props.state.notice,
)
watch(
() => props.state.server,
(value) => {
server.value = value
gameId.value = null
},
{ immediate: true },
)
@@ -29,9 +90,20 @@ watch(
() => props.state.authenticated,
() => {
password.value = ''
if (!props.state.authenticated) gameId.value = null
},
)
watch(
[
() => props.state.currentGameId,
() => props.state.server,
() => props.state.authenticated,
() => props.selectedKey,
],
() => {
gameId.value = props.state.authenticated ? props.state.currentGameId : null
},
{ immediate: true },
)
function run(intent: OnlineIntent) {
emit('action', intent)
if (intent.action === 'login') password.value = ''
@@ -45,17 +117,50 @@ const gameStatusLabels = new Map([
['pause', 'Пауза'],
['finish', 'Завершена'],
])
const connectionLabels = {
disabled: 'Управление не подключено',
waiting: 'Ожидание ответа сервера',
connected: 'Связь с сервером есть',
offline: 'Нет связи; локальный показ продолжается',
expired: 'Сессия экрана недействительна; подключите управление заново',
}
const gameOptions = computed(() => {
const options = props.state.games.map((g) => ({
label: `${g.name} · ${gameStatusLabels.get(g.status) ?? 'Неизвестный статус'}`,
value: g.id,
}))
const currentId = props.state.currentGameId
if (currentId !== null && !options.some((option) => option.value === currentId))
options.push({ label: props.selectedName ?? 'Текущая игра', value: currentId })
return options
})
</script>
<template>
<n-card v-if="bridge && isReady" title="Сервер">
<div class="connection-content">
<n-card v-if="bridge && isReady">
<template #header>
<div class="server-heading">
<span>Сервер</span>
<span v-if="state.authenticated" class="server-address" :title="state.server">
{{ state.server }}
</span>
</div>
</template>
<template #header-extra>
<div class="server-actions">
<n-tag
role="status"
data-testid="auth-status"
:type="state.authenticated ? 'success' : 'error'"
>
{{ state.authenticated ? 'Вход выполнен' : 'Не выполнен' }}
</n-tag>
<n-button
v-if="state.authenticated"
size="small"
:disabled="isBusy || state.busy"
@click="run({ action: 'logout' })"
>Выйти</n-button
>
</div>
</template>
<div v-if="showServerContent" class="connection-content">
<div v-if="state.busy" class="request-status" role="status" data-testid="request-status">
<n-spin size="small" />
<span>{{ state.busyMessage || 'Выполнение запроса…' }}</span>
</div>
<template v-if="!state.authenticated">
<label for="server">Адрес сервера</label>
<n-input
@@ -81,6 +186,7 @@ const connectionLabels = {
@keyup.enter="run({ action: 'login', server, email, password })"
/>
<n-button
class="auth-action"
:loading="isBusy"
:disabled="!server || !email || !password"
@click="run({ action: 'login', server, email, password })"
@@ -88,15 +194,10 @@ const connectionLabels = {
>
</template>
<template v-else>
<p>{{ state.server }}</p>
<n-alert v-if="!state.remembered" type="warning"
>Вход не сохранён: после перезапуска потребуется войти снова.</n-alert
>
<n-button :disabled="isBusy || state.busy" @click="run({ action: 'logout' })"
>Выйти</n-button
>
</template>
<p role="status">{{ connectionLabels[state.connection] }}</p>
<n-alert
v-if="state.error"
type="error"
@@ -108,37 +209,59 @@ const connectionLabels = {
</div>
</n-card>
<n-card v-if="bridge && isReady" title="Игра">
<template #header-extra>
<n-tag
role="status"
data-testid="game-connection-status"
:type="state.connection === 'connected' ? 'success' : 'error'"
>
{{ state.connection === 'connected' ? 'Подключено' : 'Не подключено' }}
</n-tag>
</template>
<div class="connection-content">
<div v-if="selectedKey" class="current-game">
<span data-testid="control-game">Текущая игра: {{ selectedName }}</span>
<n-button :disabled="isBusy || state.busy" @click="emit('resetGame')"> Сбросить </n-button>
</div>
<template v-if="state.authenticated">
<n-button :disabled="isBusy || state.busy" @click="run({ action: 'games' })"
>Обновить список игр</n-button
>
<n-select
v-model:value="gameId"
clearable
:options="
state.games.map((g) => ({
label: `${g.name} · ${gameStatusLabels.get(g.status) ?? 'Неизвестный статус'}`,
value: g.id,
}))
"
:disabled="isBusy || state.busy"
placeholder="Выберите игру"
:input-props="{ 'aria-label': 'Игра на сервере' }"
/>
<p v-if="!state.games.length">Нет игр.</p>
<div class="game-selection">
<n-select
class="game-select"
v-model:value="gameId"
clearable
:options="gameOptions"
:disabled="isBusy || state.busy"
placeholder="Выберите игру"
:input-props="{ 'aria-label': 'Игра на сервере' }"
/>
<n-button
square
aria-label="Обновить список игр"
title="Обновить список игр"
:disabled="isBusy || state.busy"
@click="run({ action: 'games' })"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path d="M20 7v5h-5M4 17v-5h5" />
<path d="M6.1 6.1A8 8 0 0 1 20 12M4 12a8 8 0 0 0 13.9 5.9" />
</svg>
</n-button>
</div>
<p v-if="!gameOptions.length">Нет игр.</p>
<n-button
:disabled="(gameId === null && !canConnectCurrent) || isBusy || state.busy"
@click="run(gameId === null ? { action: 'connect' } : { action: 'connect', gameId })"
>Подключить</n-button
>
<n-button
:disabled="gameId === null || isBusy || state.busy"
@click="run({ action: 'download', gameId: gameId! })"
>Загрузить материалы</n-button
>Подключиться</n-button
>
</template>
<p v-if="selectedKey" data-testid="control-game">Текущая игра: {{ selectedName }}</p>
<p v-if="state.preparation.active" role="status" data-testid="materials-progress">
Материалы: {{ state.preparation.completed }}/{{ state.preparation.total }} ·
{{ (state.preparation.downloadedBytes / 1048576).toFixed(1) }} МиБ
@@ -158,12 +281,240 @@ const connectionLabels = {
</p>
<n-button v-if="state.canCancel" @click="cancel">Отменить загрузку</n-button>
</template>
<n-collapse v-if="selectedKey">
<n-collapse-item
:title="`Материалы — загружено ${loadedMaterials} из ${state.materials.length}`"
name="materials"
>
<div class="materials-list">
<n-button text size="small" @click="openMaterialsFolder">Открыть папку</n-button>
<p v-if="!state.materials.length">В игре нет материалов.</p>
<n-table
v-if="state.materials.length"
class="materials-table"
size="small"
:single-line="false"
>
<thead>
<tr>
<th scope="col">Материал</th>
<th scope="col" class="status-column">Статус</th>
<th scope="col" class="action-column">
<span class="visually-hidden">Загрузка</span>
</th>
</tr>
</thead>
<tbody v-for="group in materialGroups" :key="group.id">
<tr>
<th scope="rowgroup" colspan="3">{{ group.name }}</th>
</tr>
<tr
v-for="(row, index) in group.rows"
:key="`${row.material.id}-${index}`"
class="material-row"
>
<td class="material-name">
{{ row.name }}
<span v-if="row.material.status === 'error'" class="material-error">{{
row.material.error
}}</span>
</td>
<td class="material-status-cell">
<span
role="status"
:aria-label="materialLabels[row.material.status]"
:title="materialLabels[row.material.status]"
class="material-state"
:class="{
'material-ready': row.material.status === 'ready',
'material-unavailable': row.material.status !== 'ready',
}"
>
<n-spin v-if="row.material.status === 'loading'" size="small" />
<svg
v-else
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path v-if="row.material.status === 'ready'" d="m5 12 4 4L19 6" />
<path v-else d="m6 6 12 12M6 18 18 6" />
</svg>
</span>
</td>
<td>
<n-button
v-if="row.material.status === 'missing' || row.material.status === 'error'"
square
size="small"
:disabled="isBusy || state.busy"
:aria-label="`${row.material.status === 'error' ? 'Повторить загрузку' : 'Загрузить'}: ${row.name}`"
:title="row.material.status === 'error' ? 'Повторить загрузку' : 'Загрузить'"
@click="
run({
action: 'download-material',
key: selectedKey!,
assetId: row.material.id,
})
"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path d="M12 3v12m-5-5 5 5 5-5M5 16v5h14v-5" />
</svg>
</n-button>
</td>
</tr>
</tbody>
</n-table>
<div class="materials-actions">
<n-button
:disabled="
!state.authenticated || state.currentGameId === null || isBusy || state.busy
"
@click="run({ action: 'download', gameId: state.currentGameId! })"
>Загрузить материалы</n-button
>
</div>
</div>
</n-collapse-item>
</n-collapse>
<p v-if="!state.authenticated">Войдите на сервер, чтобы выбрать игру.</p>
</div>
</n-card>
</template>
<style scoped>
.materials-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.materials-list > button {
align-self: flex-start;
}
.materials-table {
table-layout: fixed;
}
.status-column {
width: 72px;
}
.action-column {
width: 48px;
}
.material-name {
overflow-wrap: anywhere;
}
.material-error {
color: #d03050;
display: block;
}
.material-status-cell {
text-align: center;
}
.material-state {
display: inline-flex;
}
.material-ready {
color: #18a058;
}
.material-unavailable {
color: #d03050;
}
.materials-actions {
display: flex;
justify-content: flex-end;
}
.visually-hidden {
clip-path: inset(50%);
height: 1px;
overflow: hidden;
position: absolute;
white-space: nowrap;
width: 1px;
}
.current-game {
align-items: center;
display: flex;
gap: 8px;
justify-content: space-between;
}
.current-game > span {
min-width: 0;
overflow-wrap: anywhere;
}
.current-game > button {
flex-shrink: 0;
}
.game-selection {
align-items: center;
display: flex;
gap: 8px;
}
.game-select {
flex: 1;
min-width: 0;
}
.request-status {
align-items: center;
display: flex;
gap: 8px;
}
.server-actions {
align-items: center;
display: flex;
gap: 8px;
}
.server-heading {
align-items: baseline;
display: flex;
gap: 12px;
padding-right: 12px;
}
.server-heading > span:first-child {
flex-shrink: 0;
}
.server-address {
font-size: 14px;
font-weight: 400;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.connection-content {
display: flex;
flex-direction: column;
@@ -174,6 +525,10 @@ const connectionLabels = {
align-self: flex-start;
}
.connection-content > .auth-action {
align-self: flex-end;
}
.connection-error {
overflow-wrap: anywhere;
white-space: pre-wrap;
+9 -2
View File
@@ -203,6 +203,13 @@ async function send(intent: Intent) {
message.error(error instanceof Error ? error.message : 'Команда не выполнена')
}
}
async function resetGame() {
if (!bridge || isBusy.value || isOnlineBusy.value || onlineState.value.busy) return
await run(async () => {
acceptState(await bridge.selectPackage(null))
onlineState.value = await bridge.onlineState()
})
}
async function refreshHost() {
if (!bridge) return
host.value = await bridge.host()
@@ -240,14 +247,14 @@ onUnmounted(() => {
<template>
<main class="control">
<h1>Вечерний детектив</h1>
<n-alert v-if="!bridge" type="warning">Откройте пульт в приложении Electron.</n-alert>
<connection-panel
:state="onlineState"
:is-ready="isOnlineReady"
:is-busy="isOnlineBusy"
:is-busy="isOnlineBusy || isBusy"
@action="runOnline"
@cancel="cancelOnline"
@reset-game="resetGame"
:selected-key="state?.catalog.key ?? null"
:selected-name="state?.catalog.name ?? null"
:can-connect-current="
+3
View File
@@ -8,9 +8,12 @@ export function useOnlineConnection() {
const isReady = ref(false)
const state = ref<OnlineState>({
server: DEFAULT_SERVER,
currentGameId: null,
materials: [],
authenticated: false,
remembered: false,
busy: false,
busyMessage: '',
canCancel: false,
games: [],
playlists: [],
+18 -1
View File
@@ -8,7 +8,16 @@ export interface MusicPlaylist {
tracks: { id: number; name: string }[]
}
export interface GameMaterial {
id: string
name: string
usages: { groupId: string; groupName: string; name: string; order: number }[]
status: 'missing' | 'loading' | 'ready' | 'error'
error: string
}
export type OnlineIntent =
| { action: 'download-material'; key: string; assetId: string }
| { action: 'playlists' }
| { action: 'playlist-create'; name: string }
| { action: 'playlist-rename'; playlistId: number; name: string }
@@ -25,9 +34,12 @@ export type OnlineIntent =
| { action: 'remove'; key: string }
export interface OnlineState {
server: string
currentGameId: number | null
materials: GameMaterial[]
authenticated: boolean
remembered: boolean
busy: boolean
busyMessage: string
canCancel: boolean
playlists: MusicPlaylist[]
games: { id: number; name: string; status: string }[]
@@ -36,7 +48,12 @@ export interface OnlineState {
connection: 'disabled' | 'waiting' | 'connected' | 'offline' | 'expired'
error: string
notice: string
progress: { downloadedBytes: number; completedFiles: number; totalFiles: number } | null
progress: {
downloadedBytes: number
completedFiles: number
totalFiles: number
assetId?: string | null
} | null
preparation: {
active: boolean
completed: number
+1
View File
@@ -160,4 +160,5 @@ export interface ScreenBridge {
show: (displayId: number) => Promise<void>
packages: () => Promise<PackageSummary[]>
selectPackage: (key: string | null) => Promise<State>
openMaterialsFolder: (key: string) => Promise<void>
}