This commit is contained in:
2026-10-18 23:18:25 +07:00
parent 8da494c424
commit fc326f46c8
10 changed files with 336 additions and 29 deletions
+9 -3
View File
@@ -592,11 +592,11 @@ app
intent && typeof intent === 'object' && 'action' in intent ? intent.action : null
try {
const result = await online!.act(intent)
if (['download', 'check', 'remove'].includes(String(action)))
if (['download', 'check', 'remove', 'clear-games'].includes(String(action)))
diagnostics?.record('package', { action, status: 'success' })
return result
} catch (error) {
if (['download', 'check', 'remove'].includes(String(action)))
if (['download', 'check', 'remove', 'clear-games'].includes(String(action)))
diagnostics?.record('package', { action, status: 'failed' })
throw error
} finally {
@@ -622,7 +622,13 @@ app
requireControl(event)
return packages
.list()
.map((pkg) => ({ key: pkg.key, version: pkg.version, name: pkg.package.game.name }))
.map((pkg) => ({
key: pkg.key,
version: pkg.version,
name: pkg.package.game.name,
downloadedFiles: pkg.package.assets.filter((asset) => packages.canRead(pkg.key, asset.id)).length,
totalFiles: pkg.package.assets.length,
}))
})
ipcMain.handle('select-package', async (event, key: unknown) => {
requireControl(event)
+113 -1
View File
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict'
import { test, type TestContext } from 'node:test'
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { Controller } from './controller.js'
@@ -443,6 +443,79 @@ test('список материалов показывает отдельную
assert.deepEqual(f.online.snapshot().materials, [])
})
test('полная очистка без сети удаляет игры и локальные данные, сохраняя вход после перезапуска', async (t) => {
const f = await fixture(t)
await f.prepare()
const directory = join(f.directory, 'connection')
const key = f.controller.state.catalog.key!
await writeFile(join(directory, `plan-${key}.json`), '{}')
await writeFile(join(directory, `results-${'a'.repeat(64)}.json.tmp`), '{}')
const credentials = JSON.parse(f.secrets.decrypt(await readFile(join(directory, 'credentials.bin'))))
f.model.offline = true
const requests = f.requests.length
const revision = f.online.snapshot().packagesRevision
const result = await f.online.act({ action: 'clear-games' })
assert.equal(result.authenticated, true)
assert.equal(result.screenId, null)
assert.equal(result.game, null)
assert.deepEqual(result.materials, [])
assert.equal(result.packagesRevision, revision + 1)
assert.equal(result.notice, 'Все сохранённые игры удалены')
assert.equal(f.controller.state.catalog.key, null)
assert.deepEqual(f.store.list(), [])
assert.equal(f.requests.length, requests)
assert.ok(!(await readdir(directory)).some((name) => /^(plan|results)-/.test(name)))
const saved = JSON.parse(f.secrets.decrypt(await readFile(join(directory, 'credentials.bin'))))
assert.equal(saved.access, credentials.access)
assert.equal(saved.screen, null)
await f.online.close()
await f.store.initialize()
await f.online.initialize()
assert.equal(f.controller.state.catalog.key, null)
assert.equal(f.online.snapshot().authenticated, true)
assert.deepEqual(f.store.list(), [])
await f.online.act({ action: 'logout' })
await f.online.act({ action: 'clear-games' })
})
test('ошибка очистки обновляет список и позволяет повторить удаление', async (t) => {
const f = await fixture(t)
await f.prepare()
const clear = f.store.clear.bind(f.store)
f.store.clear = async () => {
assert.equal(f.online.blocksOutput, true)
throw new Error('disk failure')
}
const revision = f.online.snapshot().packagesRevision
await assert.rejects(f.online.act({ action: 'clear-games' }), /Очистка не завершена/)
assert.equal(f.online.blocksOutput, false)
assert.equal(f.online.snapshot().packagesRevision, revision + 1)
assert.equal(f.controller.state.catalog.key, null)
assert.equal(f.store.list().length, 1)
assert.equal(f.online.snapshot().notice, '')
f.store.clear = clear
await f.online.act({ action: 'clear-games' })
assert.deepEqual(f.store.list(), [])
})
test('очистка отклоняется во время показа и подготовки материалов', async (t) => {
const f = await fixture(t)
await f.prepare()
const key = f.controller.state.catalog.key
f.controller.state.outputOpen = true
await assert.rejects(f.online.act({ action: 'clear-games' }), /Закройте показ/)
assert.equal(f.controller.state.catalog.key, key)
assert.equal(f.store.list().length, 1)
f.controller.state.outputOpen = false
f.online.state.preparation.active = true
await assert.rejects(f.online.act({ action: 'clear-games' }), /Подождите/)
assert.equal(f.store.list().length, 1)
f.online.state.preparation.active = false
f.online.state.busy = true
await assert.rejects(f.online.act({ action: 'clear-games' }), /Подождите/)
f.online.state.busy = false
})
test('готовый пакет показывает все материалы загруженными без входа', async (t) => {
const f = await fixture(t)
await f.prepare()
@@ -451,6 +524,45 @@ test('готовый пакет показывает все материалы
assert.ok(f.online.snapshot().materials.every((m) => m.status === 'ready'))
})
test('локальную игру можно выбрать без сети и входа, но нельзя сменить во время показа', async (t) => {
const f = await fixture(t)
await f.prepare()
const key = f.controller.state.catalog.key!
await f.online.act({ action: 'logout' })
await f.online.select(null)
f.model.offline = true
const requests = f.requests.length
await f.online.select(key)
assert.equal(f.controller.state.catalog.key, key)
assert.ok(f.online.snapshot().materials.every((m) => m.status === 'ready'))
assert.equal(f.requests.length, requests)
f.controller.state.outputOpen = true
await assert.rejects(f.online.select(null), /Закройте показ/)
assert.equal(f.controller.state.catalog.key, key)
f.controller.state.outputOpen = false
})
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 asset = f.online.snapshot().materials.find((m) => m.usages.some((u) => u.name === 'Вступление'))!
await f.online.act({ action: 'download-material', key, assetId: asset.id })
await f.online.act({ action: 'logout' })
await f.online.select(null)
await f.online.close()
f.model.offline = true
await f.store.initialize()
await f.online.initialize()
const requests = f.requests.length
await f.online.select(key)
const materials = f.online.snapshot().materials
assert.equal(materials.find((m) => m.id === asset.id)!.status, 'ready')
assert.ok(materials.some((m) => m.status === 'missing'))
assert.equal(f.requests.length, requests)
})
test('один файл сохраняет назначения в группе игры и плейлиста без дублирования счётчика', async (t) => {
const f = await fixture(t)
await f.login()
+37 -2
View File
@@ -2,7 +2,7 @@ import { requestErrorContext } from './request-error.js'
import { createEveningDetectiveServerClient } from './api/generated/crabs/evening_detective_server/index.js'
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 { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import {
DEFAULT_SERVER,
@@ -916,8 +916,9 @@ export class Online {
return true
}
}
private clearingGames = false
get blocksOutput() {
return this.downloadKey !== null && this.downloadKey === this.controller.state.catalog.key
return this.clearingGames || (this.downloadKey !== null && this.downloadKey === this.controller.state.catalog.key)
}
get busy() {
return this.state.busy
@@ -1054,6 +1055,7 @@ export class Online {
}
async select(key: string | null, persist = true) {
if (this.busy) throw new Error('Подождите завершения подготовки материалов')
if (this.controller.state.outputOpen) throw new Error('Закройте показ перед выбором игры')
let pkg
try {
pkg =
@@ -1238,6 +1240,8 @@ export class Online {
return this.snapshot()
}
if (this.busy) throw new Error('Подождите завершения текущего действия')
if (data.action === 'clear-games' && (this.state.preparation.active || this.resultsBusy || this.planBusy > 0))
throw new Error('Подождите завершения подготовки материалов и результатов')
const isBackgroundRefresh = data.action === 'playlists'
this.state.busy = true
const messages: Record<OnlineIntent['action'], string> = {
@@ -1263,6 +1267,7 @@ export class Online {
check: 'Проверка материалов игры…',
'retry-materials': 'Подготовка материалов игры…',
remove: 'Удаление локальных материалов…',
'clear-games': 'Удаление всех сохранённых игр…',
sound: 'Изменение назначения звука…',
results: 'Загрузка результатов игры…',
cancel: 'Отмена загрузки…',
@@ -1453,6 +1458,36 @@ export class Online {
this.state.packagesRevision++
return
}
case 'clear-games': {
if (this.controller.state.outputOpen) throw new Error('Закройте показ перед очисткой игр')
this.clearingGames = true
this.detach()
this.controller.selectPackage(null)
this.controller.setResults(null)
this.plan = { steps: structuredClone(this.controller.state.catalog.steps), currentStep: 0 }
this.cachedPlan = ''
this.planNotice = ''
try {
await Promise.allSettled([
this.materialRefresh, this.materialWork, this.mainWork, this.musicWork, this.planWrites,
])
await this.saveSelection()
await this.persistSecrets()
await this.packages.clear()
for (const name of await readdir(this.directory)) {
if (/^(plan|results)-[a-f0-9]{64}\.json(?:\.tmp)?$/.test(name))
await rm(join(this.directory, name), { force: true })
}
this.state.notice = 'Все сохранённые игры удалены'
} catch {
await this.packages.initialize().catch(() => {})
throw new Error('Очистка не завершена. Повторите удаление оставшихся данных')
} finally {
this.clearingGames = false
this.state.packagesRevision++
}
return
}
case 'retry-materials':
this.lastMaterials = -Infinity
await this.refreshMaterials()
+23
View File
@@ -70,6 +70,29 @@ async function fixture(t: TestContext) {
return { root, dir, state, calls, delays, dependencies, store, pkg, key }
}
test('полная очистка удаляет пакеты, частичный кеш, повреждённые остатки и ссылки, сохраняя внешние файлы', async (t) => {
const f = await fixture(t)
await f.store.download(f.pkg)
const partial = description('https://partial.test')
await f.store.remember(partial)
await f.store.ensureAsset(partial, partial.assets[0]!.id, new AbortController().signal)
const broken = join(f.root, 'a'.repeat(64))
await mkdir(broken)
await writeFile(join(broken, 'current.json'), 'broken')
const outside = join(f.dir, 'outside')
await mkdir(outside)
await writeFile(join(outside, 'keep'), 'keep')
await symlink(outside, join(f.root, 'b'.repeat(64)))
await f.store.clear()
assert.deepEqual(f.store.list(), [])
assert.deepEqual(await readdir(f.root), [])
assert.equal(await readFile(join(outside, 'keep'), 'utf8'), 'keep')
await assert.rejects(f.store.localFile(f.key, f.pkg.assets[0]!.id))
await f.store.initialize()
assert.deepEqual(f.store.list(), [])
await f.store.clear()
})
test('запуск без сети проверяет отдельный кеш всех игр и сообщает прогресс', async (t) => {
const f = await fixture(t)
const other = description('https://other.test')
+23 -8
View File
@@ -304,14 +304,29 @@ export class PackageStore {
await this.exclusive(async () => {
const dir = await this.path(key)
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)
this.forget(key)
})
}
private forget(key: string) {
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)
}
async clear(): Promise<void> {
await this.exclusive(async () => {
await Promise.allSettled([...this.loading.values(), ...this.remembering.values()])
const root = await this.path()
for (const key of await readdir(root)) {
if (!hex.test(key)) continue
// Удаляется сама ссылка, без перехода к её цели.
await rm(join(root, key), { recursive: true, force: true })
this.forget(key)
}
})
}
private descriptionPackage(pkg: ScreenPackage): LocalPackage {