up
This commit is contained in:
+73
-4
@@ -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
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -155,6 +155,7 @@ export function requestErrorContext(
|
||||
GetGames: 'Список игр',
|
||||
CreateScreenSession: 'Создание экрана',
|
||||
BindScreen: 'Подключение экрана',
|
||||
UnbindScreen: 'Отключение экрана',
|
||||
SetScreenSound: 'Назначение звука',
|
||||
SetGameMusic: 'Управление музыкой',
|
||||
SetScreenMode: 'Режим экрана',
|
||||
|
||||
+37
-20
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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="
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user