This commit is contained in:
2026-10-18 14:15:43 +07:00
parent 0cd736f682
commit 913b237cb8
14 changed files with 1042 additions and 100 deletions
+73 -4
View File
@@ -8,6 +8,7 @@ import {
screen,
session,
safeStorage,
shell,
powerMonitor,
powerSaveBlocker,
} from 'electron'
@@ -43,6 +44,7 @@ let closingWindow: BrowserWindow | null = null
let openingOutput = false
let outputEpoch = 0
let quitting = false
let startup: BrowserWindow | null = null
let selectedDisplay: number | null = null
let online: Online | null = null
let settings: WindowSettings
@@ -269,13 +271,40 @@ async function show(displayId: number) {
// Две копии приложения не должны одновременно менять указатели пакетов.
if (!app.requestSingleInstanceLock()) app.exit(0)
app.on('second-instance', () => {
control?.show()
control?.focus()
const window = control ?? startup
window?.show()
window?.focus()
})
app.on('before-quit', () => {
quitting = true
})
app
.whenReady()
.then(async () => {
startup = new BrowserWindow({
width: 440,
height: 200,
resizable: false,
title: 'Проверка файлов',
webPreferences: { contextIsolation: true, sandbox: true, nodeIntegration: false },
})
const startupWindow = startup
startupWindow.setMenu(null)
startupWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
startupWindow.webContents.on('will-navigate', (event) => event.preventDefault())
startupWindow.on('closed', () => {
if (startup === startupWindow) {
startup = null
app.quit()
}
})
await startupWindow.loadURL(
'data:text/html;charset=utf-8,' +
encodeURIComponent(
`<!doctype html><html lang="ru"><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'"><title>Проверка файлов</title><style>body{background:#18181c;color:#eee;font:16px system-ui;padding:24px}progress{width:100%;accent-color:#63e2b7}p{margin:12px 0}</style><p id="message" role="status">Проверка загруженных файлов…</p><progress id="progress"></progress><p id="count">Поиск и проверка пакетов…</p></html>`,
),
)
diagnostics = new DiagnosticLog(join(app.getPath('userData'), 'logs'))
diagnostics.record('app-start', {
version: app.getVersion(),
@@ -295,7 +324,29 @@ app
const packages = new PackageStore(join(app.getPath('userData'), 'packages'), {
fetch: fetchNetwork,
})
await packages.initialize()
let lastProgressUpdate = 0
await packages.initialize((progress) => {
if (startupWindow.isDestroyed()) return
const now = Date.now()
if (
progress.total !== null &&
progress.completed !== progress.total &&
now - lastProgressUpdate < 100
)
return
lastProgressUpdate = now
const { completed, total } = progress
void startupWindow.webContents
.executeJavaScript(
`(() => {
const bar = document.getElementById('progress');
const count = document.getElementById('count');
${total === null ? "bar.removeAttribute('value');" : `bar.max = ${Math.max(total, 1)}; bar.value = ${completed}; count.textContent = 'Проверено файлов: ${completed} из ${total}';`}
})()`,
)
.catch(() => {})
})
if (quitting || startupWindow.isDestroyed()) return
online = new Online(
join(app.getPath('userData'), 'connection'),
packages,
@@ -324,6 +375,7 @@ app
},
)
await online.initialize()
if (quitting || startupWindow.isDestroyed()) return
let previousConnection = online.state.connection
const logConnection = () => {
const status = online!.state.connection
@@ -425,6 +477,8 @@ app
keepControlOnPrimary()
if (settings.value.control?.maximized) control!.maximize()
control!.show()
startup = null
if (!startupWindow.isDestroyed()) startupWindow.destroy()
})
const scheduleSave = () => {
clearTimeout(saveTimer)
@@ -503,6 +557,15 @@ app
selectingPackage = false
}
})
ipcMain.handle('open-materials-folder', async (event, key: unknown) => {
requireControl(event)
if (typeof key !== 'string' || key !== controller.state.catalog.key)
throw new Error('Выберите текущую игру')
for (const path of await packages.folders(key)) {
const error = await shell.openPath(path)
if (error) throw new Error('Не удалось открыть папку материалов')
}
})
let localSession = ''
let localSequence = 0
const pendingCommands = new Set<string>()
@@ -637,6 +700,12 @@ app
.catch(() => {
diagnostics?.record('startup-error')
console.error('SCREEN_STARTUP_FAILED')
app.quit()
if (startup && !startup.isDestroyed()) {
void startup.webContents
.executeJavaScript(
`document.getElementById('message').textContent = 'Не удалось проверить файлы. Проверьте доступ к папке данных приложения и перезапустите его.'; document.getElementById('progress').hidden = true; document.getElementById('count').textContent = '';`,
)
.catch(() => app.quit())
} else app.quit()
})
app.on('window-all-closed', () => app.quit())
+210 -7
View File
@@ -47,6 +47,8 @@ async function fixture(t: TestContext, secure = true) {
playlistError: 0,
upload: null as { filename: string; data: Buffer } | null,
bindings: [] as Record<string, unknown>[],
unbindError: 0,
createScreenError: 0,
offline: false,
server: 'https://server.test',
introURL: 'https://media.test/intro.wav',
@@ -109,7 +111,14 @@ async function fixture(t: TestContext, secure = true) {
model.refreshed = true
return json({ accessToken: 'ACCESS-ROTATED', refreshToken: 'REFRESH-ROTATED' })
}
if (path === '/api/screens') return json({ screenID: '12', accessToken: 'SCREEN-SECRET' })
if (path === '/api/screens')
return model.createScreenError
? json({}, model.createScreenError)
: json({ screenID: '12', accessToken: 'SCREEN-SECRET' })
if (path.endsWith('/unbind')) {
assert.equal(auth, 'Bearer ACCESS-SECRET')
return json({}, model.unbindError || 200)
}
if (path === '/api/screens/state') {
assert.equal(auth, null)
if (model.stateResponse) return model.stateResponse()
@@ -150,10 +159,10 @@ async function fixture(t: TestContext, secure = true) {
{ ID: 7, name: model.title, status: 'finish', teams: [{ password: 'TEAM-SECRET' }] },
],
})
if (path === '/api/games/7')
if (path === '/api/games/7' || path === '/api/games/8')
return json({
game: {
ID: 7,
ID: Number(path.split('/').at(-1)),
name: model.title,
scenario: model.omitScenario
? null
@@ -176,6 +185,8 @@ async function fixture(t: TestContext, secure = true) {
}
return json({ plan: model.plan.steps.length ? model.plan : null })
}
if (path === '/api/games/8/screen-plan') return json({ plan: null })
if (path === '/api/games/8/music') return json({ playlists: [] })
if (path === '/api/games/7/screen-plan/step') {
if (model.stepResponse) return model.stepResponse()
model.planTransitions++
@@ -325,6 +336,200 @@ test('чистый профиль предлагает адрес сайта п
assert.equal(f.online.snapshot().server, 'https://evening-detective.crabs-games.art')
})
test('список материалов показывает отдельную загрузку, ошибку, повтор и кеш после перезапуска', async (t) => {
const f = await fixture(t)
await f.login()
await f.online.act({ action: 'connect', gameId: 7 })
const key = f.controller.state.catalog.key!
const materials = f.online.snapshot().materials
assert.ok(materials.length >= 3)
assert.ok(materials.every((m) => m.status === 'missing'))
const intro = materials.find((m) => m.name === 'Вступление')!
assert.ok(intro)
assert.deepEqual(intro.usages, [
{ groupId: 'game', groupName: 'Игра', name: 'Вступление', order: 2 },
])
const music = materials.filter((m) => m.usages.some((u) => u.groupId.startsWith('playlist-')))
assert.equal(music.length, 2)
assert.deepEqual(music.flatMap((m) => m.usages.map((u) => u.order)).sort(), [0, 1])
f.model.failMedia = true
await assert.rejects(f.online.act({ action: 'download-material', key, assetId: intro.id }))
assert.equal(f.online.snapshot().materials.find((m) => m.id === intro.id)!.status, 'error')
f.model.failMedia = false
let release!: () => void
let requested!: () => void
const started = new Promise<void>((resolve) => {
requested = resolve
})
f.model.mediaHandler = async () => {
requested()
await new Promise<void>((resolve) => {
release = resolve
})
return new Response(Buffer.from('RIFFtest-WAVE'), { headers: { 'Content-Type': 'audio/wav' } })
}
const download = f.online.act({ action: 'download-material', key, assetId: intro.id })
await started
assert.equal(f.online.snapshot().materials.find((m) => m.id === intro.id)!.status, 'loading')
release()
await download
assert.equal(f.online.snapshot().materials.find((m) => m.id === intro.id)!.status, 'ready')
assert.ok(f.online.snapshot().materials.some((m) => m.status === 'missing'))
assert.equal(f.controller.state.catalog.media.find((m) => m.id === 'intro')!.ready, true)
assert.deepEqual(await f.store.folders(key), [join(f.directory, 'packages', key, 'cache')])
await assert.rejects(f.store.folders('../'), /Некорректный/)
await assert.rejects(
f.online.act({ action: 'download-material', key: 'other', assetId: intro.id }),
)
await assert.rejects(f.online.act({ action: 'download-material', key, assetId: 'unknown' }))
await f.online.close()
await f.store.initialize()
await f.online.initialize()
assert.equal(f.online.snapshot().materials.find((m) => m.id === intro.id)!.status, 'ready')
const file = await f.store.localFile(key, intro.id)
await rm(file.path)
await f.online.select(key)
assert.equal(f.online.snapshot().materials.find((m) => m.id === intro.id)!.status, 'missing')
await f.online.select(null)
assert.deepEqual(f.online.snapshot().materials, [])
})
test('готовый пакет показывает все материалы загруженными без входа', async (t) => {
const f = await fixture(t)
await f.prepare()
await f.online.act({ action: 'logout' })
assert.ok(f.online.snapshot().materials.length > 0)
assert.ok(f.online.snapshot().materials.every((m) => m.status === 'ready'))
})
test('один файл сохраняет назначения в группе игры и плейлиста без дублирования счётчика', async (t) => {
const f = await fixture(t)
await f.login()
f.model.musicOverride = {
gamePlaylistId: 1,
playlists: [
{
ID: 1,
name: 'Общий плейлист',
tracks: [{ ID: 1, name: 'Общий трек', filename: f.model.introURL, position: 0 }],
},
],
}
await f.online.act({ action: 'connect', gameId: 7 })
const id = f.online
.snapshot()
.materials.find((m) => m.usages.some((u) => u.name === 'Вступление'))!.id
const materials = f.online.snapshot().materials
assert.equal(materials.filter((m) => m.id === id).length, 1)
assert.deepEqual(
materials.find((m) => m.id === id)!.usages.map((u) => u.groupId),
['game', 'playlist-1'],
)
})
for (const gameId of [7, 8]) {
test(`переподключение к игре ${gameId} сначала отвязывает прежний экран`, async (t) => {
const f = await fixture(t)
await f.prepare()
assert.equal(
f.requests.some((r) => r.path.endsWith('/unbind')),
false,
)
const start = f.requests.length
await f.online.act({ action: 'connect', gameId })
const paths = f.requests.slice(start).map((r) => r.path)
assert.equal(paths[0], '/api/screens/12/unbind')
assert.ok(paths.indexOf('/api/screens') > 0)
assert.ok(paths.indexOf('/api/screens/12/bind') > paths.indexOf('/api/screens'))
assert.equal(f.model.bindings.at(-1)!.gameID, gameId)
assert.equal(f.online.snapshot().connection, 'waiting')
})
}
test('ошибка отвязки сохраняет прежнюю сессию и не создаёт новую', async (t) => {
const f = await fixture(t)
await f.prepare()
f.model.unbindError = 500
const start = f.requests.length
await assert.rejects(f.online.act({ action: 'connect' }), /Отключение экрана/)
assert.equal(f.online.snapshot().screenId, '12')
assert.equal(
f.requests.slice(start).some((r) => r.path === '/api/screens'),
false,
)
f.model.unbindError = 0
await f.online.act({ action: 'connect' })
assert.equal(f.online.snapshot().connection, 'waiting')
})
test('отсутствующий прежний экран не мешает переподключению', async (t) => {
const f = await fixture(t)
await f.prepare()
f.model.unbindError = 404
await f.online.act({ action: 'connect' })
assert.equal(f.online.snapshot().connection, 'waiting')
})
test('ошибка создания после отвязки оставляет экран отключённым после перезапуска', async (t) => {
const f = await fixture(t)
await f.prepare()
f.model.createScreenError = 500
await assert.rejects(f.online.act({ action: 'connect' }))
assert.equal(f.online.snapshot().connection, 'disabled')
assert.equal(f.online.snapshot().screenId, null)
await f.online.close()
await f.online.initialize()
assert.equal(f.online.snapshot().screenId, null)
assert.equal(f.online.snapshot().connection, 'disabled')
})
test('сброс текущей игры отключает управление, сохраняет материалы и вход', async (t) => {
const f = await fixture(t)
await f.prepare()
const packages = f.store.list().map((pkg) => pkg.key)
await f.online.select(null)
assert.equal(f.controller.state.catalog.key, null)
assert.equal(f.online.snapshot().currentGameId, null)
assert.equal(f.online.snapshot().connection, 'disabled')
assert.equal(f.online.snapshot().screenId, null)
assert.equal(f.online.snapshot().authenticated, true)
assert.deepEqual(
f.store.list().map((pkg) => pkg.key),
packages,
)
await f.online.close()
await f.online.initialize()
assert.equal(f.controller.state.catalog.key, null)
assert.equal(f.online.snapshot().currentGameId, null)
})
test('статус запроса виден во время загрузки и очищается после успеха или ошибки', async (t) => {
const f = await fixture(t)
await f.login()
const loading = f.online.act({ action: 'playlists' })
assert.equal(f.online.snapshot().busy, true)
assert.equal(f.online.snapshot().busyMessage, 'Загрузка списка плейлистов…')
await loading
assert.equal(f.online.snapshot().busyMessage, '')
f.model.playlistError = 500
const failing = f.online.act({ action: 'playlists' })
assert.equal(f.online.snapshot().busyMessage, 'Загрузка списка плейлистов…')
await assert.rejects(failing)
assert.equal(f.online.snapshot().busy, false)
assert.equal(f.online.snapshot().busyMessage, '')
})
test('состояние сообщает ID выбранной игры только для текущего сервера', async (t) => {
const f = await fixture(t)
assert.equal(f.online.snapshot().currentGameId, null)
await f.login()
await f.online.act({ action: 'download', gameId: 7 })
await f.online.select(f.store.list()[0]!.key)
assert.equal(f.online.snapshot().currentGameId, 7)
f.online.state.server = 'https://other.test'
assert.equal(f.online.snapshot().currentGameId, null)
})
for (const [input, expected] of [
['https://evening-detective.crabs-games.art/', 'https://evening-detective-api.crabs-games.art'],
['http://127.0.0.1:8090', 'http://127.0.0.1:8090'],
@@ -793,12 +998,10 @@ test('назначение звука подтверждается сервер
await f.online.act({ action: 'connect' })
assert.equal(f.online.state.soundEnabled, true)
await f.online.poll()
const unbindsBeforeDisconnect = f.requests.filter((r) => r.path.endsWith('/unbind')).length
await f.online.act({ action: 'disconnect' })
assert.equal(f.online.state.soundEnabled, null)
assert.equal(
f.requests.some((r) => r.path.endsWith('/unbind')),
false,
)
assert.equal(f.requests.filter((r) => r.path.endsWith('/unbind')).length, unbindsBeforeDisconnect)
})
test('план: сохранение и удаление на сервере, восстановление копии офлайн, ошибки валидации и доступа', async (t) => {
+111 -3
View File
@@ -4,7 +4,12 @@ import { apiScreenMode, apiScreenCommand, apiPlan } from './api-values.js'
import { ApiError, NetworkError, SessionChangedError, networkFailure } from './request-error.js'
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { DEFAULT_SERVER, type OnlineIntent, type OnlineState } from '../src/types/online.js'
import {
DEFAULT_SERVER,
type GameMaterial,
type OnlineIntent,
type OnlineState,
} from '../src/types/online.js'
import type {
Intent,
PlanStatus,
@@ -67,9 +72,12 @@ function token(value: unknown): string {
export class Online {
readonly state: OnlineState = {
server: DEFAULT_SERVER,
currentGameId: null,
materials: [],
authenticated: false,
remembered: false,
busy: false,
busyMessage: '',
canCancel: false,
games: [],
playlists: [],
@@ -141,6 +149,7 @@ export class Online {
const local = await this.packages.remember(description)
if (generation !== this.generation) return
this.controller.selectPackage(local, true)
await this.checkMaterials(local.package)
this.state.packagesRevision++
}
this.materialSignature = signature
@@ -831,7 +840,55 @@ export class Online {
return this.state.busy
}
snapshot(): OnlineState {
return structuredClone(this.state)
const pkg = this.selectedGame()?.package
const names = new Map<string, string[]>()
const usages = new Map<string, GameMaterial['usages']>()
const add = (
id: string | null,
name: string,
order: number,
groupId = 'game',
groupName = 'Игра',
) => {
if (!id) return
names.set(id, [
...(names.get(id) ?? []),
groupId === 'game' ? name : `${groupName} · ${name}`,
])
usages.set(id, [...(usages.get(id) ?? []), { groupId, groupName, name, order }])
}
if (pkg) {
add(pkg.media.cover, 'Обложка', 0)
add(pkg.media.background, 'Фон', 1)
add(pkg.media.intro, 'Вступление', 2)
add(pkg.media.conclusion, 'Заключение', 3)
for (const playlist of pkg.playlists)
for (const track of playlist.tracks)
add(track.assetId, track.name, track.position, `playlist-${playlist.id}`, playlist.name)
}
const key = this.controller.state.catalog.key
return structuredClone({
...this.state,
currentGameId: pkg?.server === this.state.server ? pkg.game.id : null,
materials:
pkg && key
? pkg.assets.map((asset) => ({
id: asset.id,
name: names.get(asset.id)?.join(', ') ?? 'Материал игры',
usages: usages.get(asset.id) ?? [
{ groupId: 'game', groupName: 'Игра', name: 'Материал игры', order: 4 },
],
...this.packages.assetStatus(key, asset.id),
...(this.downloadKey === key && this.state.progress?.assetId === asset.id
? { status: 'loading' as const }
: {}),
}))
: [],
})
}
private async checkMaterials(pkg: ScreenPackage) {
const key = packageKey(pkg.server, pkg.game.id)
for (const asset of pkg.assets) await this.packages.checkAsset(key, asset.id)
}
private async atomic(name: string, data: string | Buffer) {
await mkdir(this.directory, { recursive: true, mode: 0o700 })
@@ -929,6 +986,7 @@ export class Online {
}
this.detach()
this.controller.selectPackage(pkg)
if (pkg) await this.checkMaterials(pkg.package)
this.plan = { steps: structuredClone(this.controller.state.catalog.steps), currentStep: 0 }
this.planNotice = ''
this.lastPlan = -Infinity
@@ -1098,6 +1156,33 @@ export class Online {
if (this.busy) throw new Error('Подождите завершения текущего действия')
const isBackgroundRefresh = data.action === 'playlists'
this.state.busy = true
const messages: Record<OnlineIntent['action'], string> = {
playlists: 'Загрузка списка плейлистов…',
'download-material': 'Загрузка файла игры…',
'playlist-create': 'Создание плейлиста…',
'playlist-rename': 'Сохранение названия плейлиста…',
'playlist-delete': 'Удаление плейлиста…',
'playlist-upload': 'Загрузка трека на сервер…',
'playlist-use': 'Подключение плейлиста к игре…',
'playlist-track-delete': 'Удаление трека…',
'playlist-reorder': 'Сохранение порядка треков…',
'plan-save': 'Сохранение плана экранов…',
'plan-refresh': 'Загрузка плана экранов…',
login: 'Вход на сервер…',
logout: 'Выход из аккаунта…',
games: 'Загрузка списка игр…',
connect: 'Подключение игры…',
disconnect: 'Отключение игры…',
download: 'Загрузка материалов игры…',
check: 'Проверка материалов игры…',
'retry-materials': 'Подготовка материалов игры…',
remove: 'Удаление локальных материалов…',
sound: 'Изменение назначения звука…',
results: 'Загрузка результатов игры…',
cancel: 'Отмена загрузки…',
}
this.state.busyMessage =
messages[data.action as OnlineIntent['action']] ?? 'Выполнение запроса…'
if (!isBackgroundRefresh) {
this.state.error = ''
this.state.notice = ''
@@ -1110,6 +1195,7 @@ export class Online {
throw new Error(message)
} finally {
this.state.busy = false
this.state.busyMessage = ''
this.state.progress = null
}
return this.snapshot()
@@ -1117,6 +1203,17 @@ export class Online {
private async perform(data: Record<string, unknown>) {
const action = data.action as OnlineIntent['action']
switch (action) {
case 'download-material': {
const pkg = this.selectedGame()
const assetId = text(data.assetId)
if (!pkg || data.key !== pkg.key || !pkg.package.assets.some((a) => a.id === assetId))
throw new Error('Материал не принадлежит текущей игре')
const generation = this.generation
await this.packages.ensureAsset(pkg.package, assetId, this.materialAbort.signal)
if (generation === this.generation && this.controller.state.catalog.key === pkg.key)
this.controller.markAssetReady(assetId)
return
}
case 'playlists':
return this.loadPlaylists()
case 'playlist-create':
@@ -1267,11 +1364,22 @@ export class Online {
let pkg = this.selectedGame()
const gameId = data.gameId == null ? pkg?.package.game.id : integer(data.gameId, 1)
if (!gameId) throw new Error('Выберите игру на сервере')
this.detach()
if (this.screen) {
this.state.busyMessage = 'Отключение предыдущего подключения…'
try {
await this.client().UnbindScreen({ screenID: this.screen.id })
} catch (error) {
if (!(error instanceof ApiError && error.status === 404)) throw error
}
this.detach()
await this.persistSecrets()
} else this.detach()
this.state.busyMessage = 'Подключение игры…'
if (!pkg || pkg.package.game.id !== gameId || pkg.package.server !== this.state.server) {
pkg = await this.packages.remember(await this.description(gameId))
this.controller.selectPackage(pkg)
this.plan = { steps: this.controller.state.catalog.steps, currentStep: 0 }
await this.checkMaterials(pkg.package)
this.state.packagesRevision++
await this.saveSelection()
}
+82 -1
View File
@@ -7,7 +7,12 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test, type TestContext } from 'node:test'
import { buildScreenPackage, parseScreenPackage } from './screen-package.js'
import { PackageStore, packageKey, type DownloadProgress } from './package-store.js'
import {
PackageStore,
packageKey,
type DownloadProgress,
type VerificationProgress,
} from './package-store.js'
import { packageMediaResponse } from './package-media.js'
const highSpace = 1n << 40n
@@ -65,6 +70,82 @@ async function fixture(t: TestContext) {
return { root, dir, state, calls, delays, dependencies, store, pkg, key }
}
test('запуск без сети проверяет отдельный кеш всех игр и сообщает прогресс', async (t) => {
const f = await fixture(t)
const other = description('https://other.test')
const otherKey = packageKey(other.server, other.game.id)
for (const pkg of [f.pkg, other]) {
await f.store.remember(pkg)
await f.store.ensureAsset(pkg, pkg.assets[0]!.id, new AbortController().signal)
}
const calls = f.calls.length
f.state.handler = async () => {
throw new Error('Нет сети')
}
const restored = new PackageStore(f.root, f.dependencies)
const progress: VerificationProgress[] = []
await restored.initialize((value) => progress.push(value))
assert.equal(f.calls.length, calls)
for (const [pkg, key] of [
[f.pkg, f.key],
[other, otherKey],
] as const) {
assert.equal(restored.assetStatus(key, pkg.assets[0]!.id).status, 'ready')
assert.equal(restored.assetStatus(key, pkg.assets[1]!.id).status, 'missing')
}
assert.equal(progress[0]!.total, null)
assert.deepEqual(progress.at(-1), { completed: 4, total: 4 })
assert.deepEqual(
progress.slice(1).map((p) => p.completed),
[0, 1, 2, 3, 4],
)
})
test('перезапуск выявляет подмену кеша того же размера, удаление и симлинк', async (t) => {
const f = await fixture(t)
await f.store.remember(f.pkg)
const id = f.pkg.assets[0]!.id
await f.store.ensureAsset(f.pkg, id, new AbortController().signal)
const file = await f.store.localFile(f.key, id)
await writeFile(file.path, Buffer.alloc(file.size, 42))
await f.store.initialize()
assert.equal(f.store.assetStatus(f.key, id).status, 'missing')
await f.store.ensureAsset(f.pkg, id, new AbortController().signal)
await rm(file.path)
await symlink(join(f.dir, 'outside'), file.path)
await f.store.initialize()
assert.equal(f.store.assetStatus(f.key, id).status, 'missing')
await rm(file.path)
await f.store.initialize()
assert.equal(f.store.assetStatus(f.key, id).status, 'missing')
})
test('пустое хранилище завершает стартовую проверку с нулевым счётчиком', async (t) => {
const f = await fixture(t)
const progress: VerificationProgress[] = []
await f.store.initialize((p) => progress.push(p))
assert.deepEqual(progress, [
{ completed: 0, total: null },
{ completed: 0, total: 0 },
])
})
test('открытие папки выбирает реальные файлы полного пакета и восстановленного кеша', async (t) => {
const f = await fixture(t)
await f.store.remember(f.pkg)
await assert.rejects(f.store.folders(f.key), /ещё не загружены/)
const local = await f.store.download(f.pkg)
const assets = join(f.root, f.key, `v-${local.version}`, 'assets')
assert.deepEqual(await f.store.folders(f.key), [assets])
const id = f.pkg.assets[0]!.id
await rm((await f.store.localFile(f.key, id)).path)
await f.store.ensureAsset(f.pkg, id, new AbortController().signal)
assert.deepEqual(
new Set(await f.store.folders(f.key)),
new Set([assets, join(f.root, f.key, 'cache')]),
)
})
test('загружает, проверяет с диска, восстанавливает после перезапуска и выдаёт офлайн Range', async (t) => {
const f = await fixture(t)
const progress: DownloadProgress[] = []
+89 -16
View File
@@ -1,7 +1,7 @@
import { createHash, randomUUID } from 'node:crypto'
import { createReadStream } from 'node:fs'
import { lstat, mkdir, open, readFile, readdir, rename, rm, statfs } from 'node:fs/promises'
import { extname, join, resolve } from 'node:path'
import { dirname, extname, join, resolve } from 'node:path'
import { setTimeout as wait } from 'node:timers/promises'
import { parseScreenPackage, type ScreenPackage } from './screen-package.js'
@@ -48,6 +48,10 @@ export interface DownloadProgress {
totalFiles: number
totalBytes: number | null
}
export interface VerificationProgress {
completed: number
total: number | null
}
interface Dependencies {
fetch?: typeof fetch
freeBytes?: () => Promise<bigint>
@@ -89,6 +93,7 @@ export class PackageStore {
private readonly repaired = new Set<string>()
private readonly checked = new Map<string, string>()
private readonly loading = new Map<string, Promise<void>>()
private readonly assetErrors = new Map<string, string>()
constructor(
root: string,
private readonly dependencies: Dependencies = {},
@@ -110,6 +115,21 @@ export class PackageStore {
}
return path
}
async folders(key: string) {
checkHex(key)
const pkg = this.list().find((pkg) => pkg.key === key)
if (!pkg) throw new Error('Игра не найдена')
const folders = new Set<string>()
for (const asset of pkg.package.assets) {
try {
folders.add(dirname((await this.localFile(key, asset.id)).path))
} catch {
/* Отсутствующий файл не должен вести пользователя в пустую папку. */
}
}
if (!folders.size) throw new Error('Материалы игры ещё не загружены')
return [...folders]
}
private async exclusive<T>(action: () => Promise<T>): Promise<T> {
if (this.busy) throw new Error('Хранилище пакетов занято')
this.busy = true
@@ -152,13 +172,17 @@ export class PackageStore {
}
}
}
async initialize(): Promise<void> {
async initialize(onProgress: (progress: VerificationProgress) => void = () => {}): Promise<void> {
onProgress({ completed: 0, total: null })
await this.exclusive(async () => {
await this.path()
await mkdir(this.root, { recursive: true })
await this.path()
this.ready.clear()
this.descriptions.clear()
this.checked.clear()
this.repaired.clear()
this.assetErrors.clear()
for (const key of await readdir(this.root)) {
if (!hex.test(key)) continue
try {
@@ -194,6 +218,16 @@ export class PackageStore {
}
}
}
const packages = this.list()
const total = packages.reduce((count, pkg) => count + pkg.package.assets.length, 0)
let completed = 0
onProgress({ completed, total })
for (const pkg of packages) {
for (const asset of pkg.package.assets) {
await this.checkAsset(pkg.key, asset.id)
onProgress({ completed: ++completed, total })
}
}
})
}
private async load(key: string): Promise<LocalPackage> {
@@ -219,6 +253,7 @@ export class PackageStore {
if (packageKey(pkg.server, pkg.game.id) !== key || raw.files.length !== pkg.assets.length)
throw new Error('Неполный пакет')
const files: StoredFile[] = []
const signatures = new Map<string, string>()
for (let i = 0; i < pkg.assets.length; i++) {
const file = raw.files[i] as StoredFile
if (
@@ -231,13 +266,19 @@ export class PackageStore {
)
throw new Error('Повреждён список файлов')
const path = await this.path(key, `v-${version}`, 'assets', file.id)
if (!(await lstat(path)).isFile()) throw new Error('Ресурс не является файлом')
const info = await lstat(path)
if (!info.isFile()) throw new Error('Ресурс не является файлом')
const actual = await checksum(path)
if (actual.size !== file.size || actual.sha256 !== file.sha256)
throw new Error('Повреждён файл пакета')
files.push({ id: file.id, ...actual, mime: file.mime })
signatures.set(
`${key}/${file.id}`,
`${info.size}:${info.mtimeMs}:${info.ctimeMs}:${file.sha256}`,
)
}
if (versionOf(pkg, files) !== version) throw new Error('Повреждена версия пакета')
for (const [token, signature] of signatures) this.checked.set(token, signature)
return { formatVersion: 1, key, version, package: pkg, files }
}
list(): LocalPackage[] {
@@ -265,6 +306,12 @@ export class PackageStore {
await rm(dir, { recursive: true, force: true })
this.ready.delete(key)
this.descriptions.delete(key)
for (const token of this.checked.keys())
if (token.startsWith(`${key}/`)) this.checked.delete(token)
for (const token of this.repaired)
if (token.startsWith(`${key}/`)) this.repaired.delete(token)
for (const token of this.assetErrors.keys())
if (token.startsWith(`${key}/`)) this.assetErrors.delete(token)
})
}
private descriptionPackage(pkg: ScreenPackage): LocalPackage {
@@ -348,6 +395,37 @@ export class PackageStore {
?.files.some((f) => f.id === assetId && !this.repaired.has(`${key}/${assetId}`))
)
}
assetStatus(key: string, assetId: string) {
const token = `${key}/${assetId}`
const error = this.assetErrors.get(token) ?? ''
const status = this.loading.has(token)
? 'loading'
: this.canRead(key, assetId)
? 'ready'
: error
? 'error'
: 'missing'
return { status, error } as const
}
async checkAsset(key: string, assetId: string): Promise<boolean> {
const token = `${key}/${assetId}`
try {
const file = await this.localFile(key, assetId)
const stat = await lstat(file.path)
const signature = `${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}:${file.sha256}`
if (this.checked.get(token) === signature) return true
const actual = await checksum(file.path)
if (actual.size === file.size && actual.sha256 === file.sha256) {
this.checked.set(token, signature)
return true
}
} catch {
/* Отсутствующие и повреждённые файлы доступны для повторной загрузки. */
}
this.checked.delete(token)
this.repaired.add(token)
return false
}
async ensureAsset(
pkg: ScreenPackage,
assetId: string,
@@ -361,21 +439,10 @@ export class PackageStore {
const token = `${key}/${assetId}`
const existing = this.loading.get(token)
if (existing) return existing
this.assetErrors.delete(token)
const job = (async () => {
signal.throwIfAborted()
try {
const file = await this.localFile(key, assetId)
const stat = await lstat(file.path)
const signature = `${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}:${file.sha256}`
if (this.checked.get(token) === signature) return
const actual = await checksum(file.path)
if (actual.size === file.size && actual.sha256 === file.sha256) {
this.checked.set(token, signature)
return
}
} catch {
/* Отсутствующий или повреждённый файл скачивается заново. */
}
if (await this.checkAsset(key, assetId)) return
signal.throwIfAborted()
await this.remembering.get(key)
if (!this.descriptions.has(key)) await this.remember(pkg)
@@ -409,6 +476,8 @@ export class PackageStore {
await rename(metadata, await this.path(key, 'cache', `${assetId}.json`))
// Восстановленный кеш заменяет повреждённый ресурс старого пакета.
this.repaired.add(token)
const info = await lstat(await this.path(key, 'cache', assetId))
this.checked.set(token, `${info.size}:${info.mtimeMs}:${info.ctimeMs}:${file.sha256}`)
} finally {
await rm(path, { force: true })
await rm(metadata, { force: true })
@@ -417,6 +486,10 @@ export class PackageStore {
this.loading.set(token, job)
try {
await job
} catch (error) {
if (!signal.aborted)
this.assetErrors.set(token, error instanceof Error ? error.message : 'Файл недоступен')
throw error
} finally {
if (this.loading.get(token) === job) this.loading.delete(token)
}
+1
View File
@@ -21,4 +21,5 @@ contextBridge.exposeInMainWorld('screenBridge', {
show: (id: number) => ipcRenderer.invoke('show', id),
packages: () => ipcRenderer.invoke('packages'),
selectPackage: (key: string | null) => ipcRenderer.invoke('select-package', key),
openMaterialsFolder: (key: string) => ipcRenderer.invoke('open-materials-folder', key),
})
+1
View File
@@ -155,6 +155,7 @@ export function requestErrorContext(
GetGames: 'Список игр',
CreateScreenSession: 'Создание экрана',
BindScreen: 'Подключение экрана',
UnbindScreen: 'Отключение экрана',
SetScreenSound: 'Назначение звука',
SetGameMusic: 'Управление музыкой',
SetScreenMode: 'Режим экрана',