diff --git a/electron/main.ts b/electron/main.ts index 070501a..85472f4 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -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) diff --git a/electron/online.test.ts b/electron/online.test.ts index 3a6823c..8eedf07 100644 --- a/electron/online.test.ts +++ b/electron/online.test.ts @@ -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() diff --git a/electron/online.ts b/electron/online.ts index 8898c17..82a4d1b 100644 --- a/electron/online.ts +++ b/electron/online.ts @@ -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 = { @@ -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() diff --git a/electron/package-store.test.ts b/electron/package-store.test.ts index 8ecbc3e..3cba836 100644 --- a/electron/package-store.test.ts +++ b/electron/package-store.test.ts @@ -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') diff --git a/electron/package-store.ts b/electron/package-store.ts index f1cc816..db8e04a 100644 --- a/electron/package-store.ts +++ b/electron/package-store.ts @@ -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 { + 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 { diff --git a/scripts/smoke.cjs b/scripts/smoke.cjs index fb70b2d..fbfc453 100644 --- a/scripts/smoke.cjs +++ b/scripts/smoke.cjs @@ -219,6 +219,25 @@ app.whenReady().then(async () => { (await evaluate('!!window.screenBridge && !!document.querySelector("main.control")')), 'control UI', ) + if (process.argv.includes('--clear-games')) { + await evaluate(`window.screenBridge.selectPackage(${JSON.stringify(offlinePackage.key)})`) + await poll(() => evaluate('!!document.querySelector("[data-testid=clear-games]:not([disabled])")'), 'clear games button') + const clickDialog = (label) => evaluate(`Array.from(document.querySelectorAll('.n-dialog__action button')).find(b => b.textContent.trim() === ${JSON.stringify(label)}).click()`) + await evaluate('document.querySelector("[data-testid=clear-games]").click()') + await poll(() => evaluate('!!document.querySelector(".n-dialog")'), 'clear games confirmation') + await clickDialog('Отмена') + assert.equal((await evaluate('window.screenBridge.packages()')).length, 1) + await poll(() => evaluate('!document.querySelector(".n-dialog")'), 'clear games cancelled') + await evaluate('document.querySelector("[data-testid=clear-games]").click()') + await poll(() => evaluate('!!document.querySelector(".n-dialog")'), 'clear games confirmation again') + await clickDialog('Удалить всё') + await poll(async () => (await evaluate('window.screenBridge.packages()')).length === 0, 'packages cleared') + await poll(() => evaluate('document.body.textContent.includes("Скачанных игр пока нет")'), 'empty local games UI') + assert.equal((await evaluate('window.screenBridge.state()')).catalog.key, null) + console.log('SCREEN_CLEAR_GAMES_PASS') + app.exit(0) + return + } if (manual) { await evaluate(`window.screenBridge.selectPackage(${JSON.stringify(offlinePackage.key)})`) console.log( @@ -293,7 +312,17 @@ app.whenReady().then(async () => { await command({ action: 'pause', channel: 'main' }) assert.equal(await evaluate('document.body.textContent.includes("Материалы показа")'), false) assert.equal(await evaluate('document.body.textContent.includes("Вступление и видео")'), false) - await evaluate(`window.screenBridge.selectPackage(${JSON.stringify(offlinePackage.key)})`) + await assert.rejects(evaluate(`window.screenBridge.selectPackage(${JSON.stringify(offlinePackage.key)})`)) + await evaluate('window.screenBridge.closeOutput()') + const localPackages = await evaluate('window.screenBridge.packages()') + assert.equal(localPackages[0].downloadedFiles, localPackages[0].totalFiles) + await poll(() => evaluate('!!document.querySelector("[data-testid=local-game-select] .n-base-selection")'), 'local game selector') + await evaluate('document.querySelector("[data-testid=local-game-select] .n-base-selection").click()') + await poll(() => evaluate('!!document.querySelector(".n-base-select-option")'), 'local game option') + await evaluate('document.querySelector(".n-base-select-option").click()') + await evaluate('document.querySelector("[data-testid=local-game-confirm]").click()') + await poll(async () => (await state()).catalog.key === offlinePackage.key, 'local game selected') + await evaluate(`window.screenBridge.show(${targetDisplay.id})`) await poll(async () => { const s = await state() return ( diff --git a/src/components/ConnectionPanel.vue b/src/components/ConnectionPanel.vue index b257157..24b6152 100644 --- a/src/components/ConnectionPanel.vue +++ b/src/components/ConnectionPanel.vue @@ -11,8 +11,10 @@ import { NTag, NTable, useMessage, + useDialog, } from 'naive-ui' import { computed, ref, watch } from 'vue' +import type { PackageSummary } from '../types/screen' import ScreenConnectionPanel from './ScreenConnectionPanel.vue' import GameControlPanel from './GameControlPanel.vue' import { @@ -29,10 +31,50 @@ const props = defineProps<{ selectedKey: string | null selectedName: string | null canConnectCurrent: boolean + outputOpen: boolean }>() -const emit = defineEmits<{ action: [intent: OnlineIntent]; cancel: []; resetGame: [] }>() +const emit = defineEmits<{ action: [intent: OnlineIntent]; cancel: []; resetGame: []; selectLocal: [key: string] }>() const bridge = window.screenBridge const message = useMessage() +const dialog = useDialog() +const cannotClearGames = computed(() => props.isBusy || props.state.busy || props.outputOpen || props.state.preparation.active) +function clearGames() { + if (cannotClearGames.value) return + dialog.warning({ + title: 'Удалить все скачанные игры?', + content: 'Удалить все сохранённые игры, материалы, локальные планы экранов и снимки результатов? Для повторной загрузки потребуется сеть. Данные на сервере сохранятся.', + positiveText: 'Удалить всё', + negativeText: 'Отмена', + onPositiveClick: () => { + if (!cannotClearGames.value) run({ action: 'clear-games' }) + }, + }) +} +const localPackages = ref([]) +const localKey = ref(null) +const localPackagesLoading = ref(false) +const localPackagesError = ref('') +const localOptions = computed(() => localPackages.value.map((pkg) => ({ + label: `${pkg.name} · скачано ${pkg.downloadedFiles} из ${pkg.totalFiles}`, + value: pkg.key, +}))) +watch( + [() => props.isReady, () => props.state.packagesRevision, () => props.state.busy, () => props.selectedKey], + async () => { + if (!bridge || !props.isReady || props.state.busy) return + localPackagesLoading.value = true + localPackagesError.value = '' + try { + localPackages.value = await bridge.packages() + if (!localPackages.value.some((pkg) => pkg.key === localKey.value)) localKey.value = null + } catch (error) { + localPackagesError.value = error instanceof Error ? error.message : 'Не удалось прочитать скачанные игры' + } finally { + localPackagesLoading.value = false + } + }, + { immediate: true }, +) async function openMaterialsFolder() { if (!bridge || !props.selectedKey) return try { @@ -223,8 +265,34 @@ const gameOptions = computed(() => {
Текущая игра: {{ selectedName }} - Сменить игру + Сменить игру
+ +

+ Без сети доступны только скачанные материалы. +

Материалы: {{ state.preparation.completed }}/{{ state.preparation.total }} · @@ -389,6 +455,15 @@ const gameOptions = computed(() => {

Войдите на сервер, чтобы выбрать игру.

+ Удалить все скачанные игры
@@ -405,6 +480,10 @@ const gameOptions = computed(() => {