From 913b237cb842448d8786be7ea0f8cf2feef2500f Mon Sep 17 00:00:00 2001 From: Fedorov Vladimir Date: Sun, 18 Oct 2026 14:15:43 +0700 Subject: [PATCH] up --- electron/main.ts | 77 ++++- electron/online.test.ts | 217 +++++++++++- electron/online.ts | 114 ++++++- electron/package-store.test.ts | 83 ++++- electron/package-store.ts | 105 +++++- electron/preload.ts | 1 + electron/request-error.ts | 1 + scripts/online-smoke.cjs | 57 ++-- scripts/smoke.cjs | 10 +- src/components/ConnectionPanel.vue | 443 ++++++++++++++++++++++--- src/components/ControlPage.vue | 11 +- src/composables/useOnlineConnection.ts | 3 + src/types/online.ts | 19 +- src/types/screen.ts | 1 + 14 files changed, 1042 insertions(+), 100 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index 397d1b9..d94abcd 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -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( + `Проверка файлов

Проверка загруженных файлов…

Поиск и проверка пакетов…

`, + ), + ) 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() @@ -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()) diff --git a/electron/online.test.ts b/electron/online.test.ts index 187dd90..e7ebb79 100644 --- a/electron/online.test.ts +++ b/electron/online.test.ts @@ -47,6 +47,8 @@ async function fixture(t: TestContext, secure = true) { playlistError: 0, upload: null as { filename: string; data: Buffer } | null, bindings: [] as Record[], + 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((resolve) => { + requested = resolve + }) + f.model.mediaHandler = async () => { + requested() + await new Promise((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) => { diff --git a/electron/online.ts b/electron/online.ts index 4f99c4b..c28d712 100644 --- a/electron/online.ts +++ b/electron/online.ts @@ -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() + const usages = new Map() + 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 = { + 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) { 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() } diff --git a/electron/package-store.test.ts b/electron/package-store.test.ts index dabb184..8ecbc3e 100644 --- a/electron/package-store.test.ts +++ b/electron/package-store.test.ts @@ -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[] = [] diff --git a/electron/package-store.ts b/electron/package-store.ts index fc466cc..f1cc816 100644 --- a/electron/package-store.ts +++ b/electron/package-store.ts @@ -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 @@ -89,6 +93,7 @@ export class PackageStore { private readonly repaired = new Set() private readonly checked = new Map() private readonly loading = new Map>() + private readonly assetErrors = new Map() 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() + 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(action: () => Promise): Promise { if (this.busy) throw new Error('Хранилище пакетов занято') this.busy = true @@ -152,13 +172,17 @@ export class PackageStore { } } } - async initialize(): Promise { + async initialize(onProgress: (progress: VerificationProgress) => void = () => {}): Promise { + 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 { @@ -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() 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 { + 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) } diff --git a/electron/preload.ts b/electron/preload.ts index 58948ff..796f11f 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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), }) diff --git a/electron/request-error.ts b/electron/request-error.ts index 17c7c2c..cae5d93 100644 --- a/electron/request-error.ts +++ b/electron/request-error.ts @@ -155,6 +155,7 @@ export function requestErrorContext( GetGames: 'Список игр', CreateScreenSession: 'Создание экрана', BindScreen: 'Подключение экрана', + UnbindScreen: 'Отключение экрана', SetScreenSound: 'Назначение звука', SetGameMusic: 'Управление музыкой', SetScreenMode: 'Режим экрана', diff --git a/scripts/online-smoke.cjs b/scripts/online-smoke.cjs index 5d4b6e6..dd92c5d 100644 --- a/scripts/online-smoke.cjs +++ b/scripts/online-smoke.cjs @@ -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') diff --git a/scripts/smoke.cjs b/scripts/smoke.cjs index 45e0df2..eac3c67 100644 --- a/scripts/smoke.cjs +++ b/scripts/smoke.cjs @@ -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) { diff --git a/src/components/ConnectionPanel.vue b/src/components/ConnectionPanel.vue index 87ed58d..65ccbc0 100644 --- a/src/components/ConnectionPanel.vue +++ b/src/components/ConnectionPanel.vue @@ -1,7 +1,24 @@ + + +
+ Открыть папку +

В игре нет материалов.

+ + + + Материал + Статус + + Загрузка + + + + + + {{ group.name }} + + + + {{ row.name }} + {{ + row.material.error + }} + + + + + + + + + + + + + + + +
+ Загрузить материалы +
+
+
+

Войдите на сервер, чтобы выбрать игру.