From 5976b38304c8f0df7b17fa498a79eb1213bc90b9 Mon Sep 17 00:00:00 2001 From: Zaldaryon <273555259+Zaldaryon@users.noreply.github.com> Date: Tue, 15 Sep 2026 13:57:23 -0300 Subject: [PATCH 1/3] Add per-world save management --- docs/SUMMARY.md | 1 + docs/get-started/usage/game-client/worlds.md | 18 + src/config/configManager.ts | 11 + src/domain/config/migrations.ts | 18 +- src/domain/installations/delete.ts | 3 +- src/domain/worlds/worlds.ts | 72 ++++ src/global.d.ts | 21 ++ src/ipc/archiveValidation.ts | 24 ++ src/ipc/handlers/configHandlers.ts | 9 +- src/ipc/handlers/gameHandlers.ts | 33 +- src/ipc/handlers/worldsHandlers.ts | 318 ++++++++++++++++++ src/ipc/index.ts | 1 + src/ipc/installationActivity.ts | 42 +++ src/ipc/ipcChannels.ts | 7 + src/ipc/pathPolicy.ts | 5 + src/ipc/workers/compression.ts | 14 +- src/preload/index.ts | 8 + src/preload/preload.d.ts | 7 + src/renderer/src/App.tsx | 2 + .../features/installations/adapters/create.ts | 2 +- .../features/installations/adapters/delete.ts | 1 + .../installations/pages/ListInstallations.tsx | 3 + .../pages/ManageInstallationWorlds.tsx | 195 +++++++++++ src/renderer/src/locales/en-US.json | 35 ++ src/renderer/src/locales/fr-FR.json | 35 ++ src/renderer/src/locales/pt-BR.json | 35 ++ tests/domain/config/migrations.test.ts | 10 +- tests/domain/worlds.test.ts | 50 +++ tests/ipc/compression.test.ts | 23 +- tests/ipc/installationActivity.test.ts | 31 ++ tests/ipc/workerHost.test.ts | 12 +- tests/renderer-dom/helpers/windowApi.ts | 7 + 32 files changed, 1017 insertions(+), 36 deletions(-) create mode 100644 docs/get-started/usage/game-client/worlds.md create mode 100644 src/domain/worlds/worlds.ts create mode 100644 src/ipc/handlers/worldsHandlers.ts create mode 100644 src/ipc/installationActivity.ts create mode 100644 src/renderer/src/features/installations/pages/ManageInstallationWorlds.tsx create mode 100644 tests/domain/worlds.test.ts create mode 100644 tests/ipc/installationActivity.test.ts diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index cf757701..17ee4a89 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -19,6 +19,7 @@ - [Play Vintage Story](get-started/usage/game-client/play-vintage-story.md) - [Edit Installations](get-started/usage/game-client/edit-installations.md) - [Installation Backups](get-started/usage/game-client/backups.md) + - [Manage worlds](get-started/usage/game-client/worlds.md) - [Install/Manage Mods](get-started/usage/game-client/install-manage-mods.md) - [🌐 Translation](get-started/translation/README.md) - [Option 1](get-started/translation/option-1.md) diff --git a/docs/get-started/usage/game-client/worlds.md b/docs/get-started/usage/game-client/worlds.md new file mode 100644 index 00000000..788c18a8 --- /dev/null +++ b/docs/get-started/usage/game-client/worlds.md @@ -0,0 +1,18 @@ +# Manage worlds + +Open the worlds button beside an Installation to see the `.vcdbs` worlds in its +`Saves` folder. The list shows each world's size, last modification time, and +the number of launcher backups that exist for it. + +You can back up, restore, delete, copy, or move one world at a time. Copying or +moving to another Installation adds a numeric suffix when the destination +already has a world with that name. Transfers between different Vintage Story +versions are allowed but show a warning. + +World changes are disabled while either Installation is playing. The launcher +also refuses to change a world while Vintage Story has its SQLite `-wal` or +`-shm` sidecar open; close the game first so no recent save data is lost. + +World backups are stored beneath the configured Backups folder and survive +deleting the live world. A restore replaces only the selected `.vcdbs` file; +other files in `Saves` are left untouched. diff --git a/src/config/configManager.ts b/src/config/configManager.ts index b238705e..5b72d614 100644 --- a/src/config/configManager.ts +++ b/src/config/configManager.ts @@ -13,6 +13,7 @@ import { normalizeModDbVisibility } from "@domain/moddbVisibility" import { normalizeReceiveBetaUpdates } from "@domain/appUpdate/betaUpdates" import { DEFAULT_COMPRESSION_LEVEL, DEFAULT_CONFIG_BASE } from "@domain/config/defaults" import { normalizeServerBookmarks } from "@domain/servers/bookmarks" +import { isSafeWorldName } from "@domain/worlds/worlds" const defaultConfig: ConfigType = { ...DEFAULT_CONFIG_BASE, @@ -34,6 +35,7 @@ const defaultInstallation: InstallationType = { backupsAuto: false, compressionLevel: DEFAULT_COMPRESSION_LEVEL, backups: [], + worldBackups: [], lastTimePlayed: -1, totalTimePlayed: 0, mesaGlThread: false, @@ -328,6 +330,14 @@ function normalizeBackup(value: unknown): BackupType | null { } } +function normalizeWorldBackup(value: unknown): WorldBackupType | null { + if (!isRecord(value)) return null + const backup = normalizeBackup(value) + const worldName = asString(value.worldName, "", 255) + if (!backup || !isSafeWorldName(worldName)) return null + return { ...backup, worldName } +} + function normalizeInstallation(value: unknown): InstallationType | null { if (!isRecord(value)) return null const installation: InstallationType = { @@ -347,6 +357,7 @@ function normalizeInstallation(value: unknown): InstallationType | null { .filter((backup): backup is BackupType => backup !== null) .slice(0, 100) : [], + worldBackups: Array.isArray(value.worldBackups) ? value.worldBackups.map(normalizeWorldBackup).filter((backup): backup is WorldBackupType => backup !== null) : [], lastTimePlayed: asNumber(value.lastTimePlayed, defaultInstallation.lastTimePlayed, -1, Number.MAX_SAFE_INTEGER), totalTimePlayed: asNumber(value.totalTimePlayed, defaultInstallation.totalTimePlayed, 0, Number.MAX_SAFE_INTEGER), mesaGlThread: asBoolean(value.mesaGlThread, defaultInstallation.mesaGlThread), diff --git a/src/domain/config/migrations.ts b/src/domain/config/migrations.ts index 46f9d097..d253ced7 100644 --- a/src/domain/config/migrations.ts +++ b/src/domain/config/migrations.ts @@ -18,7 +18,7 @@ */ /** Schema every config the launcher writes today carries. */ -export const CURRENT_CONFIG_SCHEMA = 5 +export const CURRENT_CONFIG_SCHEMA = 6 /** * First schema expressed as an integer. @@ -339,8 +339,22 @@ export const addGameVersionIdentity: ConfigMigration = { } } +/** Gives every installation its own durable world-backup record collection. */ +export const addWorldBackupRecords: ConfigMigration = { + fromSchema: 5, + toSchema: 6, + migrate(doc: unknown): unknown { + if (!isRecord(doc) || !Array.isArray(doc.installations)) return { ...(doc as Record) } + const installations = doc.installations.map((entry) => { + if (!isRecord(entry)) return entry + return Array.isArray(entry.worldBackups) ? entry : { ...entry, worldBackups: [] } + }) + return { ...doc, installations } + } +} + /** Every migration the launcher knows, lowest schema first. */ -export const CONFIG_MIGRATIONS: readonly ConfigMigration[] = [floatMarkerToIntegerSchema, stampLinkedOnExternalVersions, singleAccountToAccountList, addGameVersionIdentity] +export const CONFIG_MIGRATIONS: readonly ConfigMigration[] = [floatMarkerToIntegerSchema, stampLinkedOnExternalVersions, singleAccountToAccountList, addGameVersionIdentity, addWorldBackupRecords] function byFromSchema(migrations: readonly ConfigMigration[]): Map { return new Map(migrations.map((migration) => [migration.fromSchema, migration])) diff --git a/src/domain/installations/delete.ts b/src/domain/installations/delete.ts index f048615f..6ae516f6 100644 --- a/src/domain/installations/delete.ts +++ b/src/domain/installations/delete.ts @@ -6,6 +6,7 @@ import { deleteInstallationBackup } from "./backupDeletion" export interface InstallationDeleteSnapshot { path: string backups: readonly (Pick & { isDeleting?: boolean; isRestoring?: boolean })[] + worldBackups?: readonly (Pick & { isDeleting?: boolean; isRestoring?: boolean })[] isPlaying: boolean isBackingUp: boolean isRestoringBackup: boolean @@ -80,7 +81,7 @@ export async function deleteInstallation(ports: DeleteInstallationPorts, input: const failedBackupPaths: string[] = [] - for (const backup of installation.backups) { + for (const backup of [...installation.backups, ...(installation.worldBackups ?? [])]) { const result = await deleteInstallationBackup( { fileSystem: ports.fileSystem }, { backup: { id: backup.id, path: backup.path, isDeleting: backup.isDeleting ?? false, isRestoring: backup.isRestoring ?? false } } diff --git a/src/domain/worlds/worlds.ts b/src/domain/worlds/worlds.ts new file mode 100644 index 00000000..7d54948d --- /dev/null +++ b/src/domain/worlds/worlds.ts @@ -0,0 +1,72 @@ +export const SAVES_FOLDER_NAME = "Saves" +export const WORLD_FILE_EXTENSION = ".vcdbs" +export const DEFAULT_WORLD_FILE_NAME = `default${WORLD_FILE_EXTENSION}` +export const MAX_WORLDS = 1_000 +export const WORLD_SIDECAR_SUFFIXES = ["-wal", "-shm"] as const + +export interface WorldFileEntry { + name: string + size: number + lastModified: number + isDefault: boolean + backupCount: number +} + +export function isSafeWorldName(name: unknown): name is string { + if (typeof name !== "string") return false + const stem = name.slice(0, -WORLD_FILE_EXTENSION.length) + const reservedDeviceName = /^(con|prn|aux|nul|clock\$|com[1-9]|lpt[1-9])(?:\..*)?$/i + const hasControlCharacter = Array.from(name).some((character) => { + const code = character.charCodeAt(0) + return code <= 0x1f || code === 0x7f + }) + return ( + name.length > WORLD_FILE_EXTENSION.length && + name.length <= 255 && + name.toLowerCase().endsWith(WORLD_FILE_EXTENSION) && + name !== "." && + name !== ".." && + !name.includes("/") && + !name.includes("\\") && + !name.includes("\0") && + !hasControlCharacter && + !/[ .]$/u.test(name) && + !reservedDeviceName.test(stem) + ) +} + +export function worldSidecarNames(name: string): string[] { + return WORLD_SIDECAR_SUFFIXES.map((suffix) => `${name}${suffix}`) +} + +export function hasWorldSidecars(names: readonly string[], worldName: string): boolean { + const entries = new Set(names.map((name) => name.toLocaleLowerCase("en-US"))) + return worldSidecarNames(worldName).some((name) => entries.has(name.toLocaleLowerCase("en-US"))) +} + +export function listWorlds(entries: readonly WorldFileEntry[]): WorldFileEntry[] { + return entries + .filter((entry) => isSafeWorldName(entry.name) && Number.isFinite(entry.size) && Number.isFinite(entry.lastModified)) + .sort((left, right) => right.lastModified - left.lastModified || left.name.localeCompare(right.name)) + .slice(0, MAX_WORLDS) +} + +export function collisionFreeWorldName(name: string, existingNames: readonly string[]): string { + const existing = new Set(existingNames.map((entry) => entry.toLocaleLowerCase("en-US"))) + if (!existing.has(name.toLocaleLowerCase("en-US"))) return name + const lower = WORLD_FILE_EXTENSION.length + const stem = name.slice(0, -lower) + for (let suffix = 2; suffix <= MAX_WORLDS + 1; suffix++) { + const candidate = `${stem} (${suffix})${WORLD_FILE_EXTENSION}` + if (!existing.has(candidate.toLocaleLowerCase("en-US"))) return candidate + } + throw new Error("No available world name") +} + +export function worldVersionWarning(sourceVersion: string, targetVersion: string): "different-version" | undefined { + return sourceVersion !== targetVersion ? "different-version" : undefined +} + +export function canTransferWorld(sourceInstallationId: string, targetInstallationId: string): boolean { + return sourceInstallationId !== targetInstallationId +} diff --git a/src/global.d.ts b/src/global.d.ts index 7bdb1c67..83cb8af8 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -216,6 +216,25 @@ declare global { _restoring?: boolean } + type WorldBackupType = BackupType & { + worldName: string + _deleting?: boolean + _restoring?: boolean + } + + type WorldType = { + name: string + size: number + lastModified: number + isDefault: boolean + backupCount: number + } + + type WorldListResult = { ok: true; worlds: WorldType[] } | { ok: false; reason: string } + type WorldBackupResult = { ok: true; backup: WorldBackupType } | { ok: false; reason: string } + type WorldOperationResult = { ok: true } | { ok: false; reason: string } + type WorldTransferResult = { ok: true; targetWorldName: string; warning?: "different-version" } | { ok: false; reason: string } + /** * One server an Installation can join straight from the launcher (#460). * @@ -246,6 +265,7 @@ declare global { backupsAuto: boolean compressionLevel: number backups: BackupType[] + worldBackups?: WorldBackupType[] lastTimePlayed: number totalTimePlayed: number mesaGlThread: boolean @@ -263,6 +283,7 @@ declare global { _backuping?: boolean _restoringBackup?: boolean _updatingMods?: boolean + _worldsCount?: number } type ConfigType = BasicConfigType & { diff --git a/src/ipc/archiveValidation.ts b/src/ipc/archiveValidation.ts index 6a89e122..6bed9552 100644 --- a/src/ipc/archiveValidation.ts +++ b/src/ipc/archiveValidation.ts @@ -24,6 +24,7 @@ import * as tar from "tar" import type { ArchiveSizeLimits } from "./validation" import { archiveSizeLimits, isArchiveSymlink, isSafeArchiveEntry, isSafeTarEntryType, isTarGzName } from "./validation" +import { isSafeWorldName } from "@domain/worlds/worlds" const MAX_ARCHIVE_ENTRIES = 100_000 @@ -155,3 +156,26 @@ export async function validateArchive(filePath: string, limits: ArchiveSizeLimit if (filePath.toLowerCase().endsWith(".zip")) return validateZipArchive(filePath, limits) throw new Error("Archive format is not supported") } + +/** Validates a launcher-created world archive before any entry is extracted. */ +export async function validateWorldBackupArchive(filePath: string, worldName: string): Promise { + if (!isTarGzName(filePath) || !isSafeWorldName(worldName)) throw new Error("World backup format is not supported") + + let count = 0 + let valid = false + try { + await tar.list({ + file: filePath, + onReadEntry: (entry) => { + count++ + const size = Number(entry.size) + if (count === 1 && entry.type === "File" && entry.path === worldName && isSafeArchiveEntry(entry.path) && Number.isFinite(size) && size >= 0 && size <= archiveSizeLimits(true).entryBytes) + valid = true + } + }) + } catch { + throw new Error("World backup could not be read") + } + + if (count !== 1 || !valid) throw new Error("World backup must contain exactly one world file") +} diff --git a/src/ipc/handlers/configHandlers.ts b/src/ipc/handlers/configHandlers.ts index 488599ef..9ec649bb 100644 --- a/src/ipc/handlers/configHandlers.ts +++ b/src/ipc/handlers/configHandlers.ts @@ -15,8 +15,15 @@ ipcMain.handle(IPC_CHANNELS.CONFIG_MANAGER.GET_CONFIG, async (event): Promise => { assertTrustedIpcSender(event) if (!isRecord(config)) return invalidPayloadResult() - const normalizedConfig = normalizeConfig(config) const currentConfig = await getConfig() + const requestedConfig = normalizeConfig(config) + const normalizedConfig = normalizeConfig({ + ...requestedConfig, + installations: requestedConfig.installations.map((installation) => ({ + ...installation, + worldBackups: currentConfig.installations.find((current) => current.id === installation.id)?.worldBackups ?? installation.worldBackups + })) + }) if (!(await assertConfigPathsAuthorized(normalizedConfig, currentConfig))) return unauthorizedPathResult() return saveOutcomeToResult(await saveConfig(normalizedConfig)) }) diff --git a/src/ipc/handlers/gameHandlers.ts b/src/ipc/handlers/gameHandlers.ts index 25bac33b..b78371ab 100644 --- a/src/ipc/handlers/gameHandlers.ts +++ b/src/ipc/handlers/gameHandlers.ts @@ -15,6 +15,7 @@ import { createProcessSampler } from "@src/ipc/adapters/processSampler" import { createPlaySessionRecorder, forgetPlaySessions, readPlaySessions, recordPlaySession } from "@src/ipc/playSessionsStore" import { getAccountSecrets, saveAccountSecrets } from "@src/ipc/accountStore" import { getConfig } from "@src/config/configManager" +import { clearInstallationPlaying, markInstallationPlaying } from "@src/ipc/installationActivity" import { detectInstalledGameVersion } from "@domain/versions/detect" import { buildSessionReport, type InstalledModRef } from "@domain/gameLogs/report" import { scanInstalledMods } from "@domain/mods/scanInstalled" @@ -270,6 +271,7 @@ ipcMain.handle(IPC_CHANNELS.GAME_MANAGER.EXECUTE_GAME, async (event, version: un safeVersion.path = await assertManagedPath(safeVersion.path, "game version path") safeInstallation.path = await assertManagedPath(safeInstallation.path, "installation path") const config = await getConfig() + const installationId = config.installations.find((candidate) => comparablePath(candidate.path) === comparablePath(safeInstallation.path))?.id const account = config.accounts.find((candidate) => candidate.playerUid === config.activeAccountId) ?? null const accountSecrets = account ? await getAccountSecrets(account.playerUid) : null logMessage("info", `[back] [ipc] [ipc/handlers/gameHandlers.ts] [EXECUTE_GAME] Trying to run Vintage Story ${safeVersion.version}.`) @@ -439,24 +441,31 @@ ipcMain.handle(IPC_CHANNELS.GAME_MANAGER.EXECUTE_GAME, async (event, version: un // The id comes from the config the launcher wrote, never from the renderer's own object, so the // file name a session lands under cannot be chosen by whatever sent the launch. - const installationId = config.installations.find((candidate) => comparablePath(candidate.path) === comparablePath(safeInstallation.path))?.id + const markedPlaying = installationId ? markInstallationPlaying(installationId) : false + if (installationId && !markedPlaying) return invalidRequestResult() // Windows has no /proc, so its sampler reads `tasklist` and needs a probe to run it with. Passing // it only there keeps macOS on the absent sampler, which is what the factory answers with none. const platform = os.platform() const samplerOptions = platform === "win32" ? { processProbe: tasklistProbe() } : {} const recorder = config.measurePlaySessions && installationId ? createPlaySessionRecorder(createProcessSampler(platform, samplerOptions)) : undefined - const outcome = await realGameProcess().run({ - command: plan.command, - args: plan.args, - env: { ...process.env, ...processEnv, ...plan.env }, - cwd: plan.cwd, - ...(recorder ? { onStarted: recorder.onStarted } : {}) - }) - - // Settles the sampling loop on the same path the launch outcome settles on, whichever way it - // went, so the timer cannot outlive this handler. - const session = await recorder?.finish() + let outcome: GameProcessOutcome + let session: PlaySession | undefined + try { + outcome = await realGameProcess().run({ + command: plan.command, + args: plan.args, + env: { ...process.env, ...processEnv, ...plan.env }, + cwd: plan.cwd, + ...(recorder ? { onStarted: recorder.onStarted } : {}) + }) + + // Settles the sampling loop on the same path the launch outcome settles on, whichever way it + // went, so the timer cannot outlive this handler. + session = await recorder?.finish() + } finally { + if (installationId) clearInstallationPlaying(installationId) + } if (session && installationId) { const stored = await recordPlaySession(installationId, session) const shape = session.partial ? "partial" : "complete" diff --git a/src/ipc/handlers/worldsHandlers.ts b/src/ipc/handlers/worldsHandlers.ts new file mode 100644 index 00000000..3b4c84ec --- /dev/null +++ b/src/ipc/handlers/worldsHandlers.ts @@ -0,0 +1,318 @@ +import { ipcMain } from "electron" +import fse from "fs-extra" +import { join } from "node:path" +import { randomUUID } from "node:crypto" +import { constants } from "node:fs" + +import { getConfig, saveConfig } from "@src/config/configManager" +import { assertConfiguredInstallationPath, assertManagedDeletionPath, assertManagedPath } from "@src/ipc/pathPolicy" +import { assertTrustedIpcSender } from "@src/ipc/ipcSecurity" +import { IPC_CHANNELS } from "@src/ipc/ipcChannels" +import { isInstallationPlaying, tryAcquireInstallationOperation } from "@src/ipc/installationActivity" +import { setShouldPreventClose } from "@src/utils/shouldPreventClose" +import { logMessage } from "@src/utils/logManager" +import { runCompression } from "@src/ipc/workers/compression" +import { extractTarGz } from "@src/ipc/workers/extraction" +import { validateWorldBackupArchive } from "@src/ipc/archiveValidation" +import { SAVES_FOLDER_NAME, WORLD_FILE_EXTENSION, collisionFreeWorldName, hasWorldSidecars, isSafeWorldName, listWorlds, worldVersionWarning } from "@domain/worlds/worlds" + +const WORLD_BACKUPS_FOLDER = "Worlds" + +type WorldOperationFailure = + | "invalid-request" + | "installation-not-found" + | "installation-playing" + | "saves-unavailable" + | "world-not-found" + | "world-busy" + | "world-has-sidecars" + | "archive-not-found" + | "operation-failed" +type WorldOperationResult = { ok: true } | { ok: false; reason: WorldOperationFailure } +type WorldFailureResult = { ok: false; reason: WorldOperationFailure } + +function failure(reason: WorldOperationFailure): { ok: false; reason: WorldOperationFailure } { + return { ok: false, reason } +} + +async function withCloseGuard(description: string, operation: () => Promise): Promise { + const token = randomUUID() + setShouldPreventClose("add", token, description) + try { + return await operation() + } finally { + setShouldPreventClose("remove", token, description) + } +} + +function operationFailure(reason: "playing" | "busy"): WorldFailureResult { + return failure(reason === "playing" ? "installation-playing" : "world-busy") +} + +function findInstallation(config: ConfigType, installationId: unknown): InstallationType | undefined { + if (typeof installationId !== "string" || installationId.length === 0 || installationId.length > 128 || installationId.includes("\0")) return undefined + return config.installations.find((installation) => installation.id === installationId) +} + +function worldPath(savesPath: string, worldName: string): string { + return join(savesPath, worldName) +} + +async function checkedInstallation(installationId: unknown): Promise<{ config: ConfigType; installation: InstallationType; savesPath: string } | { error: WorldFailureResult }> { + const config = await getConfig() + const installation = findInstallation(config, installationId) + if (!installation) return { error: failure("installation-not-found") } + + try { + const installationPath = await assertConfiguredInstallationPath(installation.path) + const savesPath = await assertManagedPath(join(installationPath, SAVES_FOLDER_NAME), "worlds folder", { allowMissing: true }) + return { config, installation, savesPath } + } catch { + return { error: failure("operation-failed") } + } +} + +async function listWorldEntries(savesPath: string, installation: InstallationType): Promise { + let names: string[] + try { + if (!(await fse.pathExists(savesPath))) return { ok: true, worlds: [] } + names = await fse.readdir(savesPath) + } catch { + return { ok: false, reason: "saves-unavailable" } + } + + const entries = [] + for (const name of names) { + if (!isSafeWorldName(name)) continue + try { + const stats = await fse.lstat(join(savesPath, name)) + if (!stats.isFile() || stats.isSymbolicLink()) continue + entries.push({ + name, + size: stats.size, + lastModified: stats.mtimeMs, + isDefault: name.toLocaleLowerCase("en-US") === `default${WORLD_FILE_EXTENSION}`, + backupCount: (installation.worldBackups ?? []).filter((backup) => backup.worldName.toLocaleLowerCase("en-US") === name.toLocaleLowerCase("en-US")).length + }) + } catch { + // A world can disappear while the player is looking at the list. + } + } + return { ok: true, worlds: listWorlds(entries) } +} + +async function findWorld(savesPath: string, requestedName: unknown): Promise<{ name: string; path: string; names: string[] } | null> { + if (!isSafeWorldName(requestedName)) return null + const names = await fse.readdir(savesPath).catch(() => []) + const name = names.find((candidate) => candidate.toLocaleLowerCase("en-US") === requestedName.toLocaleLowerCase("en-US")) + if (!name || !isSafeWorldName(name)) return null + const path = worldPath(savesPath, name) + const stats = await fse.lstat(path).catch(() => null) + if (!stats || !stats.isFile() || stats.isSymbolicLink()) return null + return { name, path, names } +} + +async function assertWorldWritable(world: { name: string; path: string; names: string[] }): Promise { + return !hasWorldSidecars(world.names, world.name) +} + +function updateInstallation(config: ConfigType, installationId: string, update: (installation: InstallationType) => InstallationType): ConfigType { + return { ...config, installations: config.installations.map((installation) => (installation.id === installationId ? update(installation) : installation)) } +} + +async function makeWorldBackup(installationId: unknown, requestedName: unknown): Promise { + const checked = await checkedInstallation(installationId) + if ("error" in checked) return checked.error + const { config, installation, savesPath } = checked + if (isInstallationPlaying(installation.id)) return failure("installation-playing") + if (!(await fse.pathExists(savesPath))) return failure("saves-unavailable") + const lease = tryAcquireInstallationOperation([installation.id]) + if (!lease.ok) return operationFailure(lease.reason) + try { + const world = await findWorld(savesPath, requestedName) + if (!world) return failure("world-not-found") + if (!(await assertWorldWritable(world))) return failure("world-has-sidecars") + await assertManagedPath(world.path, "world") + + const backupId = randomUUID() + const outputFolder = await assertManagedPath(join(config.backupsFolder, WORLD_BACKUPS_FOLDER), "world backup folder", { allowMissing: true }) + const archivePath = join(outputFolder, `${backupId}.tar.gz`) + return await withCloseGuard("Backing up a world.", async () => { + try { + await runCompression({ inputPath: world.path, outputPath: outputFolder, outputFileName: `${backupId}.tar.gz`, compressionLevel: installation.compressionLevel }) + const backup: WorldBackupType = { id: backupId, date: Date.now(), path: archivePath, worldName: world.name } + const nextConfig = updateInstallation(config, installation.id, (current) => ({ ...current, worldBackups: [backup, ...(current.worldBackups ?? [])] })) + if (!(await saveConfig(nextConfig))) { + await fse.remove(archivePath).catch(() => undefined) + return failure("operation-failed") + } + return { ok: true, backup } + } catch { + await fse.remove(archivePath).catch(() => undefined) + return failure("operation-failed") + } + }) + } finally { + lease.release() + } +} + +async function deleteWorld(installationId: unknown, requestedName: unknown): Promise { + const checked = await checkedInstallation(installationId) + if ("error" in checked) return checked.error + const { installation, savesPath } = checked + if (isInstallationPlaying(installation.id)) return failure("installation-playing") + const lease = tryAcquireInstallationOperation([installation.id]) + if (!lease.ok) return operationFailure(lease.reason) + try { + const world = await findWorld(savesPath, requestedName) + if (!world) return failure("world-not-found") + if (!(await assertWorldWritable(world))) return failure("world-has-sidecars") + await assertManagedDeletionPath(world.path) + try { + await fse.remove(world.path) + return { ok: true as const } + } catch { + return failure("operation-failed") + } + } finally { + lease.release() + } +} + +async function restoreWorld(installationId: unknown, backupIdValue: unknown): Promise { + const checked = await checkedInstallation(installationId) + if ("error" in checked) return checked.error + const { installation, savesPath } = checked + if (isInstallationPlaying(installation.id)) return failure("installation-playing") + if (typeof backupIdValue !== "string") return failure("invalid-request") + const backup = (installation.worldBackups ?? []).find((candidate) => candidate.id === backupIdValue) + if (!backup || !isSafeWorldName(backup.worldName)) return failure("archive-not-found") + if (!(await fse.pathExists(backup.path))) return failure("archive-not-found") + const lease = tryAcquireInstallationOperation([installation.id]) + if (!lease.ok) return operationFailure(lease.reason) + try { + const saveNames = await fse.readdir(savesPath).catch(() => []) + if (hasWorldSidecars(saveNames, backup.worldName)) return failure("world-has-sidecars") + const world = await findWorld(savesPath, backup.worldName) + if (world && !(await assertWorldWritable(world))) return failure("world-has-sidecars") + const result = await withCloseGuard("Restoring a world backup.", async () => { + await fse.ensureDir(savesPath) + const tempRoot = await fse.mkdtemp(join(savesPath, ".rift-world-restore-")) + try { + await assertManagedPath(backup.path, "world backup") + await validateWorldBackupArchive(backup.path, backup.worldName) + await extractTarGz(backup.path, tempRoot) + const extracted = await fse.readdir(tempRoot) + const files = [] + for (const name of extracted) { + const stats = await fse.lstat(join(tempRoot, name)) + if (stats.isFile() && !stats.isSymbolicLink()) files.push(name) + else throw new Error("unsafe world backup") + } + if (files.length !== 1 || !isSafeWorldName(files[0])) throw new Error("invalid world backup") + await fse.ensureDir(savesPath) + const target = world?.path ?? worldPath(savesPath, backup.worldName) + await assertManagedPath(target, "restored world", { allowMissing: true }) + const staged = join(tempRoot, files[0]) + const replacement = `${target}.rift-replaced-${randomUUID()}` + const existing = await fse.pathExists(target) + if (existing) await fse.move(target, replacement) + try { + await fse.move(staged, target) + } catch (error) { + if (existing) await fse.move(replacement, target).catch(() => undefined) + throw error + } + if (existing) { + await fse.remove(replacement).catch(() => { + logMessage("warn", "[back] [ipc] [ipc/handlers/worldsHandlers.ts] [RESTORE] Kept the replaced world aside after a successful restore.") + }) + } + return { ok: true as const } + } catch { + return failure("operation-failed") + } finally { + await fse.remove(tempRoot).catch(() => undefined) + } + }) + return result as WorldOperationResult + } finally { + lease.release() + } +} + +async function transferWorld(sourceId: unknown, requestedName: unknown, targetId: unknown, modeValue: unknown): Promise { + const config = await getConfig() + const source = findInstallation(config, sourceId) + const target = findInstallation(config, targetId) + if (!source || !target) return failure("installation-not-found") + if (source.id === target.id) return failure("invalid-request") + if (modeValue !== "copy" && modeValue !== "move") return failure("invalid-request") + if (isInstallationPlaying(source.id) || isInstallationPlaying(target.id)) return failure("installation-playing") + if (!isSafeWorldName(requestedName)) return failure("world-not-found") + + try { + const sourcePath = await assertConfiguredInstallationPath(source.path) + const targetPath = await assertConfiguredInstallationPath(target.path) + const sourceSaves = await assertManagedPath(join(sourcePath, SAVES_FOLDER_NAME), "source worlds folder") + const targetSaves = await assertManagedPath(join(targetPath, SAVES_FOLDER_NAME), "destination worlds folder", { allowMissing: true }) + const lease = tryAcquireInstallationOperation([source.id, target.id]) + if (!lease.ok) return operationFailure(lease.reason) + try { + const world = await findWorld(sourceSaves, requestedName) + if (!world) return failure("world-not-found") + if (!(await assertWorldWritable(world))) return failure("world-has-sidecars") + await assertManagedPath(world.path, "world") + const names = await fse.readdir(targetSaves).catch(() => []) + const targetName = collisionFreeWorldName(world.name, names.filter(isSafeWorldName)) + const warning = worldVersionWarning(source.version, target.version) + await fse.ensureDir(targetSaves) + const targetFile = worldPath(targetSaves, targetName) + await assertManagedPath(targetFile, "destination world", { allowMissing: true }) + await fse.copyFile(world.path, targetFile, constants.COPYFILE_EXCL) + if (modeValue === "move") { + try { + await fse.remove(world.path) + } catch (error) { + await fse.remove(targetFile).catch(() => undefined) + throw error + } + } + return { ok: true, targetWorldName: targetName, ...(warning ? { warning } : {}) } + } finally { + lease.release() + } + } catch { + return failure("operation-failed") + } +} + +ipcMain.handle(IPC_CHANNELS.WORLDS_MANAGER.LIST, async (event, installationId: unknown): Promise => { + assertTrustedIpcSender(event) + const checked = await checkedInstallation(installationId) + if ("error" in checked) return checked.error as WorldListResult + return listWorldEntries(checked.savesPath, checked.installation) +}) + +ipcMain.handle(IPC_CHANNELS.WORLDS_MANAGER.BACKUP, async (event, installationId: unknown, worldName: unknown): Promise => { + assertTrustedIpcSender(event) + return makeWorldBackup(installationId, worldName) +}) + +ipcMain.handle(IPC_CHANNELS.WORLDS_MANAGER.DELETE, async (event, installationId: unknown, worldName: unknown): Promise => { + assertTrustedIpcSender(event) + return deleteWorld(installationId, worldName) +}) + +ipcMain.handle(IPC_CHANNELS.WORLDS_MANAGER.RESTORE, async (event, installationId: unknown, backupId: unknown): Promise => { + assertTrustedIpcSender(event) + return restoreWorld(installationId, backupId) +}) + +ipcMain.handle(IPC_CHANNELS.WORLDS_MANAGER.TRANSFER, async (event, sourceId: unknown, worldName: unknown, targetId: unknown, mode: unknown): Promise => { + assertTrustedIpcSender(event) + return transferWorld(sourceId, worldName, targetId, mode) +}) + +logMessage("debug", "[back] [worlds] World management handlers registered.") diff --git a/src/ipc/index.ts b/src/ipc/index.ts index 561c0572..780cb69d 100644 --- a/src/ipc/index.ts +++ b/src/ipc/index.ts @@ -8,3 +8,4 @@ import "./handlers/optimumHandlers" import "./handlers/pathsHandlers" import "./handlers/utilsHandlers" import "./handlers/netHandlers" +import "./handlers/worldsHandlers" diff --git a/src/ipc/installationActivity.ts b/src/ipc/installationActivity.ts new file mode 100644 index 00000000..54633164 --- /dev/null +++ b/src/ipc/installationActivity.ts @@ -0,0 +1,42 @@ +/** Main-process activity state. Renderer flags are advisory; world operations use this state. */ +const playingInstallationCounts = new Map() +const installationOperationIds = new Set() + +export type InstallationOperationFailure = "playing" | "busy" + +export type InstallationOperationLease = { ok: true; release: () => void } | { ok: false; reason: InstallationOperationFailure } + +/** Reserves one or more installations for one host-side filesystem operation. */ +export function tryAcquireInstallationOperation(installationIds: readonly string[]): InstallationOperationLease { + const ids = [...new Set(installationIds)].sort() + if (ids.some((id) => isInstallationPlaying(id))) return { ok: false, reason: "playing" } + if (ids.some((id) => installationOperationIds.has(id))) return { ok: false, reason: "busy" } + + ids.forEach((id) => installationOperationIds.add(id)) + let released = false + return { + ok: true, + release: (): void => { + if (released) return + released = true + ids.forEach((id) => installationOperationIds.delete(id)) + } + } +} + +/** Marks a launch before spawning, closing the check-to-spawn race with world operations. */ +export function markInstallationPlaying(installationId: string): boolean { + if (installationOperationIds.has(installationId)) return false + playingInstallationCounts.set(installationId, (playingInstallationCounts.get(installationId) ?? 0) + 1) + return true +} + +export function clearInstallationPlaying(installationId: string): void { + const count = playingInstallationCounts.get(installationId) ?? 0 + if (count <= 1) playingInstallationCounts.delete(installationId) + else playingInstallationCounts.set(installationId, count - 1) +} + +export function isInstallationPlaying(installationId: string): boolean { + return (playingInstallationCounts.get(installationId) ?? 0) > 0 +} diff --git a/src/ipc/ipcChannels.ts b/src/ipc/ipcChannels.ts index 08fdcf30..83d69828 100644 --- a/src/ipc/ipcChannels.ts +++ b/src/ipc/ipcChannels.ts @@ -77,5 +77,12 @@ export const IPC_CHANNELS = { ACCOUNT_MANAGER: { LOGIN: "account-login", REMOVE_ACCOUNT: "account-remove" + }, + WORLDS_MANAGER: { + LIST: "worlds-list", + BACKUP: "worlds-backup", + RESTORE: "worlds-restore", + DELETE: "worlds-delete", + TRANSFER: "worlds-transfer" } } diff --git a/src/ipc/pathPolicy.ts b/src/ipc/pathPolicy.ts index 419de6b7..2e5be8da 100644 --- a/src/ipc/pathPolicy.ts +++ b/src/ipc/pathPolicy.ts @@ -114,6 +114,10 @@ function getEntryGrants(config: ConfigType): PathGrant[] { ...toGrants( config.installations.flatMap((installation) => installation.backups.map((backup) => backup.path)), false + ), + ...toGrants( + config.installations.flatMap((installation) => (installation.worldBackups ?? []).map((backup) => backup.path)), + false ) ] } @@ -239,6 +243,7 @@ export async function assertConfigPathsAuthorized(nextConfig: ConfigType, curren nextConfig.backupsFolder, ...nextConfig.installations.map((installation) => installation.path), ...nextConfig.installations.flatMap((installation) => installation.backups.map((backup) => backup.path)), + ...nextConfig.installations.flatMap((installation) => (installation.worldBackups ?? []).map((backup) => backup.path)), ...nextConfig.gameVersions.map((gameVersion) => gameVersion.path) ] diff --git a/src/ipc/workers/compression.ts b/src/ipc/workers/compression.ts index 61e77725..fa587a4a 100644 --- a/src/ipc/workers/compression.ts +++ b/src/ipc/workers/compression.ts @@ -14,7 +14,7 @@ */ import fse from "fs-extra" -import { join } from "node:path" +import { basename, dirname, join } from "node:path" import * as tar from "tar" import { DEFAULT_COMPRESSION_LEVEL } from "@domain/config/defaults" @@ -124,7 +124,7 @@ export function assertRoomForArchive(outputPath: string, requiredBytes: number, } export interface CompressionOptions { - /** Folder whose contents are archived. Its own name is not kept. */ + /** Folder whose contents are archived, or one plain file archived by its basename. */ inputPath: string /** Folder the archive is written into. Created when missing. */ outputPath: string @@ -162,7 +162,9 @@ export async function runCompression(options: CompressionOptions): Promise // under the same pair, which is what keeps the two ends honest. const oversized = describeOversizedBackupSource(totalBytes, MAX_BACKUP_TOTAL_BYTES) if (oversized) throw new Error(oversized) - if (!fse.existsSync(inputPath) || !fse.lstatSync(inputPath).isDirectory()) throw new Error("Compression source must be a directory") + if (!fse.existsSync(inputPath)) throw new Error("Compression source does not exist") + const sourceStats = fse.lstatSync(inputPath) + if (!sourceStats.isDirectory() && !sourceStats.isFile()) throw new Error("Compression source is unsafe") if (!fse.existsSync(outputPath)) fse.mkdirSync(outputPath, { recursive: true }) if (fse.lstatSync(outputPath).isSymbolicLink() || !fse.lstatSync(outputPath).isDirectory()) throw new Error("Compression destination is unsafe") // After the destination exists, since that is the path whose filesystem is asked. @@ -174,7 +176,9 @@ export async function runCompression(options: CompressionOptions): Promise if (archiveStats.isSymbolicLink() || archiveStats.isDirectory()) throw new Error("Compression archive target is unsafe") } - const entries = fse.readdirSync(inputPath) + const sourceIsFile = sourceStats.isFile() + const archiveCwd = sourceIsFile ? dirname(inputPath) : inputPath + const entries = sourceIsFile ? [basename(inputPath)] : fse.readdirSync(inputPath) let writtenBytes = 0 let lastReportedProgress = 0 @@ -182,7 +186,7 @@ export async function runCompression(options: CompressionOptions): Promise await tar.create( { file: archivePath, - cwd: inputPath, + cwd: archiveCwd, gzip: { level: compressionLevel }, portable: true, // Two names for one inode would otherwise become a Link entry the diff --git a/src/preload/index.ts b/src/preload/index.ts index 8f68da08..2aa23aa1 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -101,6 +101,14 @@ const api: BridgeAPI = { accountManager: { login: (email: string, password: string, twoFactorCode?: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.ACCOUNT_MANAGER.LOGIN, email, password, twoFactorCode), removeAccount: (accountId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.ACCOUNT_MANAGER.REMOVE_ACCOUNT, accountId) + }, + worldsManager: { + list: (installationId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.WORLDS_MANAGER.LIST, installationId), + backup: (installationId: string, worldName: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.WORLDS_MANAGER.BACKUP, installationId, worldName), + restore: (installationId: string, backupId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.WORLDS_MANAGER.RESTORE, installationId, backupId), + delete: (installationId: string, worldName: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.WORLDS_MANAGER.DELETE, installationId, worldName), + transfer: (sourceId: string, worldName: string, targetId: string, mode: "copy" | "move"): Promise => + ipcRenderer.invoke(IPC_CHANNELS.WORLDS_MANAGER.TRANSFER, sourceId, worldName, targetId, mode) } } diff --git a/src/preload/preload.d.ts b/src/preload/preload.d.ts index 6f210132..49f73188 100644 --- a/src/preload/preload.d.ts +++ b/src/preload/preload.d.ts @@ -122,6 +122,13 @@ declare global { /** Drops one saved account's secrets, by its `playerUid`. */ removeAccount: (accountId: string) => Promise } + worldsManager: { + list: (installationId: string) => Promise + backup: (installationId: string, worldName: string) => Promise + restore: (installationId: string, backupId: string) => Promise + delete: (installationId: string, worldName: string) => Promise + transfer: (sourceId: string, worldName: string, targetId: string, mode: "copy" | "move") => Promise + } } interface Window { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 0c4b6b94..196bd9d3 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -24,6 +24,7 @@ const ListInslallations = lazy(() => import("@renderer/features/installations/pa const AddInslallation = lazy(() => import("@renderer/features/installations/pages/AddInstallation")) const EditInslallation = lazy(() => import("@renderer/features/installations/pages/EditInstallation")) const ManageInstallationBackups = lazy(() => import("@renderer/features/installations/pages/ManageInstallationBackups")) +const ManageInstallationWorlds = lazy(() => import("@renderer/features/installations/pages/ManageInstallationWorlds")) const SessionReport = lazy(() => import("@renderer/features/installations/pages/SessionReport")) const ManageInstallationMods = lazy(() => import("@renderer/features/installations/pages/ManageMods")) const ManageInstallationServers = lazy(() => import("@renderer/features/servers/pages/ManageInstallationServers")) @@ -101,6 +102,7 @@ function AnimatedRoutes(): JSX.Element { } />} /> } />} /> } />} /> + } />} /> } />} /> } />} /> } />} /> diff --git a/src/renderer/src/features/installations/adapters/create.ts b/src/renderer/src/features/installations/adapters/create.ts index 10124ff4..69b3cacd 100644 --- a/src/renderer/src/features/installations/adapters/create.ts +++ b/src/renderer/src/features/installations/adapters/create.ts @@ -19,7 +19,7 @@ export function toFoldersInUse({ backupsFolder, installations, gameVersions }: F /** Turns the built record into the full config shape, with the runtime flags a fresh installation starts without. */ export function toInstallationType(installation: CreatedInstallation): InstallationType { - return { ...installation, _modsCount: 0 } + return { ...installation, worldBackups: [], _modsCount: 0 } } export interface InstallationFailureFeedback { diff --git a/src/renderer/src/features/installations/adapters/delete.ts b/src/renderer/src/features/installations/adapters/delete.ts index 68c6383a..abcaa903 100644 --- a/src/renderer/src/features/installations/adapters/delete.ts +++ b/src/renderer/src/features/installations/adapters/delete.ts @@ -11,6 +11,7 @@ export function toInstallationDeleteSnapshot(installation: InstallationType): In return { path: installation.path, backups: installation.backups.map((backup) => ({ id: backup.id, path: backup.path, isDeleting: backup._deleting ?? false, isRestoring: backup._restoring ?? false })), + worldBackups: (installation.worldBackups ?? []).map((backup) => ({ id: backup.id, path: backup.path, isDeleting: backup._deleting ?? false, isRestoring: backup._restoring ?? false })), isPlaying: installation._playing ?? false, isBackingUp: installation._backuping ?? false, isRestoringBackup: installation._restoringBackup ?? false diff --git a/src/renderer/src/features/installations/pages/ListInstallations.tsx b/src/renderer/src/features/installations/pages/ListInstallations.tsx index 3c3729ec..c692e510 100644 --- a/src/renderer/src/features/installations/pages/ListInstallations.tsx +++ b/src/renderer/src/features/installations/pages/ListInstallations.tsx @@ -199,6 +199,9 @@ function ListInslallations(): JSX.Element { + + + {/* Servers sits beside Manage Mods rather than at the end of the strip: the two are the per-Installation pages a player opens over and over, and the column pairs diff --git a/src/renderer/src/features/installations/pages/ManageInstallationWorlds.tsx b/src/renderer/src/features/installations/pages/ManageInstallationWorlds.tsx new file mode 100644 index 00000000..8061685c --- /dev/null +++ b/src/renderer/src/features/installations/pages/ManageInstallationWorlds.tsx @@ -0,0 +1,195 @@ +import { useCallback, useEffect, useRef, useState } from "react" +import { useTranslation } from "react-i18next" +import { useParams } from "react-router-dom" +import { PiArrowCounterClockwiseDuotone, PiCopyDuotone, PiFolderOpenDuotone, PiTrashDuotone, PiTruckDuotone } from "react-icons/pi" + +import { useInstallations, useConfigDispatch, CONFIG_ACTIONS } from "@renderer/features/config/contexts/ConfigContext" +import { useNotificationsContext } from "@renderer/contexts/NotificationsContext" +import { ListGroup, ListItem, ListWrapper } from "@renderer/components/ui/List" +import ScrollableContainer from "@renderer/components/ui/ScrollableContainer" +import { NormalButton } from "@renderer/components/ui/Buttons" +import { StickyMenuWrapper, StickyMenuGroupWrapper, StickyMenuGroup, StickyMenuBreadcrumbs, GoBackButton, GoToTopButton } from "@renderer/components/ui/StickyMenu" + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB` + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB` +} + +function ManageInstallationWorlds(): JSX.Element { + const { id } = useParams() + const { t } = useTranslation() + const installations = useInstallations() + const configDispatch = useConfigDispatch() + const { addNotification } = useNotificationsContext() + const installation = installations.find((candidate) => candidate.id === id) + const [worlds, setWorlds] = useState([]) + const [loading, setLoading] = useState(true) + const [targetId, setTargetId] = useState("") + const scrollRef = useRef(null) + + const refresh = useCallback(async (): Promise => { + if (!installation) return + setLoading(true) + const result = await window.api.worldsManager.list(installation.id) + setLoading(false) + if (result.ok) setWorlds(result.worlds) + else addNotification(t(`features.worlds.error.${result.reason}`), "error") + }, [addNotification, installation, t]) + + useEffect(() => { + void refresh() + }, [refresh]) + + async function backup(world: WorldType, askConfirmation = true): Promise { + if (!installation || (askConfirmation && !window.confirm(t("features.worlds.confirmBackup", { name: world.name })))) return false + const result = await window.api.worldsManager.backup(installation.id, world.name) + if (!result.ok) { + addNotification(t(`features.worlds.error.${result.reason}`), "error") + return false + } + configDispatch({ type: CONFIG_ACTIONS.EDIT_INSTALLATION, payload: { id: installation.id, updates: { worldBackups: [result.backup, ...(installation.worldBackups ?? [])] } } }) + addNotification(t("features.worlds.backupDone"), "success") + await refresh() + return true + } + + async function remove(world: WorldType): Promise { + if (!installation || window.prompt(t("features.worlds.confirmDelete", { name: world.name }), "") !== world.name) return + const backups = (installation.worldBackups ?? []).filter((backup) => backup.worldName.toLocaleLowerCase("en-US") === world.name.toLocaleLowerCase("en-US")) + if (backups.length === 0 && window.confirm(t("features.worlds.backupBeforeDelete", { name: world.name }))) { + if (!(await backup(world, false))) return + } + const result = await window.api.worldsManager.delete(installation.id, world.name) + if (!result.ok) return addNotification(t(`features.worlds.error.${result.reason}`), "error") + addNotification(t("features.worlds.deleteDone"), "success") + await refresh() + } + + async function restore(backup: WorldBackupType): Promise { + if (!installation || !window.confirm(t("features.worlds.confirmRestore", { name: backup.worldName }))) return + const result = await window.api.worldsManager.restore(installation.id, backup.id) + if (!result.ok) return addNotification(t(`features.worlds.error.${result.reason}`), "error") + addNotification(t("features.worlds.restoreDone"), "success") + await refresh() + } + + async function transfer(world: WorldType, mode: "copy" | "move"): Promise { + if (!installation || !targetId || targetId === installation.id) return addNotification(t("features.worlds.chooseTarget"), "error") + const target = installations.find((candidate) => candidate.id === targetId) + if (!target || !window.confirm(t("features.worlds.confirmTransfer", { name: world.name, target: target.name }))) return + if (mode === "move" && !window.confirm(t("features.worlds.confirmMove", { name: world.name, target: target.name }))) return + const result = await window.api.worldsManager.transfer(installation.id, world.name, target.id, mode) + if (!result.ok) return addNotification(t(`features.worlds.error.${result.reason}`), "error") + addNotification(result.warning ? t("features.worlds.versionWarning") : t("features.worlds.transferDone", { name: result.targetWorldName }), result.warning ? "warning" : "success") + await refresh() + } + + if (!installation) return
{t("features.installations.noInstallationFound")}
+ + const displayWorlds: WorldType[] = [ + ...worlds, + ...(installation.worldBackups ?? []) + .filter((backup) => !worlds.some((world) => world.name.toLocaleLowerCase("en-US") === backup.worldName.toLocaleLowerCase("en-US"))) + .map((backup) => ({ name: backup.worldName, size: 0, lastModified: backup.date, isDefault: false, backupCount: 1 })) + ] + + return ( + +
+ + + + + + + + + + + + +
+
+

{t("features.worlds.title")}

+

{installation.name}

+
+ +
+ {loading &&

{t("generic.reloading")}

} + {!loading && displayWorlds.length === 0 &&

{t("features.worlds.empty")}

} + + {displayWorlds.map((world) => { + const liveWorld = worlds.some((candidate) => candidate.name.toLocaleLowerCase("en-US") === world.name.toLocaleLowerCase("en-US")) + const backups = (installation.worldBackups ?? []).filter((backup) => backup.worldName.toLocaleLowerCase("en-US") === world.name.toLocaleLowerCase("en-US")) + return ( + +
+
+

+ {world.name} + {world.isDefault ? ` · ${t("generic.default")}` : ""} +

+

+ {liveWorld ? formatBytes(world.size) : t("features.worlds.backupOnly")} · {new Date(world.lastModified).toLocaleString()} ·{" "} + {t("features.worlds.backupCount", { count: backups.length })} +

+
+ {liveWorld && ( + void backup(world)}> + + + )} + {liveWorld && ( + void transfer(world, "copy")}> + + + )} + {liveWorld && ( + void transfer(world, "move")}> + + + )} + {liveWorld && ( + void remove(world)}> + + + )} +
+ {backups.map((backup) => ( +
+ {new Date(backup.date).toLocaleString()} + void restore(backup)}> + + + void window.api.pathsManager.openPathOnFileExplorer(backup.path)}> + + +
+ ))} +
+ ) + })} +
+
+
+
+ ) +} + +export default ManageInstallationWorlds diff --git a/src/renderer/src/locales/en-US.json b/src/renderer/src/locales/en-US.json index 7d22143b..59e92435 100644 --- a/src/renderer/src/locales/en-US.json +++ b/src/renderer/src/locales/en-US.json @@ -758,6 +758,40 @@ "compressionLevelDesc": "Higher compression takes longer but creates smaller Backups.", "manageBackups": "Manage Backups" }, + "worlds": { + "title": "Worlds", + "manageWorlds": "Manage Worlds", + "empty": "No worlds found in this Installation.", + "backupOnly": "backup only", + "backup": "Back up this world", + "copy": "Copy world", + "move": "Move world", + "backupCount": "{{count}} backups", + "transferTarget": "Transfer to…", + "chooseTarget": "Choose another Installation first.", + "confirmBackup": "Back up {{name}} now?", + "confirmDelete": "Type {{name}} to permanently delete this world.", + "backupBeforeDelete": "There is no backup for {{name}}. Create one before deleting it?", + "confirmRestore": "Restore the backup of {{name}}? The current world will be replaced.", + "confirmTransfer": "Transfer {{name}} to {{target}}?", + "confirmMove": "This removes {{name}} from this Installation after copying it to {{target}}. Continue?", + "backupDone": "World backup created.", + "deleteDone": "World deleted.", + "restoreDone": "World restored.", + "transferDone": "World transferred as {{name}}.", + "versionWarning": "World transferred between different Vintage Story versions.", + "error": { + "installation-not-found": "Installation not found.", + "installation-playing": "Stop playing before managing worlds.", + "saves-unavailable": "The Saves folder is unavailable.", + "world-not-found": "World not found.", + "world-busy": "This world is already being changed.", + "world-has-sidecars": "This world is active. Close Vintage Story and try again.", + "archive-not-found": "World backup not found.", + "invalid-request": "That world operation is not valid.", + "operation-failed": "The world operation failed. Nothing else was changed." + } + }, "servers": { "manageServers": "Manage Servers", "manageServersDesc": "Servers you add here are joined straight from the launcher.", @@ -987,6 +1021,7 @@ "manageMods": "Manage Mods", "manageBackups": "Manage Backups", "manageServers": "Manage Servers", + "manageWorlds": "Manage Worlds", "versions": "Versions", "addVersion": "Add Version", "lookForAVersion": "Look for a Version", diff --git a/src/renderer/src/locales/fr-FR.json b/src/renderer/src/locales/fr-FR.json index 2791b41a..9992a368 100644 --- a/src/renderer/src/locales/fr-FR.json +++ b/src/renderer/src/locales/fr-FR.json @@ -758,6 +758,40 @@ "compressionLevelDesc": "Une compression plus forte prend plus de temps mais produit des sauvegardes plus petites.", "manageBackups": "Gérer les sauvegardes" }, + "worlds": { + "title": "Mondes", + "manageWorlds": "Gérer les mondes", + "empty": "Aucun monde trouvé dans cette installation.", + "backupOnly": "sauvegarde uniquement", + "backup": "Sauvegarder ce monde", + "copy": "Copier le monde", + "move": "Déplacer le monde", + "backupCount": "{{count}} sauvegardes", + "transferTarget": "Transférer vers…", + "chooseTarget": "Choisissez d'abord une autre installation.", + "confirmBackup": "Sauvegarder {{name}} maintenant ?", + "confirmDelete": "Saisissez {{name}} pour supprimer définitivement ce monde.", + "backupBeforeDelete": "Il n'y a aucune sauvegarde de {{name}}. En créer une avant de le supprimer ?", + "confirmRestore": "Restaurer la sauvegarde de {{name}} ? Le monde actuel sera remplacé.", + "confirmTransfer": "Transférer {{name}} vers {{target}} ?", + "confirmMove": "{{name}} sera supprimé de cette installation après sa copie vers {{target}}. Continuer ?", + "backupDone": "Sauvegarde du monde créée.", + "deleteDone": "Monde supprimé.", + "restoreDone": "Monde restauré.", + "transferDone": "Monde transféré sous le nom {{name}}.", + "versionWarning": "Monde transféré entre différentes versions de Vintage Story.", + "error": { + "installation-not-found": "Installation introuvable.", + "installation-playing": "Arrêtez de jouer avant de gérer les mondes.", + "saves-unavailable": "Le dossier Saves est indisponible.", + "world-not-found": "Monde introuvable.", + "world-busy": "Ce monde est déjà en cours de modification.", + "world-has-sidecars": "Ce monde est actif. Fermez Vintage Story puis réessayez.", + "archive-not-found": "Sauvegarde du monde introuvable.", + "invalid-request": "Cette opération sur le monde n'est pas valide.", + "operation-failed": "L'opération sur le monde a échoué. Rien d'autre n'a été modifié." + } + }, "servers": { "manageServers": "Gérer les serveurs", "manageServersDesc": "Les serveurs ajoutés ici se rejoignent directement depuis le launcher.", @@ -986,6 +1020,7 @@ "editInstallation": "Modifier l'installation", "manageMods": "Gérer les mods", "manageBackups": "Gérer les sauvegardes", + "manageWorlds": "Gérer les mondes", "manageServers": "Gérer les serveurs", "versions": "Versions", "addVersion": "Ajouter une version", diff --git a/src/renderer/src/locales/pt-BR.json b/src/renderer/src/locales/pt-BR.json index d59d5cbc..1220355b 100644 --- a/src/renderer/src/locales/pt-BR.json +++ b/src/renderer/src/locales/pt-BR.json @@ -318,6 +318,40 @@ "compressionLevel": "Nível de compressão", "compressionLevelDesc": "Compressões mais altas demoram mais tempo, mas criam backups menores.", "manageBackups": "Gerenciar Backups", + "worlds": { + "title": "Mundos", + "manageWorlds": "Gerenciar mundos", + "empty": "Nenhum mundo encontrado nesta instalação.", + "backupOnly": "somente backup", + "backup": "Fazer backup deste mundo", + "copy": "Copiar mundo", + "move": "Mover mundo", + "backupCount": "{{count}} backups", + "transferTarget": "Transferir para…", + "chooseTarget": "Escolha outra instalação primeiro.", + "confirmBackup": "Fazer backup de {{name}} agora?", + "confirmDelete": "Digite {{name}} para excluir este mundo permanentemente.", + "backupBeforeDelete": "Não há backup de {{name}}. Criar um antes de excluir?", + "confirmRestore": "Restaurar o backup de {{name}}? O mundo atual será substituído.", + "confirmTransfer": "Transferir {{name}} para {{target}}?", + "confirmMove": "{{name}} será removido desta instalação depois de ser copiado para {{target}}. Continuar?", + "backupDone": "Backup do mundo criado.", + "deleteDone": "Mundo excluído.", + "restoreDone": "Mundo restaurado.", + "transferDone": "Mundo transferido como {{name}}.", + "versionWarning": "Mundo transferido entre versões diferentes do Vintage Story.", + "error": { + "installation-not-found": "Instalação não encontrada.", + "installation-playing": "Pare de jogar antes de gerenciar mundos.", + "saves-unavailable": "A pasta Saves está indisponível.", + "world-not-found": "Mundo não encontrado.", + "world-busy": "Este mundo já está sendo alterado.", + "world-has-sidecars": "Este mundo está ativo. Feche o Vintage Story e tente novamente.", + "archive-not-found": "Backup do mundo não encontrado.", + "invalid-request": "Essa operação de mundo não é válida.", + "operation-failed": "A operação do mundo falhou. Nada mais foi alterado." + } + }, "backupsDisabled": "Nenhum backup foi feito: o limite máximo de Backups desta Instalação está definido como 0. Aumente-o na página de edição da Instalação para reativar os backups.", "errorRestoringBackup": "Não foi possível restaurar o Backup, então sua Instalação foi deixada como estava.", "installationPathMissing": "Nenhum backup foi feito: esta Instalação ainda não tem dados. Jogue uma vez para gerar os dados primeiro.", @@ -456,6 +490,7 @@ "lookForAVersion": "Procurar uma versão", "manageMods": "Gerenciar Mods", "manageBackups": "Gerenciar Backups", + "manageWorlds": "Gerenciar mundos", "installations": "Instalações", "versions": "Versões", "mods": "Mods" diff --git a/tests/domain/config/migrations.test.ts b/tests/domain/config/migrations.test.ts index 254c55f2..e5cd8d4b 100644 --- a/tests/domain/config/migrations.test.ts +++ b/tests/domain/config/migrations.test.ts @@ -153,8 +153,8 @@ describe("migrateConfigDocument on real configs", () => { const repeatedDoc = repeated.doc as { gameVersions: Array> } assert.equal(result.outcome, "migrated") - assert.equal(result.schema, 5) - assert.deepEqual(result.applied.at(-1), { fromSchema: 4, toSchema: 5 }) + assert.equal(result.schema, CURRENT_CONFIG_SCHEMA) + assert.deepEqual(result.applied.at(-1), { fromSchema: 5, toSchema: 6 }) assert.equal(doc.gameVersions[0]!.label, "1.22.7") assert.equal(typeof doc.gameVersions[0]!.id, "string") assert.equal(doc.gameVersions[0]!.id, repeatedDoc.gameVersions[0]!.id, "legacy ids are deterministic") @@ -262,7 +262,8 @@ describe("migrateConfigDocument on real configs", () => { { fromSchema: 1, toSchema: 2 }, { fromSchema: 2, toSchema: 3 }, { fromSchema: 3, toSchema: 4 }, - { fromSchema: 4, toSchema: 5 } + { fromSchema: 4, toSchema: 5 }, + { fromSchema: 5, toSchema: 6 } ]) const doc = result.doc as Record @@ -328,7 +329,8 @@ describe("migrateConfigDocument on real configs", () => { [FLOAT_ERA_CONFIG_SCHEMA, FIRST_INTEGER_CONFIG_SCHEMA], [2, 3], [3, 4], - [4, 5] + [4, 5], + [5, 6] ] ) assert.equal(CONFIG_MIGRATIONS[CONFIG_MIGRATIONS.length - 1]?.toSchema, CURRENT_CONFIG_SCHEMA) diff --git a/tests/domain/worlds.test.ts b/tests/domain/worlds.test.ts new file mode 100644 index 00000000..664baf08 --- /dev/null +++ b/tests/domain/worlds.test.ts @@ -0,0 +1,50 @@ +import assert from "node:assert/strict" +import { describe, it } from "vitest" + +import { MAX_WORLDS, canTransferWorld, collisionFreeWorldName, hasWorldSidecars, isSafeWorldName, listWorlds, worldSidecarNames, worldVersionWarning } from "@domain/worlds/worlds" + +describe("world domain", () => { + it("lists only safe vcdb worlds, newest first, with a hard cap", () => { + const worlds = listWorlds([ + { name: "old.vcdbs", size: 1, lastModified: 1, isDefault: false, backupCount: 0 }, + { name: "default.vcdbs", size: 2, lastModified: 2, isDefault: true, backupCount: 1 }, + { name: "Mods", size: 3, lastModified: 3, isDefault: false, backupCount: 0 }, + { name: "../escape.vcdbs", size: 4, lastModified: 4, isDefault: false, backupCount: 0 }, + ...Array.from({ length: MAX_WORLDS + 4 }, (_, index) => ({ name: `world-${index}.vcdbs`, size: 1, lastModified: index + 10, isDefault: false, backupCount: 0 })) + ]) + + assert.equal(worlds.length, MAX_WORLDS) + assert.equal(worlds[0]?.name, `world-${MAX_WORLDS + 3}.vcdbs`) + assert.equal( + worlds.some((world) => world.name === "Mods"), + false + ) + assert.equal( + worlds.some((world) => world.name.includes("escape")), + false + ) + }) + + it("rejects separators, sidecars, and extension-only names", () => { + assert.equal(isSafeWorldName("world.vcdbs"), true) + assert.equal(isSafeWorldName("world.VCDBS"), true) + assert.equal(isSafeWorldName(".vcdbs"), false) + assert.equal(isSafeWorldName("../world.vcdbs"), false) + assert.equal(isSafeWorldName("world.vcdbs-wal"), false) + assert.equal(isSafeWorldName("CON.vcdbs"), false) + assert.equal(isSafeWorldName("world.vcdbs "), false) + assert.equal(isSafeWorldName("world\u0001.vcdbs"), false) + assert.deepEqual(worldSidecarNames("world.vcdbs"), ["world.vcdbs-wal", "world.vcdbs-shm"]) + assert.equal(hasWorldSidecars(["world.vcdbs", "world.vcdbs-wal"], "world.vcdbs"), true) + }) + + it("chooses a deterministic collision suffix and warns on version changes", () => { + assert.equal(collisionFreeWorldName("A.vcdbs", ["A.vcdbs", "A (2).vcdbs"]), "A (3).vcdbs") + assert.equal(collisionFreeWorldName("B.vcdbs", []), "B.vcdbs") + assert.equal(collisionFreeWorldName("B.vcdbs", ["b.VCDBS"]), "B (2).vcdbs") + assert.equal(worldVersionWarning("1.20.0", "1.21.0"), "different-version") + assert.equal(worldVersionWarning("1.20.0", "1.20.0"), undefined) + assert.equal(canTransferWorld("a", "b"), true) + assert.equal(canTransferWorld("a", "a"), false) + }) +}) diff --git a/tests/ipc/compression.test.ts b/tests/ipc/compression.test.ts index c304dea3..0a34acae 100644 --- a/tests/ipc/compression.test.ts +++ b/tests/ipc/compression.test.ts @@ -10,6 +10,7 @@ import * as tar from "tar" import fse from "fs-extra" import { assertRoomForArchive, assertSafeCompressionTree, runCompression } from "@src/ipc/workers/compression" +import { validateWorldBackupArchive } from "@src/ipc/archiveValidation" import { MAX_ARCHIVE_TOTAL_BYTES, MAX_BACKUP_ENTRY_BYTES, MAX_BACKUP_TOTAL_BYTES } from "@src/ipc/validation" /** @@ -186,6 +187,23 @@ describe("assertSafeCompressionTree", () => { }) }) +describe("validateWorldBackupArchive", () => { + it("accepts the one-file archive produced for a world", async () => { + const world = workspacePath("world.vcdbs") + const archive = join(output, "world-backup.tar.gz") + writeFileSync(world, "world data") + + await runCompression({ inputPath: world, outputPath: output, outputFileName: "world-backup.tar.gz" }) + await validateWorldBackupArchive(archive, "world.vcdbs") + }) + + it("rejects an archive containing more than the selected world before extraction", async () => { + await runCompression({ inputPath: source, outputPath: output, outputFileName: "invalid-world-backup.tar.gz" }) + + await assert.rejects(() => validateWorldBackupArchive(join(output, "invalid-world-backup.tar.gz"), "Vintagestory.vcdbs"), /exactly one world file/) + }) +}) + describe("runCompression", () => { it("archives the source contents, not the source folder itself", async () => { await runCompression({ inputPath: source, outputPath: output, outputFileName: "backup.tar.gz" }) @@ -270,8 +288,9 @@ describe("runCompression", () => { assert.throws(() => statSync(join(output, "backup.tar.gz"))) }) - it("refuses a source that is a file rather than a folder", async () => { - await assert.rejects(runCompression({ inputPath: join(source, "Vintagestory"), outputPath: output, outputFileName: "backup.tar.gz" }), /must be a directory/) + it("can archive one world database file", async () => { + await runCompression({ inputPath: join(source, "Vintagestory"), outputPath: output, outputFileName: "world.tar.gz" }) + assert.equal(statSync(join(output, "world.tar.gz")).isFile(), true) }) it("refuses a source that does not exist", async () => { diff --git a/tests/ipc/installationActivity.test.ts b/tests/ipc/installationActivity.test.ts new file mode 100644 index 00000000..592b4aeb --- /dev/null +++ b/tests/ipc/installationActivity.test.ts @@ -0,0 +1,31 @@ +import assert from "node:assert/strict" +import { describe, it } from "vitest" + +import { clearInstallationPlaying, isInstallationPlaying, markInstallationPlaying, tryAcquireInstallationOperation } from "@src/ipc/installationActivity" + +describe("installation activity", () => { + it("keeps an installation playing until every concurrent launch ends", () => { + assert.equal(markInstallationPlaying("install"), true) + assert.equal(markInstallationPlaying("install"), true) + assert.equal(isInstallationPlaying("install"), true) + + clearInstallationPlaying("install") + assert.equal(isInstallationPlaying("install"), true) + clearInstallationPlaying("install") + assert.equal(isInstallationPlaying("install"), false) + }) + + it("excludes world operations while playing and excludes launches while reserved", () => { + assert.equal(markInstallationPlaying("install"), true) + assert.deepEqual(tryAcquireInstallationOperation(["install"]), { ok: false, reason: "playing" }) + clearInstallationPlaying("install") + + const lease = tryAcquireInstallationOperation(["install"]) + assert.equal(lease.ok, true) + assert.equal(markInstallationPlaying("install"), false) + assert.deepEqual(tryAcquireInstallationOperation(["install"]), { ok: false, reason: "busy" }) + if (lease.ok) lease.release() + assert.equal(markInstallationPlaying("install"), true) + clearInstallationPlaying("install") + }) +}) diff --git a/tests/ipc/workerHost.test.ts b/tests/ipc/workerHost.test.ts index 5cda76df..0fc62008 100644 --- a/tests/ipc/workerHost.test.ts +++ b/tests/ipc/workerHost.test.ts @@ -209,9 +209,8 @@ describe("serveTasks", () => { * `serveTasks` call and nothing else imports it, so every test above passes one * of its own and collapsing the shipped one back to a constant went unnoticed. * The worker module is imported for real here, over the same fake port, and the - * failures are the ones the filesystem actually raises: a missing folder and a - * source that is a file. Both take the route a full disk (ENOSPC) or a denied - * write (EACCES) takes, which is the case #337 was reported for. + * failures are the ones the filesystem actually raises: a missing folder takes + * the failure route while a single file is now a valid world-backup source. */ describe("the compress worker's own failure describer", () => { it("forwards each distinct compression failure instead of one constant sentence", async () => { @@ -228,14 +227,11 @@ describe("the compress worker's own failure describer", () => { assert.notEqual(missingSourceMessage, "Compression failed") port.postMessage.mockClear() - // A file rather than a folder: a different throw in compression.ts, and it - // has to arrive as a different sentence. + // A single file is a valid world-backup source and must finish successfully. const fileAsSource = { inputPath: fileURLToPath(import.meta.url), outputPath: "/tmp", outputFileName: "backup.tar.gz" } port.emit("message", { type: "task", token: 2, payload: fileAsSource }) await vi.waitFor(() => assert.equal(lastMessage() !== undefined, true)) - const fileAsSourceMessage = (lastMessage() as { message: string }).message - assert.equal(fileAsSourceMessage, "Compression source must be a directory") - assert.notEqual(fileAsSourceMessage, missingSourceMessage) + assert.equal((lastMessage() as { type: string }).type, "finished") }) }) diff --git a/tests/renderer-dom/helpers/windowApi.ts b/tests/renderer-dom/helpers/windowApi.ts index 25b8baca..2b51aa4d 100644 --- a/tests/renderer-dom/helpers/windowApi.ts +++ b/tests/renderer-dom/helpers/windowApi.ts @@ -154,6 +154,13 @@ export function createMockWindowApi(overrides: WindowApiOverrides = {}): MockedB accountManager: { login: vi.fn(notMocked("accountManager.login")), removeAccount: vi.fn(notMocked("accountManager.removeAccount")) + }, + worldsManager: { + list: vi.fn(async () => ({ ok: true as const, worlds: [] })), + backup: vi.fn(notMocked("worldsManager.backup")), + restore: vi.fn(notMocked("worldsManager.restore")), + delete: vi.fn(notMocked("worldsManager.delete")), + transfer: vi.fn(notMocked("worldsManager.transfer")) } } From e9e0fa6dabe4bb253641077842196ca211818db8 Mon Sep 17 00:00:00 2001 From: Zaldaryon <273555259+Zaldaryon@users.noreply.github.com> Date: Tue, 15 Sep 2026 20:00:10 -0300 Subject: [PATCH 2/3] fix: address world management review feedback --- src/ipc/handlers/worldsHandlers.ts | 22 +- .../pages/ManageInstallationWorlds.tsx | 49 ++- src/renderer/src/locales/pt-BR.json | 68 ++-- tests/i18n/i18n-parity.test.ts | 10 + tests/ipc/worldsHandlers.test.ts | 301 ++++++++++++++++++ .../manageInstallationWorlds.test.tsx | 110 +++++++ 6 files changed, 517 insertions(+), 43 deletions(-) create mode 100644 tests/ipc/worldsHandlers.test.ts create mode 100644 tests/renderer-dom/manageInstallationWorlds.test.tsx diff --git a/src/ipc/handlers/worldsHandlers.ts b/src/ipc/handlers/worldsHandlers.ts index 3b4c84ec..8d0f54c0 100644 --- a/src/ipc/handlers/worldsHandlers.ts +++ b/src/ipc/handlers/worldsHandlers.ts @@ -14,7 +14,7 @@ import { logMessage } from "@src/utils/logManager" import { runCompression } from "@src/ipc/workers/compression" import { extractTarGz } from "@src/ipc/workers/extraction" import { validateWorldBackupArchive } from "@src/ipc/archiveValidation" -import { SAVES_FOLDER_NAME, WORLD_FILE_EXTENSION, collisionFreeWorldName, hasWorldSidecars, isSafeWorldName, listWorlds, worldVersionWarning } from "@domain/worlds/worlds" +import { SAVES_FOLDER_NAME, WORLD_FILE_EXTENSION, canTransferWorld, collisionFreeWorldName, hasWorldSidecars, isSafeWorldName, listWorlds, worldVersionWarning } from "@domain/worlds/worlds" const WORLD_BACKUPS_FOLDER = "Worlds" @@ -120,6 +120,21 @@ function updateInstallation(config: ConfigType, installationId: string, update: return { ...config, installations: config.installations.map((installation) => (installation.id === installationId ? update(installation) : installation)) } } +let worldBackupConfigWriteQueue: Promise = Promise.resolve() + +function saveWorldBackupRecord(installationId: string, backup: WorldBackupType): Promise { + const write = worldBackupConfigWriteQueue.then(async () => { + const currentConfig = await getConfig() + const nextConfig = updateInstallation(currentConfig, installationId, (current) => ({ ...current, worldBackups: [backup, ...(current.worldBackups ?? [])] })) + return saveConfig(nextConfig) + }) + worldBackupConfigWriteQueue = write.then( + () => undefined, + () => undefined + ) + return write +} + async function makeWorldBackup(installationId: unknown, requestedName: unknown): Promise { const checked = await checkedInstallation(installationId) if ("error" in checked) return checked.error @@ -141,8 +156,7 @@ async function makeWorldBackup(installationId: unknown, requestedName: unknown): try { await runCompression({ inputPath: world.path, outputPath: outputFolder, outputFileName: `${backupId}.tar.gz`, compressionLevel: installation.compressionLevel }) const backup: WorldBackupType = { id: backupId, date: Date.now(), path: archivePath, worldName: world.name } - const nextConfig = updateInstallation(config, installation.id, (current) => ({ ...current, worldBackups: [backup, ...(current.worldBackups ?? [])] })) - if (!(await saveConfig(nextConfig))) { + if (!(await saveWorldBackupRecord(installation.id, backup))) { await fse.remove(archivePath).catch(() => undefined) return failure("operation-failed") } @@ -247,7 +261,7 @@ async function transferWorld(sourceId: unknown, requestedName: unknown, targetId const source = findInstallation(config, sourceId) const target = findInstallation(config, targetId) if (!source || !target) return failure("installation-not-found") - if (source.id === target.id) return failure("invalid-request") + if (!canTransferWorld(source.id, target.id)) return failure("invalid-request") if (modeValue !== "copy" && modeValue !== "move") return failure("invalid-request") if (isInstallationPlaying(source.id) || isInstallationPlaying(target.id)) return failure("installation-playing") if (!isSafeWorldName(requestedName)) return failure("world-not-found") diff --git a/src/renderer/src/features/installations/pages/ManageInstallationWorlds.tsx b/src/renderer/src/features/installations/pages/ManageInstallationWorlds.tsx index 8061685c..22427662 100644 --- a/src/renderer/src/features/installations/pages/ManageInstallationWorlds.tsx +++ b/src/renderer/src/features/installations/pages/ManageInstallationWorlds.tsx @@ -1,13 +1,15 @@ -import { useCallback, useEffect, useRef, useState } from "react" +import { useCallback, useEffect, useId, useRef, useState } from "react" import { useTranslation } from "react-i18next" import { useParams } from "react-router-dom" -import { PiArrowCounterClockwiseDuotone, PiCopyDuotone, PiFolderOpenDuotone, PiTrashDuotone, PiTruckDuotone } from "react-icons/pi" +import { PiArrowCounterClockwiseDuotone, PiCopyDuotone, PiFolderOpenDuotone, PiTrashDuotone, PiTruckDuotone, PiXCircleDuotone } from "react-icons/pi" import { useInstallations, useConfigDispatch, CONFIG_ACTIONS } from "@renderer/features/config/contexts/ConfigContext" import { useNotificationsContext } from "@renderer/contexts/NotificationsContext" import { ListGroup, ListItem, ListWrapper } from "@renderer/components/ui/List" import ScrollableContainer from "@renderer/components/ui/ScrollableContainer" +import PopupDialogPanel from "@renderer/components/ui/PopupDialogPanel" import { NormalButton } from "@renderer/components/ui/Buttons" +import { ButtonsWrapper, FormButton, FormInputText } from "@renderer/components/ui/FormComponents" import { StickyMenuWrapper, StickyMenuGroupWrapper, StickyMenuGroup, StickyMenuBreadcrumbs, GoBackButton, GoToTopButton } from "@renderer/components/ui/StickyMenu" function formatBytes(bytes: number): string { @@ -27,6 +29,9 @@ function ManageInstallationWorlds(): JSX.Element { const [worlds, setWorlds] = useState([]) const [loading, setLoading] = useState(true) const [targetId, setTargetId] = useState("") + const [worldToDelete, setWorldToDelete] = useState(null) + const [deleteName, setDeleteName] = useState("") + const deleteNameId = useId() const scrollRef = useRef(null) const refresh = useCallback(async (): Promise => { @@ -55,8 +60,15 @@ function ManageInstallationWorlds(): JSX.Element { return true } - async function remove(world: WorldType): Promise { - if (!installation || window.prompt(t("features.worlds.confirmDelete", { name: world.name }), "") !== world.name) return + function closeDeleteDialog(): void { + setWorldToDelete(null) + setDeleteName("") + } + + async function deleteWorldHandler(): Promise { + if (!installation || !worldToDelete || deleteName !== worldToDelete.name) return + const world = worldToDelete + closeDeleteDialog() const backups = (installation.worldBackups ?? []).filter((backup) => backup.worldName.toLocaleLowerCase("en-US") === world.name.toLocaleLowerCase("en-US")) if (backups.length === 0 && window.confirm(t("features.worlds.backupBeforeDelete", { name: world.name }))) { if (!(await backup(world, false))) return @@ -166,7 +178,15 @@ function ManageInstallationWorlds(): JSX.Element { )} {liveWorld && ( - void remove(world)}> + { + setWorldToDelete(world) + setDeleteName("") + }} + > )} @@ -187,6 +207,25 @@ function ManageInstallationWorlds(): JSX.Element { })} + + <> +
+ + setDeleteName(event.target.value)} autoFocus className="w-full" /> +
+ + } /> + } + disabled={!worldToDelete || deleteName !== worldToDelete.name} + /> + + +
) diff --git a/src/renderer/src/locales/pt-BR.json b/src/renderer/src/locales/pt-BR.json index 1220355b..7992cfd9 100644 --- a/src/renderer/src/locales/pt-BR.json +++ b/src/renderer/src/locales/pt-BR.json @@ -318,46 +318,46 @@ "compressionLevel": "Nível de compressão", "compressionLevelDesc": "Compressões mais altas demoram mais tempo, mas criam backups menores.", "manageBackups": "Gerenciar Backups", - "worlds": { - "title": "Mundos", - "manageWorlds": "Gerenciar mundos", - "empty": "Nenhum mundo encontrado nesta instalação.", - "backupOnly": "somente backup", - "backup": "Fazer backup deste mundo", - "copy": "Copiar mundo", - "move": "Mover mundo", - "backupCount": "{{count}} backups", - "transferTarget": "Transferir para…", - "chooseTarget": "Escolha outra instalação primeiro.", - "confirmBackup": "Fazer backup de {{name}} agora?", - "confirmDelete": "Digite {{name}} para excluir este mundo permanentemente.", - "backupBeforeDelete": "Não há backup de {{name}}. Criar um antes de excluir?", - "confirmRestore": "Restaurar o backup de {{name}}? O mundo atual será substituído.", - "confirmTransfer": "Transferir {{name}} para {{target}}?", - "confirmMove": "{{name}} será removido desta instalação depois de ser copiado para {{target}}. Continuar?", - "backupDone": "Backup do mundo criado.", - "deleteDone": "Mundo excluído.", - "restoreDone": "Mundo restaurado.", - "transferDone": "Mundo transferido como {{name}}.", - "versionWarning": "Mundo transferido entre versões diferentes do Vintage Story.", - "error": { - "installation-not-found": "Instalação não encontrada.", - "installation-playing": "Pare de jogar antes de gerenciar mundos.", - "saves-unavailable": "A pasta Saves está indisponível.", - "world-not-found": "Mundo não encontrado.", - "world-busy": "Este mundo já está sendo alterado.", - "world-has-sidecars": "Este mundo está ativo. Feche o Vintage Story e tente novamente.", - "archive-not-found": "Backup do mundo não encontrado.", - "invalid-request": "Essa operação de mundo não é válida.", - "operation-failed": "A operação do mundo falhou. Nada mais foi alterado." - } - }, "backupsDisabled": "Nenhum backup foi feito: o limite máximo de Backups desta Instalação está definido como 0. Aumente-o na página de edição da Instalação para reativar os backups.", "errorRestoringBackup": "Não foi possível restaurar o Backup, então sua Instalação foi deixada como estava.", "installationPathMissing": "Nenhum backup foi feito: esta Instalação ainda não tem dados. Jogue uma vez para gerar os dados primeiro.", "noBackupsFolder": "Nenhum backup foi feito: você ainda não definiu uma pasta de Backups. Defina uma na página de Configuração.", "restoreLeftDataAside": "Não foi possível restaurar o Backup e os dados antigos da sua Instalação agora estão em {{path}}. Mova essa pasta de volta manualmente antes de jogar." }, + "worlds": { + "title": "Mundos", + "manageWorlds": "Gerenciar mundos", + "empty": "Nenhum mundo encontrado nesta instalação.", + "backupOnly": "somente backup", + "backup": "Fazer backup deste mundo", + "copy": "Copiar mundo", + "move": "Mover mundo", + "backupCount": "{{count}} backups", + "transferTarget": "Transferir para…", + "chooseTarget": "Escolha outra instalação primeiro.", + "confirmBackup": "Fazer backup de {{name}} agora?", + "confirmDelete": "Digite {{name}} para excluir este mundo permanentemente.", + "backupBeforeDelete": "Não há backup de {{name}}. Criar um antes de excluir?", + "confirmRestore": "Restaurar o backup de {{name}}? O mundo atual será substituído.", + "confirmTransfer": "Transferir {{name}} para {{target}}?", + "confirmMove": "{{name}} será removido desta instalação depois de ser copiado para {{target}}. Continuar?", + "backupDone": "Backup do mundo criado.", + "deleteDone": "Mundo excluído.", + "restoreDone": "Mundo restaurado.", + "transferDone": "Mundo transferido como {{name}}.", + "versionWarning": "Mundo transferido entre versões diferentes do Vintage Story.", + "error": { + "installation-not-found": "Instalação não encontrada.", + "installation-playing": "Pare de jogar antes de gerenciar mundos.", + "saves-unavailable": "A pasta Saves está indisponível.", + "world-not-found": "Mundo não encontrado.", + "world-busy": "Este mundo já está sendo alterado.", + "world-has-sidecars": "Este mundo está ativo. Feche o Vintage Story e tente novamente.", + "archive-not-found": "Backup do mundo não encontrado.", + "invalid-request": "Essa operação de mundo não é válida.", + "operation-failed": "A operação do mundo falhou. Nada mais foi alterado." + } + }, "infoAndHelp": { "debugInfoTitle": "Informações de depuração", "logsFolderTitle": "Pasta de Logs", diff --git a/tests/i18n/i18n-parity.test.ts b/tests/i18n/i18n-parity.test.ts index 55c8f3e8..c8848412 100644 --- a/tests/i18n/i18n-parity.test.ts +++ b/tests/i18n/i18n-parity.test.ts @@ -189,3 +189,13 @@ describe("fr-FR stays in step with en-US", () => { assert.deepEqual(orphans, [], `fr-FR keys en-US no longer has: ${orphans.join(", ")}`) }) }) + +describe("pt-BR worlds namespace", () => { + const ptBR = flattenTranslationObject(readLocaleJson("pt-BR.json")) + + it("resolves world strings under features.worlds rather than features.backups", () => { + assert.equal(ptBR["features.worlds.title"], "Mundos") + assert.equal(ptBR["features.worlds.confirmDelete"], "Digite {{name}} para excluir este mundo permanentemente.") + assert.equal(ptBR["features.backups.worlds.title"], undefined) + }) +}) diff --git a/tests/ipc/worldsHandlers.test.ts b/tests/ipc/worldsHandlers.test.ts new file mode 100644 index 00000000..d33466a1 --- /dev/null +++ b/tests/ipc/worldsHandlers.test.ts @@ -0,0 +1,301 @@ +import assert from "node:assert/strict" +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import type { IpcMainInvokeEvent } from "electron" + +import "./helpers/electronMock" +import { createTrustedEvent, createUntrustedEvent, getIpcHandler, setElectronPath, setElectronUserDataPath } from "./helpers/electronMock" + +const compressionState = vi.hoisted(() => ({ + block: false, + calls: [] as string[], + release: [] as Array<() => void> +})) + +const runCompression = vi.hoisted(() => + vi.fn(async ({ inputPath, outputPath, outputFileName }: { inputPath: string; outputPath: string; outputFileName: string }) => { + compressionState.calls.push(inputPath) + if (compressionState.block) await new Promise((resolve) => compressionState.release.push(resolve)) + mkdirSync(outputPath, { recursive: true }) + writeFileSync(join(outputPath, outputFileName), "archive", "utf8") + }) +) + +const extractTarGz = vi.hoisted(() => + vi.fn(async (_archivePath: string, outputPath: string) => { + writeFileSync(join(outputPath, "World.vcdbs"), "restored", "utf8") + }) +) + +vi.mock("@src/ipc/workers/compression", () => ({ runCompression })) +vi.mock("@src/ipc/workers/extraction", () => ({ extractTarGz })) +vi.mock("@src/ipc/archiveValidation", () => ({ validateWorldBackupArchive: vi.fn(async () => undefined) })) + +const CURRENT_SCHEMA = 6 +let temporaryRoot: string +let userDataPath: string +let installationsRoot: string +let backupsFolder: string +let markInstallationPlaying: typeof import("@src/ipc/installationActivity").markInstallationPlaying +let clearInstallationPlaying: typeof import("@src/ipc/installationActivity").clearInstallationPlaying + +type WorldsHandler = (event: IpcMainInvokeEvent, ...args: unknown[]) => Promise + +function installation(id: string, path: string, version = "1.22.7", worldBackups: WorldBackupType[] = []): InstallationType { + return { + id, + name: id, + icon: "", + path, + version, + gameVersionId: `version-${id}`, + startParams: "", + backupsLimit: 3, + backupsAuto: false, + compressionLevel: 6, + backups: [], + worldBackups: worldBackups, + lastTimePlayed: -1, + totalTimePlayed: 0, + mesaGlThread: false, + envVars: "" + } +} + +function writeConfig(installations: InstallationType[]): void { + writeFileSync( + join(userDataPath, "config.json"), + JSON.stringify({ + schemaVersion: CURRENT_SCHEMA, + lastUsedInstallation: null, + defaultInstallationsFolder: installationsRoot, + defaultVersionsFolder: join(temporaryRoot, "versions"), + backupsFolder, + window: { width: 1280, height: 720, x: 0, y: 0, maximized: false }, + accounts: [], + activeAccountId: null, + installations, + gameVersions: [], + favMods: [], + customIcons: [] + }), + "utf8" + ) +} + +function handler(channel: string): WorldsHandler { + return getIpcHandler(channel) +} + +async function waitFor(condition: () => boolean): Promise { + const deadline = Date.now() + 2_000 + while (!condition()) { + if (Date.now() >= deadline) throw new Error("Timed out waiting for condition") + await new Promise((resolve) => setImmediate(resolve)) + } +} + +beforeEach(async () => { + temporaryRoot = mkdtempSync(join(tmpdir(), "world-handlers-")) + userDataPath = join(temporaryRoot, "userData") + installationsRoot = join(temporaryRoot, "installations") + backupsFolder = join(temporaryRoot, "backups") + mkdirSync(userDataPath, { recursive: true }) + mkdirSync(installationsRoot, { recursive: true }) + mkdirSync(join(temporaryRoot, "versions"), { recursive: true }) + setElectronUserDataPath(userDataPath) + setElectronPath("appData", join(temporaryRoot, "appData")) + setElectronPath("home", temporaryRoot) + setElectronPath("appRoot", join(temporaryRoot, "app")) + compressionState.block = false + compressionState.calls = [] + compressionState.release = [] + runCompression.mockClear() + extractTarGz.mockClear() + + vi.resetModules() + ;({ markInstallationPlaying, clearInstallationPlaying } = await import("@src/ipc/installationActivity")) + await import("@src/ipc/handlers/worldsHandlers") +}) + +afterEach(() => { + rmSync(temporaryRoot, { recursive: true, force: true }) + vi.restoreAllMocks() +}) + +describe("worlds IPC handlers", () => { + it("registers every worlds channel and rejects an untrusted sender", async () => { + const event = createUntrustedEvent() + const channels = ["worlds-list", "worlds-backup", "worlds-restore", "worlds-delete", "worlds-transfer"] + + for (const channel of channels) { + await assert.rejects(() => handler(channel)(event, "install-a", "World.vcdbs", "install-b", "copy"), /Unauthorized IPC sender/) + } + }) + + it("lists only safe world files from the configured Saves folder", async () => { + const installationPath = join(installationsRoot, "install-a") + const savesPath = join(installationPath, "Saves") + mkdirSync(savesPath, { recursive: true }) + writeFileSync(join(savesPath, "World.vcdbs"), "world", "utf8") + writeFileSync(join(savesPath, "notes.txt"), "not a world", "utf8") + writeFileSync(join(savesPath, "unsafe.vcdbs "), "unsafe", "utf8") + writeConfig([installation("install-a", installationPath)]) + + const result = await handler("worlds-list")(await createTrustedEvent(), "install-a") + + assert.deepEqual(result, { + ok: true, + worlds: [ + { + name: "World.vcdbs", + size: 5, + lastModified: (result as WorldListResult & { ok: true }).worlds[0]?.lastModified, + isDefault: false, + backupCount: 0 + } + ] + }) + }) + + it("refuses an unmanaged installation path", async () => { + const installationPath = join(installationsRoot, "install-a") + const actualPath = join(temporaryRoot, "outside-install") + mkdirSync(join(actualPath, "Saves"), { recursive: true }) + symlinkSync(actualPath, installationPath, "junction") + writeConfig([installation("install-a", installationPath)]) + + const result = await handler("worlds-list")(await createTrustedEvent(), "install-a") + + assert.deepEqual(result, { ok: false, reason: "operation-failed" }) + }) + + it.each([ + ["backup", "worlds-backup", "missing.vcdbs", "world-not-found"], + ["delete", "worlds-delete", "missing.vcdbs", "world-not-found"], + ["restore", "worlds-restore", "missing-backup", "archive-not-found"], + ["transfer", "worlds-transfer", "missing.vcdbs", "world-not-found"] + ])("%s rejects a world name that is not listed", async (_operation, channel, worldName, reason) => { + const installationPath = join(installationsRoot, "install-a") + mkdirSync(join(installationPath, "Saves"), { recursive: true }) + writeFileSync(join(installationPath, "Saves", "World.vcdbs"), "world", "utf8") + writeConfig([installation("install-a", installationPath), installation("install-b", join(installationsRoot, "install-b"))]) + + const args = channel === "worlds-transfer" ? ["install-a", worldName, "install-b", "copy"] : ["install-a", worldName] + const result = await handler(channel)(await createTrustedEvent(), ...args) + + assert.deepEqual(result, { ok: false, reason }) + }) + + it.each([ + ["backup", "worlds-backup", ["install-a", "../World.vcdbs"]], + ["delete", "worlds-delete", ["install-a", "Saves/World.vcdbs"]], + ["transfer", "worlds-transfer", ["install-a", "Saves/World.vcdbs", "install-b", "copy"]] + ])("%s rejects a path with a separator in the world name", async (_operation, channel, args) => { + const installationPath = join(installationsRoot, "install-a") + mkdirSync(join(installationPath, "Saves"), { recursive: true }) + writeFileSync(join(installationPath, "Saves", "World.vcdbs"), "world", "utf8") + writeConfig([installation("install-a", installationPath), installation("install-b", join(installationsRoot, "install-b"))]) + + const result = await handler(channel)(await createTrustedEvent(), ...args) + + assert.deepEqual(result, { ok: false, reason: "world-not-found" }) + }) + + it("backs up, restores, deletes, and transfers a listed world", async () => { + const sourcePath = join(installationsRoot, "install-a") + const targetPath = join(installationsRoot, "install-b") + const sourceWorld = join(sourcePath, "Saves", "World.vcdbs") + mkdirSync(join(sourcePath, "Saves"), { recursive: true }) + mkdirSync(join(targetPath, "Saves"), { recursive: true }) + writeFileSync(sourceWorld, "world", "utf8") + writeConfig([installation("install-a", sourcePath), installation("install-b", targetPath, "1.22.8")]) + const event = await createTrustedEvent() + + const backupResult = (await handler("worlds-backup")(event, "install-a", "World.vcdbs")) as WorldBackupResult + assert.equal(backupResult.ok, true) + if (!backupResult.ok) return + const configAfterBackup = JSON.parse(readFileSync(join(userDataPath, "config.json"), "utf8")) as ConfigType + assert.equal(configAfterBackup.installations[0]?.worldBackups?.[0]?.id, backupResult.backup.id) + + const restoreResult = await handler("worlds-restore")(event, "install-a", backupResult.backup.id) + assert.deepEqual(restoreResult, { ok: true }) + assert.equal(readFileSync(sourceWorld, "utf8"), "restored") + + const transferResult = await handler("worlds-transfer")(event, "install-a", "World.vcdbs", "install-b", "copy") + assert.deepEqual(transferResult, { ok: true, targetWorldName: "World.vcdbs", warning: "different-version" }) + expect(existsSync(join(targetPath, "Saves", "World.vcdbs"))).toBe(true) + + const deleteResult = await handler("worlds-delete")(event, "install-a", "World.vcdbs") + assert.deepEqual(deleteResult, { ok: true }) + expect(existsSync(sourceWorld)).toBe(false) + }) + + it("refuses every mutating worlds channel while an installation is playing", async () => { + const installationPath = join(installationsRoot, "install-a") + mkdirSync(join(installationPath, "Saves"), { recursive: true }) + writeFileSync(join(installationPath, "Saves", "World.vcdbs"), "world", "utf8") + writeConfig([installation("install-a", installationPath), installation("install-b", join(installationsRoot, "install-b"))]) + const event = await createTrustedEvent() + assert.equal(markInstallationPlaying("install-a"), true) + + try { + const results = await Promise.all([ + handler("worlds-backup")(event, "install-a", "World.vcdbs"), + handler("worlds-restore")(event, "install-a", "missing-backup"), + handler("worlds-delete")(event, "install-a", "World.vcdbs"), + handler("worlds-transfer")(event, "install-a", "World.vcdbs", "install-b", "copy") + ]) + expect(results).toEqual([ + { ok: false, reason: "installation-playing" }, + { ok: false, reason: "installation-playing" }, + { ok: false, reason: "installation-playing" }, + { ok: false, reason: "installation-playing" } + ]) + } finally { + clearInstallationPlaying("install-a") + } + }) + + it("routes same-installation transfer refusal through the domain rule", async () => { + const installationPath = join(installationsRoot, "install-a") + mkdirSync(join(installationPath, "Saves"), { recursive: true }) + writeFileSync(join(installationPath, "Saves", "World.vcdbs"), "world", "utf8") + writeConfig([installation("install-a", installationPath)]) + + const result = await handler("worlds-transfer")(await createTrustedEvent(), "install-a", "World.vcdbs", "install-a", "copy") + + assert.deepEqual(result, { ok: false, reason: "invalid-request" }) + }) + + it("preserves both world backup records when different installations finish concurrently", async () => { + const firstPath = join(installationsRoot, "install-a") + const secondPath = join(installationsRoot, "install-b") + mkdirSync(join(firstPath, "Saves"), { recursive: true }) + mkdirSync(join(secondPath, "Saves"), { recursive: true }) + writeFileSync(join(firstPath, "Saves", "First.vcdbs"), "first", "utf8") + writeFileSync(join(secondPath, "Saves", "Second.vcdbs"), "second", "utf8") + writeConfig([installation("install-a", firstPath), installation("install-b", secondPath)]) + const event = await createTrustedEvent() + compressionState.block = true + + const first = handler("worlds-backup")(event, "install-a", "First.vcdbs") + const second = handler("worlds-backup")(event, "install-b", "Second.vcdbs") + await waitFor(() => compressionState.calls.length === 2) + compressionState.release.shift()?.() + await new Promise((resolve) => setImmediate(resolve)) + compressionState.release.shift()?.() + + const results = await Promise.all([first, second]) + expect(results.every((result) => (result as WorldBackupResult).ok)).toBe(true) + const savedConfig = JSON.parse(readFileSync(join(userDataPath, "config.json"), "utf8")) as ConfigType + const savedWorldNames = savedConfig.installations + .flatMap((candidate) => candidate.worldBackups ?? []) + .map((backup) => backup.worldName) + .sort() + expect(savedWorldNames).toEqual(["First.vcdbs", "Second.vcdbs"]) + }) +}) diff --git a/tests/renderer-dom/manageInstallationWorlds.test.tsx b/tests/renderer-dom/manageInstallationWorlds.test.tsx new file mode 100644 index 00000000..34ab8c40 --- /dev/null +++ b/tests/renderer-dom/manageInstallationWorlds.test.tsx @@ -0,0 +1,110 @@ +import { describe, expect, it, vi } from "vitest" +import { screen, waitFor, within } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { Route, Routes } from "react-router-dom" + +import ManageInstallationWorlds from "@renderer/features/installations/pages/ManageInstallationWorlds" + +import { createMockConfig, installMockWindowApi } from "./helpers/windowApi" +import { renderWithProviders } from "./helpers/render" + +function anInstallation(): InstallationType { + return { + id: "install-a", + name: "Install A", + icon: "", + path: "/games/a", + version: "1.22.7", + gameVersionId: "version-a", + startParams: "", + backupsLimit: 3, + backupsAuto: false, + compressionLevel: 6, + backups: [], + worldBackups: [{ id: "backup-1", date: 1, path: "/backups/backup-1.tar.gz", worldName: "World.vcdbs" }], + lastTimePlayed: -1, + totalTimePlayed: 0, + mesaGlThread: false, + envVars: "" + } +} + +function renderWorlds(deleteWorld: BridgeAPI["worldsManager"]["delete"]): void { + installMockWindowApi({ + configManager: { getConfig: vi.fn(async () => createMockConfig({ installations: [anInstallation()] })) }, + worldsManager: { + list: vi.fn(async () => ({ + ok: true as const, + worlds: [{ name: "World.vcdbs", size: 5, lastModified: 1, isDefault: false, backupCount: 0 }] + })), + delete: deleteWorld + } + }) + + renderWithProviders( + + } /> + , + { route: "/installations/worlds/install-a" } + ) +} + +describe("ManageInstallationWorlds deletion confirmation", () => { + it("keeps deletion disabled for a mismatched world name", async () => { + const user = userEvent.setup() + const deleteWorld = vi.fn(async () => ({ ok: true })) + renderWorlds(deleteWorld) + + const deleteButton = await screen.findByTitle("Delete") + await user.click(deleteButton) + + const dialog = await screen.findByRole("dialog") + const nameInput = within(dialog).getByLabelText("Type World.vcdbs to permanently delete this world.") + const confirmButton = within(dialog).getByRole("button", { name: "Delete" }) as HTMLButtonElement + expect(deleteWorld).not.toHaveBeenCalled() + expect(confirmButton.disabled).toBe(true) + + await user.type(nameInput, "world.vcdbs") + + expect(confirmButton.disabled).toBe(true) + expect(deleteWorld).not.toHaveBeenCalled() + }) + + it("enables deletion only after the exact world name is entered", async () => { + const user = userEvent.setup() + const deleteWorld = vi.fn(async () => ({ ok: true })) + renderWorlds(deleteWorld) + + await user.click(await screen.findByTitle("Delete")) + + const dialog = await screen.findByRole("dialog") + const nameInput = within(dialog).getByLabelText("Type World.vcdbs to permanently delete this world.") + const confirmButton = within(dialog).getByRole("button", { name: "Delete" }) as HTMLButtonElement + await user.type(nameInput, "World.vcdbs") + + expect(confirmButton.disabled).toBe(false) + await user.click(confirmButton) + + await waitFor(() => expect(deleteWorld).toHaveBeenCalledWith("install-a", "World.vcdbs")) + }) + + it("does not delete when the PopupDialogPanel is cancelled", async () => { + const user = userEvent.setup() + const deleteWorld = vi.fn(async () => ({ ok: true })) + renderWorlds(deleteWorld) + + const deleteButton = await screen.findByTitle("Delete") + await user.click(deleteButton) + const dialog = await screen.findByRole("dialog") + await user.type(within(dialog).getByRole("textbox"), "stale input") + await user.click(within(dialog).getByTitle("Cancel")) + + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()) + expect(deleteWorld).not.toHaveBeenCalled() + + await user.click(await screen.findByTitle("Delete")) + const reopenedDialog = await screen.findByRole("dialog") + expect((within(reopenedDialog).getByRole("textbox") as HTMLInputElement).value).toBe("") + expect((within(reopenedDialog).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(true) + }) +}) From 8e8a630fe3c3a0347819dcc7ac3582b6b9ceb460 Mon Sep 17 00:00:00 2001 From: Zaldaryon <273555259+Zaldaryon@users.noreply.github.com> Date: Wed, 16 Sep 2026 19:43:18 -0300 Subject: [PATCH 3/3] fix: address world backup reconciliation, serialization, and test pinning --- src/ipc/handlers/worldsHandlers.ts | 4 + .../pages/ManageInstallationWorlds.tsx | 21 +-- tests/ipc/configHandlers.test.ts | 67 +++++++++ tests/ipc/worldsHandlers.test.ts | 135 ++++++++++++++++-- .../manageInstallationWorlds.test.tsx | 32 +++++ 5 files changed, 235 insertions(+), 24 deletions(-) diff --git a/src/ipc/handlers/worldsHandlers.ts b/src/ipc/handlers/worldsHandlers.ts index 8d0f54c0..dac019ee 100644 --- a/src/ipc/handlers/worldsHandlers.ts +++ b/src/ipc/handlers/worldsHandlers.ts @@ -166,6 +166,8 @@ async function makeWorldBackup(installationId: unknown, requestedName: unknown): return failure("operation-failed") } }) + } catch { + return failure("operation-failed") } finally { lease.release() } @@ -189,6 +191,8 @@ async function deleteWorld(installationId: unknown, requestedName: unknown): Pro } catch { return failure("operation-failed") } + } catch { + return failure("operation-failed") } finally { lease.release() } diff --git a/src/renderer/src/features/installations/pages/ManageInstallationWorlds.tsx b/src/renderer/src/features/installations/pages/ManageInstallationWorlds.tsx index 22427662..02c4d1b3 100644 --- a/src/renderer/src/features/installations/pages/ManageInstallationWorlds.tsx +++ b/src/renderer/src/features/installations/pages/ManageInstallationWorlds.tsx @@ -26,9 +26,12 @@ function ManageInstallationWorlds(): JSX.Element { const configDispatch = useConfigDispatch() const { addNotification } = useNotificationsContext() const installation = installations.find((candidate) => candidate.id === id) + const isPlaying = Boolean(installation?._playing) const [worlds, setWorlds] = useState([]) const [loading, setLoading] = useState(true) const [targetId, setTargetId] = useState("") + const target = installations.find((candidate) => candidate.id === targetId) + const isTargetPlaying = Boolean(target?._playing) const [worldToDelete, setWorldToDelete] = useState(null) const [deleteName, setDeleteName] = useState("") const deleteNameId = useId() @@ -48,7 +51,7 @@ function ManageInstallationWorlds(): JSX.Element { }, [refresh]) async function backup(world: WorldType, askConfirmation = true): Promise { - if (!installation || (askConfirmation && !window.confirm(t("features.worlds.confirmBackup", { name: world.name })))) return false + if (!installation || isPlaying || (askConfirmation && !window.confirm(t("features.worlds.confirmBackup", { name: world.name })))) return false const result = await window.api.worldsManager.backup(installation.id, world.name) if (!result.ok) { addNotification(t(`features.worlds.error.${result.reason}`), "error") @@ -66,7 +69,7 @@ function ManageInstallationWorlds(): JSX.Element { } async function deleteWorldHandler(): Promise { - if (!installation || !worldToDelete || deleteName !== worldToDelete.name) return + if (!installation || isPlaying || !worldToDelete || deleteName !== worldToDelete.name) return const world = worldToDelete closeDeleteDialog() const backups = (installation.worldBackups ?? []).filter((backup) => backup.worldName.toLocaleLowerCase("en-US") === world.name.toLocaleLowerCase("en-US")) @@ -80,7 +83,7 @@ function ManageInstallationWorlds(): JSX.Element { } async function restore(backup: WorldBackupType): Promise { - if (!installation || !window.confirm(t("features.worlds.confirmRestore", { name: backup.worldName }))) return + if (!installation || isPlaying || !window.confirm(t("features.worlds.confirmRestore", { name: backup.worldName }))) return const result = await window.api.worldsManager.restore(installation.id, backup.id) if (!result.ok) return addNotification(t(`features.worlds.error.${result.reason}`), "error") addNotification(t("features.worlds.restoreDone"), "success") @@ -88,8 +91,7 @@ function ManageInstallationWorlds(): JSX.Element { } async function transfer(world: WorldType, mode: "copy" | "move"): Promise { - if (!installation || !targetId || targetId === installation.id) return addNotification(t("features.worlds.chooseTarget"), "error") - const target = installations.find((candidate) => candidate.id === targetId) + if (!installation || isPlaying || isTargetPlaying || !targetId || targetId === installation.id) return addNotification(t("features.worlds.chooseTarget"), "error") if (!target || !window.confirm(t("features.worlds.confirmTransfer", { name: world.name, target: target.name }))) return if (mode === "move" && !window.confirm(t("features.worlds.confirmMove", { name: world.name, target: target.name }))) return const result = await window.api.worldsManager.transfer(installation.id, world.name, target.id, mode) @@ -163,17 +165,17 @@ function ManageInstallationWorlds(): JSX.Element {

{liveWorld && ( - void backup(world)}> + void backup(world)}> )} {liveWorld && ( - void transfer(world, "copy")}> + void transfer(world, "copy")}> )} {liveWorld && ( - void transfer(world, "move")}> + void transfer(world, "move")}> )} @@ -182,6 +184,7 @@ function ManageInstallationWorlds(): JSX.Element { title={t("generic.delete")} variant="ghost" className="p-1" + disabled={isPlaying} onClick={() => { setWorldToDelete(world) setDeleteName("") @@ -194,7 +197,7 @@ function ManageInstallationWorlds(): JSX.Element { {backups.map((backup) => (
{new Date(backup.date).toLocaleString()} - void restore(backup)}> + void restore(backup)}> void window.api.pathsManager.openPathOnFileExplorer(backup.path)}> diff --git a/tests/ipc/configHandlers.test.ts b/tests/ipc/configHandlers.test.ts index 91e00106..8994073c 100644 --- a/tests/ipc/configHandlers.test.ts +++ b/tests/ipc/configHandlers.test.ts @@ -157,6 +157,73 @@ describe("SAVE_CONFIG", () => { assert.equal(reread.defaultInstallationsFolder, join(appDataFolder, "RiftLauncherInstallations")) }) + it("reconciles worldBackups from current config when the renderer saves an installation", async () => { + const event = await createTrustedEvent() + const validPath = join(appDataFolder, "RiftLauncherInstallations", "test-install") + mkdirSync(validPath, { recursive: true }) + + const existingBackup: WorldBackupType = { + id: "backup-1", + worldName: "MyWorld.vcdbs", + path: join(validPath, "backup-1.tar.gz"), + date: 123456789 + } + + const initialConfig = minimalConfig({ + installations: [ + { + id: "inst-1", + name: "Inst 1", + icon: "", + path: validPath, + version: "1.20.0", + startParams: "", + backupsLimit: 3, + backupsAuto: false, + compressionLevel: 4, + backups: [], + worldBackups: [existingBackup], + lastTimePlayed: -1, + totalTimePlayed: 0, + mesaGlThread: false, + envVars: "" + } + ] as unknown as ConfigType["installations"] + }) + await saveConfigHandler()(event, initialConfig) + + // Renderer dispatches SAVE_CONFIG with empty worldBackups array + const rendererConfig = minimalConfig({ + installations: [ + { + id: "inst-1", + name: "Inst 1 Renamed", + icon: "", + path: validPath, + version: "1.20.0", + startParams: "", + backupsLimit: 3, + backupsAuto: false, + compressionLevel: 4, + backups: [], + worldBackups: [], + lastTimePlayed: -1, + totalTimePlayed: 0, + mesaGlThread: false, + envVars: "" + } + ] as unknown as ConfigType["installations"] + }) + + const result = await saveConfigHandler()(event, rendererConfig) + assert.deepEqual(result, { ok: true }) + + const reread = await getConfigHandler()(event) + const install = reread.installations.find((i) => i.id === "inst-1") + assert.ok(install) + assert.deepEqual(install.worldBackups, [existingBackup]) + }) + // chmod 0o500 does not stop a write on Windows: NTFS enforces read-only // through the file attribute, not POSIX write bits on the containing folder. it.skipIf(process.platform === "win32")("reports write-failed when the config file cannot be written", async () => { diff --git a/tests/ipc/worldsHandlers.test.ts b/tests/ipc/worldsHandlers.test.ts index d33466a1..3893ac1c 100644 --- a/tests/ipc/worldsHandlers.test.ts +++ b/tests/ipc/worldsHandlers.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict" -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs" +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" @@ -30,9 +30,11 @@ const extractTarGz = vi.hoisted(() => }) ) +const validateWorldBackupArchive = vi.hoisted(() => vi.fn(async () => undefined)) + vi.mock("@src/ipc/workers/compression", () => ({ runCompression })) vi.mock("@src/ipc/workers/extraction", () => ({ extractTarGz })) -vi.mock("@src/ipc/archiveValidation", () => ({ validateWorldBackupArchive: vi.fn(async () => undefined) })) +vi.mock("@src/ipc/archiveValidation", () => ({ validateWorldBackupArchive })) const CURRENT_SCHEMA = 6 let temporaryRoot: string @@ -41,6 +43,10 @@ let installationsRoot: string let backupsFolder: string let markInstallationPlaying: typeof import("@src/ipc/installationActivity").markInstallationPlaying let clearInstallationPlaying: typeof import("@src/ipc/installationActivity").clearInstallationPlaying +let pathPolicy: typeof import("@src/ipc/pathPolicy") +let realAssertManagedPath: (typeof import("@src/ipc/pathPolicy"))["assertManagedPath"] +let assertManagedPathSpy: ReturnType +let assertManagedDeletionPathSpy: ReturnType type WorldsHandler = (event: IpcMainInvokeEvent, ...args: unknown[]) => Promise @@ -115,8 +121,14 @@ beforeEach(async () => { compressionState.release = [] runCompression.mockClear() extractTarGz.mockClear() + validateWorldBackupArchive.mockReset() + validateWorldBackupArchive.mockResolvedValue(undefined) vi.resetModules() + pathPolicy = await import("@src/ipc/pathPolicy") + realAssertManagedPath = pathPolicy.assertManagedPath + assertManagedPathSpy = vi.spyOn(pathPolicy, "assertManagedPath") + assertManagedDeletionPathSpy = vi.spyOn(pathPolicy, "assertManagedDeletionPath") ;({ markInstallationPlaying, clearInstallationPlaying } = await import("@src/ipc/installationActivity")) await import("@src/ipc/handlers/worldsHandlers") }) @@ -141,23 +153,36 @@ describe("worlds IPC handlers", () => { const savesPath = join(installationPath, "Saves") mkdirSync(savesPath, { recursive: true }) writeFileSync(join(savesPath, "World.vcdbs"), "world", "utf8") + writeFileSync(join(savesPath, "default.vcdbs"), "default-world", "utf8") writeFileSync(join(savesPath, "notes.txt"), "not a world", "utf8") writeFileSync(join(savesPath, "unsafe.vcdbs "), "unsafe", "utf8") - writeConfig([installation("install-a", installationPath)]) + writeConfig([installation("install-a", installationPath, "1.22.7", [{ id: "backup-default", date: 1, path: join(backupsFolder, "Worlds", "backup-default.tar.gz"), worldName: "default.vcdbs" }])]) + + const statWorld = statSync(join(savesPath, "World.vcdbs")) + const statDefault = statSync(join(savesPath, "default.vcdbs")) const result = await handler("worlds-list")(await createTrustedEvent(), "install-a") + const expectedWorlds = [ + { + name: "World.vcdbs", + size: statWorld.size, + lastModified: statWorld.mtimeMs, + isDefault: false, + backupCount: 0 + }, + { + name: "default.vcdbs", + size: statDefault.size, + lastModified: statDefault.mtimeMs, + isDefault: true, + backupCount: 1 + } + ].sort((left, right) => right.lastModified - left.lastModified || left.name.localeCompare(right.name)) + assert.deepEqual(result, { ok: true, - worlds: [ - { - name: "World.vcdbs", - size: 5, - lastModified: (result as WorldListResult & { ok: true }).worlds[0]?.lastModified, - isDefault: false, - backupCount: 0 - } - ] + worlds: expectedWorlds }) }) @@ -218,22 +243,104 @@ describe("worlds IPC handlers", () => { const backupResult = (await handler("worlds-backup")(event, "install-a", "World.vcdbs")) as WorldBackupResult assert.equal(backupResult.ok, true) if (!backupResult.ok) return + expect(assertManagedPathSpy).toHaveBeenCalledWith(sourceWorld, "world") const configAfterBackup = JSON.parse(readFileSync(join(userDataPath, "config.json"), "utf8")) as ConfigType assert.equal(configAfterBackup.installations[0]?.worldBackups?.[0]?.id, backupResult.backup.id) const restoreResult = await handler("worlds-restore")(event, "install-a", backupResult.backup.id) assert.deepEqual(restoreResult, { ok: true }) + expect(validateWorldBackupArchive).toHaveBeenCalledWith(backupResult.backup.path, "World.vcdbs") assert.equal(readFileSync(sourceWorld, "utf8"), "restored") const transferResult = await handler("worlds-transfer")(event, "install-a", "World.vcdbs", "install-b", "copy") assert.deepEqual(transferResult, { ok: true, targetWorldName: "World.vcdbs", warning: "different-version" }) + expect(assertManagedPathSpy).toHaveBeenCalledWith(join(targetPath, "Saves", "World.vcdbs"), "destination world", { allowMissing: true }) expect(existsSync(join(targetPath, "Saves", "World.vcdbs"))).toBe(true) const deleteResult = await handler("worlds-delete")(event, "install-a", "World.vcdbs") assert.deepEqual(deleteResult, { ok: true }) + expect(assertManagedDeletionPathSpy).toHaveBeenCalledWith(sourceWorld) expect(existsSync(sourceWorld)).toBe(false) }) + it("refuses restore when archive validation rejects", async () => { + const sourcePath = join(installationsRoot, "install-a") + const sourceWorld = join(sourcePath, "Saves", "World.vcdbs") + mkdirSync(join(sourcePath, "Saves"), { recursive: true }) + writeFileSync(sourceWorld, "world", "utf8") + const backupId = "backup-fail-validation" + const backupArchive = join(backupsFolder, "Worlds", `${backupId}.tar.gz`) + mkdirSync(join(backupsFolder, "Worlds"), { recursive: true }) + writeFileSync(backupArchive, "dummy", "utf8") + writeConfig([installation("install-a", sourcePath, "1.22.7", [{ id: backupId, date: 1, path: backupArchive, worldName: "World.vcdbs" }])]) + const event = await createTrustedEvent() + validateWorldBackupArchive.mockRejectedValueOnce(new Error("corrupt archive")) + + const result = await handler("worlds-restore")(event, "install-a", backupId) + assert.deepEqual(result, { ok: false, reason: "operation-failed" }) + }) + + it("refuses restore when extracted archive contains multiple files or an unsafe file", async () => { + const sourcePath = join(installationsRoot, "install-a") + const sourceWorld = join(sourcePath, "Saves", "World.vcdbs") + mkdirSync(join(sourcePath, "Saves"), { recursive: true }) + writeFileSync(sourceWorld, "world", "utf8") + const backupId = "backup-multi-entry" + const backupArchive = join(backupsFolder, "Worlds", `${backupId}.tar.gz`) + mkdirSync(join(backupsFolder, "Worlds"), { recursive: true }) + writeFileSync(backupArchive, "dummy", "utf8") + writeConfig([installation("install-a", sourcePath, "1.22.7", [{ id: backupId, date: 1, path: backupArchive, worldName: "World.vcdbs" }])]) + const event = await createTrustedEvent() + + // Multiple entries extracted + extractTarGz.mockImplementationOnce(async (_archivePath, outputPath) => { + writeFileSync(join(outputPath, "World.vcdbs"), "data", "utf8") + writeFileSync(join(outputPath, "Extra.vcdbs"), "extra", "utf8") + }) + const multiResult = await handler("worlds-restore")(event, "install-a", backupId) + assert.deepEqual(multiResult, { ok: false, reason: "operation-failed" }) + + // Unsafe entry name + extractTarGz.mockImplementationOnce(async (_archivePath, outputPath) => { + writeFileSync(join(outputPath, "unsafe.txt"), "unsafe", "utf8") + }) + const unsafeResult = await handler("worlds-restore")(event, "install-a", backupId) + assert.deepEqual(unsafeResult, { ok: false, reason: "operation-failed" }) + }) + + it("fails mutating operations when path assertions reject", async () => { + const sourcePath = join(installationsRoot, "install-a") + const targetPath = join(installationsRoot, "install-b") + mkdirSync(join(sourcePath, "Saves"), { recursive: true }) + mkdirSync(join(targetPath, "Saves"), { recursive: true }) + writeFileSync(join(sourcePath, "Saves", "World.vcdbs"), "world", "utf8") + writeConfig([installation("install-a", sourcePath), installation("install-b", targetPath)]) + const event = await createTrustedEvent() + + // Backup fails when assertManagedPath rejects on world.path + assertManagedPathSpy.mockImplementation(async (value: unknown, name?: string, options?: Parameters[2]) => { + if (name === "world") throw new TypeError("Unmanaged world path") + return realAssertManagedPath(value, name, options) + }) + const backupFail = await handler("worlds-backup")(event, "install-a", "World.vcdbs") + assert.deepEqual(backupFail, { ok: false, reason: "operation-failed" }) + assertManagedPathSpy.mockImplementation(realAssertManagedPath) + + // Delete fails when assertManagedDeletionPath rejects on world.path + assertManagedDeletionPathSpy.mockRejectedValueOnce(new TypeError("Protected path")) + const deleteFail = await handler("worlds-delete")(event, "install-a", "World.vcdbs") + assert.deepEqual(deleteFail, { ok: false, reason: "operation-failed" }) + + // Transfer fails when assertManagedPath rejects on destination world + assertManagedPathSpy.mockImplementation(async (value: unknown, name?: string, options?: Parameters[2]) => { + if (name === "destination world") throw new TypeError("Unmanaged destination world") + return realAssertManagedPath(value, name, options) + }) + const transferFail = await handler("worlds-transfer")(event, "install-a", "World.vcdbs", "install-b", "copy") + assert.deepEqual(transferFail, { ok: false, reason: "operation-failed" }) + assertManagedPathSpy.mockImplementation(realAssertManagedPath) + }) + it("refuses every mutating worlds channel while an installation is playing", async () => { const installationPath = join(installationsRoot, "install-a") mkdirSync(join(installationPath, "Saves"), { recursive: true }) @@ -285,9 +392,7 @@ describe("worlds IPC handlers", () => { const first = handler("worlds-backup")(event, "install-a", "First.vcdbs") const second = handler("worlds-backup")(event, "install-b", "Second.vcdbs") await waitFor(() => compressionState.calls.length === 2) - compressionState.release.shift()?.() - await new Promise((resolve) => setImmediate(resolve)) - compressionState.release.shift()?.() + while (compressionState.release.length) compressionState.release.shift()?.() const results = await Promise.all([first, second]) expect(results.every((result) => (result as WorldBackupResult).ok)).toBe(true) diff --git a/tests/renderer-dom/manageInstallationWorlds.test.tsx b/tests/renderer-dom/manageInstallationWorlds.test.tsx index 34ab8c40..9addc61f 100644 --- a/tests/renderer-dom/manageInstallationWorlds.test.tsx +++ b/tests/renderer-dom/manageInstallationWorlds.test.tsx @@ -107,4 +107,36 @@ describe("ManageInstallationWorlds deletion confirmation", () => { expect((within(reopenedDialog).getByRole("textbox") as HTMLInputElement).value).toBe("") expect((within(reopenedDialog).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(true) }) + + it("disables world mutation buttons while the installation is playing", async () => { + installMockWindowApi({ + configManager: { getConfig: vi.fn(async () => createMockConfig({ installations: [{ ...anInstallation(), _playing: true }] })) }, + worldsManager: { + list: vi.fn(async () => ({ + ok: true as const, + worlds: [{ name: "World.vcdbs", size: 5, lastModified: 1, isDefault: false, backupCount: 1 }] + })), + delete: vi.fn(async () => ({ ok: true as const })) + } + }) + + renderWithProviders( + + } /> + , + { route: "/installations/worlds/install-a" } + ) + + const backupButton = (await screen.findByRole("button", { name: "Back up this world" })) as HTMLButtonElement + const copyButton = (await screen.findByRole("button", { name: "Copy world" })) as HTMLButtonElement + const moveButton = (await screen.findByRole("button", { name: "Move world" })) as HTMLButtonElement + const deleteButton = (await screen.findByRole("button", { name: "Delete" })) as HTMLButtonElement + const restoreButton = (await screen.findByRole("button", { name: "Restore" })) as HTMLButtonElement + + expect(backupButton.disabled).toBe(true) + expect(copyButton.disabled).toBe(true) + expect(moveButton.disabled).toBe(true) + expect(deleteButton.disabled).toBe(true) + expect(restoreButton.disabled).toBe(true) + }) })