up
This commit is contained in:
+9
-3
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user