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)
+ })
})