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 {
+30 -1
View File
@@ -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 (
+88 -13
View File
@@ -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<PackageSummary[]>([])
const localKey = ref<string | null>(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(() => {
<div class="connection-content">
<div v-if="selectedKey" class="current-game">
<span data-testid="control-game">Текущая игра: {{ selectedName }}</span>
<n-button :disabled="isBusy || state.busy" @click="emit('resetGame')">Сменить игру</n-button>
<n-button :disabled="isBusy || state.busy || outputOpen" @click="emit('resetGame')">Сменить игру</n-button>
</div>
<template v-if="!selectedKey">
<span>Скачанные игры</span>
<n-alert v-if="localPackagesError" type="warning">{{ localPackagesError }}</n-alert>
<div class="game-selection">
<n-select
v-model:value="localKey"
data-testid="local-game-select"
class="game-select"
clearable
:options="localOptions"
:loading="localPackagesLoading"
:disabled="isBusy || state.busy || outputOpen || localPackagesLoading"
placeholder="Выберите скачанную игру"
:input-props="{ 'aria-label': 'Скачанные игры' }"
/>
<n-button
data-testid="local-game-confirm"
:disabled="!localKey || isBusy || state.busy || outputOpen || localPackagesLoading"
@click="emit('selectLocal', localKey!)"
>Выбрать скачанную игру</n-button>
</div>
<span v-if="!localPackagesLoading && !localPackagesError && !localPackages.length">Скачанных игр пока нет</span>
</template>
<p v-if="(selectedKey && loadedMaterials < state.materials.length) || (!selectedKey && localPackages.some((pkg) => pkg.key === localKey && pkg.downloadedFiles < pkg.totalFiles))">
Без сети доступны только скачанные материалы.
</p>
<template v-if="state.authenticated">
<div v-if="!selectedKey" class="game-selection">
<n-select
@@ -256,14 +324,12 @@ const gameOptions = computed(() => {
<path d="M6.1 6.1A8 8 0 0 1 20 12M4 12a8 8 0 0 0 13.9 5.9" />
</svg>
</n-button>
<n-button
:disabled="gameId === null || isBusy || state.busy"
@click="run({ action: 'select-game', gameId: gameId! })"
>Выбрать игру</n-button
>
</div>
<n-button
v-if="!selectedKey"
class="game-confirm"
:disabled="gameId === null || isBusy || state.busy"
@click="run({ action: 'select-game', gameId: gameId! })"
>Выбрать игру</n-button
>
</template>
<p v-if="state.preparation.active" role="status" data-testid="materials-progress">
Материалы: {{ state.preparation.completed }}/{{ state.preparation.total }} ·
@@ -389,6 +455,15 @@ const gameOptions = computed(() => {
</n-collapse-item>
</n-collapse>
<p v-if="!state.authenticated">Войдите на сервер, чтобы выбрать игру.</p>
<n-button
class="clear-games"
data-testid="clear-games"
text
type="error"
size="small"
:disabled="cannotClearGames"
@click="clearGames"
>Удалить все скачанные игры</n-button>
</div>
</n-card>
<GameControlPanel v-if="bridge && isReady" :state="state" :is-busy="isBusy" :selected-key="selectedKey" :selected-name="selectedName" @action="run">
@@ -405,6 +480,10 @@ const gameOptions = computed(() => {
</template>
<style scoped>
.clear-games {
align-self: flex-start;
}
.materials-list {
display: flex;
flex-direction: column;
@@ -525,10 +604,6 @@ const gameOptions = computed(() => {
}
.connection-content > .auth-action,
.connection-content > .game-confirm {
align-self: flex-end;
}
.connection-error {
overflow-wrap: anywhere;
white-space: pre-wrap;
+10
View File
@@ -198,6 +198,14 @@ async function resetGame() {
onlineState.value = await bridge.onlineState()
})
}
async function selectLocalGame(key: string) {
if (!bridge || isBusy.value || isOnlineBusy.value || onlineState.value.busy) return
await run(async () => {
acceptState(await bridge.selectPackage(key))
onlineState.value = await bridge.onlineState()
await refreshHost()
})
}
async function refreshHost() {
if (!bridge) return
host.value = await bridge.host()
@@ -249,6 +257,8 @@ onUnmounted(() => {
@action="runOnline"
@cancel="cancelOnline"
@reset-game="resetGame"
@select-local="selectLocalGame"
:output-open="state?.outputOpen ?? false"
:selected-key="state?.catalog.key ?? null"
:selected-name="state?.catalog.name ?? null"
:can-connect-current="
+1 -1
View File
@@ -32,7 +32,7 @@ export type OnlineIntent =
| { action: 'login'; server: string; email: string; password: string }
| { action: 'retry-materials' }
| { action: 'connect'; gameId?: number }
| { action: 'logout' | 'games' | 'disconnect' | 'sound' | 'results' | 'cancel' }
| { action: 'logout' | 'games' | 'disconnect' | 'sound' | 'results' | 'cancel' | 'clear-games' }
| { action: 'select-game' | 'download' | 'check'; gameId: number }
| { action: 'remove'; key: string }
export interface OnlineState {
+2
View File
@@ -142,6 +142,8 @@ export interface PackageSummary {
key: string
version: string
name: string
downloadedFiles: number
totalFiles: number
}
export interface ScreenPlan {
steps: ScreenStep[]