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: 'Режим экрана',
|
||||
|
||||
Reference in New Issue
Block a user