fix: preserve legacy store maintenance

This commit is contained in:
Kurt Himebauch committed 2026-10-28 17:30:58 -04:00
1 parent cd09deddba
commit 67034e5f3d
8 files changed
+258 -71

No files matched your search

@@ -85,7 +85,7 @@ internal object AmazonMaintenanceAdapter : StoreMaintenanceAdapter {
val target = JSONObject().put("schema", TARGET_SCHEMA).put("versionId", version)
.put("manifestPath", path.absolutePath)
return MaintenanceProposal(
game.folder.canonicalFile, game.sidecar, StoreInstallRoot.labelFor(context, game.folder), action,
game.folder.canonicalFile, game.sidecar, "Version $version", action,
downloadBytes = bytes, diskBytes = bytes, description = "Up to ${com.droiddeck.launcher.stores.formatBytes(bytes)} of whole-file data; existing files are verified against Amazon's manifest.",
target = target,
)
@@ -95,7 +95,8 @@ internal object EpicMaintenanceAdapter : StoreMaintenanceAdapter {
.put("deploymentId", deploymentId)
.put("manifestPath", targetPath.absolutePath).put("tags", JSONArray(tags))
return MaintenanceProposal(
game.folder.canonicalFile, game.sidecar, StoreInstallRoot.labelFor(context, game.folder), action,
game.folder.canonicalFile, game.sidecar,
if (action == MaintenanceAction.REPAIR) "Installed build $targetBuild" else "Build $targetBuild", action,
downloadBytes = downloadBytes, diskBytes = diskBytes,
description = "Up to ${com.droiddeck.launcher.stores.formatBytes(downloadBytes)} of unique Epic chunks; files are SHA-1 verified against the pinned manifest.",
target = target,
@@ -79,7 +79,14 @@ internal class ItchInstaller(
handle.stage(DownloadStage.INSTALL)
install(archive, filename, handle, resuming)
} catch (t: Throwable) {
if (maintenanceTarget != null) runCatching { handle.interruptible(true) }
if (maintenanceTarget != null) {
runCatching { handle.interruptible(true) }
val beforeSidecar = previous
val recoverableCommit = runCatching { ItchStaging.recover(folder) }.getOrDefault(true)
if (t is ItchStaging.PreflightConflict && !recoverableCommit && beforeSidecar != null) {
runCatching { StoreMaintenance.complete(app, InstalledStoreGame(beforeSidecar, folder)) }
}
}
if (!cancelled.get()) dropCaches(scratch)
throw t
}
@@ -246,9 +253,16 @@ internal class ItchInstaller(
val old = previous ?: throw IllegalStateException("The installed itch.io sidecar disappeared")
val game = InstalledStoreGame(old, folder)
StoreMaintenance.withApplyLease(game) {
val managed = ItchStaging.readInventory(folder).filterKeys { it != old.exe }
val next = ItchStaging.commit(work, folder, managed)
ItchStaging.writeInventory(folder, next)
val targetInventory = ItchStaging.inventory(work)
val savedInventory = ItchStaging.readInventoryIfPresent(folder)
val targetUploadId = maintenanceTarget?.optString("uploadId").orEmpty()
val targetVersion = maintenanceTarget?.optString("updatedAt").orEmpty()
val installedExactUpload = targetUploadId.isNotBlank() && old.extra["uploadId"] == targetUploadId &&
old.installVersion == targetVersion
val managed = ItchStaging.previousInventory(savedInventory, targetInventory, installedExactUpload, old.exe)
ItchStaging.commit(work, folder, managed, onCommitted = { next ->
ItchStaging.writeInventory(folder, next)
})
}
}
return exe
@@ -99,6 +99,7 @@ internal object ItchMaintenanceAdapter : StoreMaintenanceAdapter {
}
override fun recovery(context: Context, game: InstalledStoreGame): MaintenanceRecovery {
ItchStaging.readInventoryIfPresent(game.folder)
val operation = StoreMaintenance.readOperation(game.folder)
return if (operation?.optString("phase")?.let { it in setOf("queued", "download", "applying") } == true || ItchStaging.recover(game.folder))
MaintenanceRecovery(RecoveryState.RESUMABLE, "Resume the interrupted itch.io archive replacement")
@@ -16,6 +16,8 @@ internal object ItchStaging {
private const val BACKUPS = "itch-backups"
private const val DIRECTORY = "<directory>"
class PreflightConflict(message: String) : IllegalArgumentException(message)
fun inventory(root: File): Map<String, String> = linkedMapOf<String, String>().also { out ->
fun visit(dir: File, prefix: String) {
for (child in dir.listFiles().orEmpty().sortedBy { it.name }) {
@@ -36,12 +38,22 @@ internal object ItchStaging {
it.lowercase() in setOf("save", "saves", "userdata", "user data", "profiles")
} || relative.substringAfterLast('/').substringAfterLast('.', "").lowercase() in setOf("sav", "save")
fun readInventory(root: File): Map<String, String> {
fun readInventory(root: File): Map<String, String> = readInventoryIfPresent(root).orEmpty()
fun previousInventory(
saved: Map<String, String>?,
target: Map<String, String>,
exactLegacyTarget: Boolean,
selectedExe: String,
): Map<String, String> = (saved ?: if (exactLegacyTarget) target else emptyMap())
.filterKeys { it != selectedExe || it in target }
fun readInventoryIfPresent(root: File): Map<String, String>? {
val metadata = File(root, ".droiddeck-maintenance")
require(!Files.isSymbolicLink(metadata.toPath())) { "Maintenance metadata directory is a symbolic link" }
val file = File(metadata, INVENTORY)
require(!Files.isSymbolicLink(file.toPath())) { "itch.io inventory is a symbolic link" }
if (!file.exists()) return emptyMap()
if (!file.exists()) return null
require(file.isFile) { "itch.io inventory is not a regular file" }
val value = runCatching { JSONObject(file.readText()) }
.getOrElse { throw IllegalStateException("itch.io inventory is corrupt", it) }
@@ -63,7 +75,13 @@ internal object ItchStaging {
writeJournal(file, JSONObject().apply { inventory.forEach { (path, hash) -> put(path, hash) } })
}
fun commit(from: File, to: File, previous: Map<String, String>, afterEntry: ((Int) -> Unit)? = null): Map<String, String> {
fun commit(
from: File,
to: File,
previous: Map<String, String>,
afterEntry: ((Int) -> Unit)? = null,
onCommitted: ((Map<String, String>) -> Unit)? = null,
): Map<String, String> {
require(!Files.isSymbolicLink(to.toPath())) { "The itch.io install folder is a symbolic link" }
val metadata = File(to, ".droiddeck-maintenance").apply { mkdirs() }
require(!Files.isSymbolicLink(metadata.toPath())) { "Maintenance metadata directory is a symbolic link" }
@@ -98,72 +116,120 @@ internal object ItchStaging {
check(state.optInt("schema") == 1 && existingNext != null && state.optJSONObject("done") != null && state.optJSONObject("obsolete") != null || existing == null) {
"An interrupted itch.io replacement has an unsupported journal"
}
writeJournal(journal, state)
require(!Files.isSymbolicLink(backups.toPath())) { "The itch.io backup directory is a symbolic link" }
backups.mkdirs()
require(!Files.isSymbolicLink(backups.toPath())) { "The itch.io backup directory is a symbolic link" }
check(backups.isDirectory) { "The itch.io backup path is not a directory" }
val journalDone = state.optJSONObject("done")
for (relative in paths) {
val expected = next[relative]
if (expected == null) continue
val path = relative.removeSuffix("/")
val dest = safe(root, path) ?: throw PreflightConflict("Unsafe itch.io archive path: $relative")
safe(backups, path) ?: throw PreflightConflict("Unsafe itch.io backup path: $relative")
val wasJournaledDone = journalDone?.optBoolean(relative) == true
if (expected == DIRECTORY) {
if (dest.exists() && !dest.isDirectory) {
val oldFileHash = previous[path]
if (oldFileHash == null || !dest.isFile || sha256(dest) != oldFileHash)
throw PreflightConflict("itch.io archive directory collides with an unmanaged file: $path")
}
continue
}
val oldHash = previous[relative]
if (protectedUserData(path) && dest.exists()) {
if (!((oldHash != null && dest.isFile && sha256(dest) == oldHash) || wasJournaledDone))
throw PreflightConflict("itch.io archive collides with untracked or modified user data: $path")
}
val currentHash = if (dest.isFile) sha256(dest) else null
if (currentHash != expected) {
val emptyManagedDirectory = dest.isDirectory && !Files.isSymbolicLink(dest.toPath()) && dest.listFiles().isNullOrEmpty()
if (dest.exists() && !dest.isFile && !emptyManagedDirectory)
throw PreflightConflict("itch.io update collides with an unmanaged directory: $relative")
if (dest.exists() && oldHash == null && !emptyManagedDirectory && !wasJournaledDone)
throw PreflightConflict("itch.io update collides with an unmanaged file: $relative")
require(safe(stage, path)?.isFile == true) { "Staged itch.io file is missing: $relative" }
}
}
writeJournal(journal, state)
var count = 0
for (relative in paths) {
val directory = next[relative] == DIRECTORY
val path = relative.removeSuffix("/")
val dest = safe(root, path) ?: throw IllegalStateException("Unsafe itch.io archive path: $relative")
val staged = safe(stage, path)
val expected = next[relative]
val oldHash = previous[relative]
val backup = safe(backups, path)!!
val done = state.optJSONObject("done") ?: JSONObject().also { state.put("done", it) }
val wasJournaledDone = done.optBoolean(relative)
val dest = safe(root, path)
if (dest == null && expected == null) {
done.put(relative, true)
writeJournal(journal, state)
count++
afterEntry?.invoke(count)
continue
}
val actualDest = dest ?: throw IllegalStateException("Unsafe itch.io archive path: $relative")
val staged = safe(stage, path)
val backup = safe(backups, path)
if (backup == null && expected == null) {
done.put(relative, true)
writeJournal(journal, state)
count++
afterEntry?.invoke(count)
continue
}
val actualBackup = backup ?: throw IllegalStateException("Unsafe itch.io backup path: $relative")
if (wasJournaledDone) {
val expectedHash = expected.takeUnless { it == DIRECTORY }
if (expectedHash != null && dest.isFile && sha256(dest) == expectedHash) { count++; continue }
if (expected == DIRECTORY && dest.isDirectory && !Files.isSymbolicLink(dest.toPath())) { count++; continue }
if (expectedHash == null && expected != DIRECTORY && (!dest.isFile || oldHash == null || sha256(dest) != oldHash)) { count++; continue }
if (expectedHash != null && actualDest.isFile && sha256(actualDest) == expectedHash) { count++; continue }
if (expected == DIRECTORY && actualDest.isDirectory && !Files.isSymbolicLink(actualDest.toPath())) { count++; continue }
if (expectedHash == null && expected != DIRECTORY && (!actualDest.isFile || oldHash == null || sha256(actualDest) != oldHash)) { count++; continue }
done.put(relative, false)
writeJournal(journal, state)
}
if (expected == null) {
if (oldHash == DIRECTORY) {
if (dest.isDirectory && !Files.isSymbolicLink(dest.toPath()) && dest.listFiles().isNullOrEmpty()) dest.delete()
} else if (dest.isFile && oldHash != null && sha256(dest) == oldHash) {
backup.parentFile?.mkdirs()
move(dest, backup)
if (actualDest.isDirectory && !Files.isSymbolicLink(actualDest.toPath()) && actualDest.listFiles().isNullOrEmpty()) actualDest.delete()
} else if (actualDest.isFile && oldHash != null && sha256(actualDest) == oldHash) {
actualBackup.parentFile?.mkdirs()
move(actualDest, actualBackup)
}
done.put(relative, true)
writeJournal(journal, state)
} else if (directory) {
require(!Files.isSymbolicLink(dest.toPath())) { "itch.io archive directory collides with a symbolic link: $path" }
if (dest.exists() && !dest.isDirectory) {
require(!Files.isSymbolicLink(actualDest.toPath())) { "itch.io archive directory collides with a symbolic link: $path" }
if (actualDest.exists() && !actualDest.isDirectory) {
val oldFileHash = previous[path]
require(oldFileHash != null && sha256(dest) == oldFileHash) { "itch.io archive directory collides with an unmanaged file: $path" }
backup.parentFile?.mkdirs()
move(dest, backup)
require(oldFileHash != null && sha256(actualDest) == oldFileHash) { "itch.io archive directory collides with an unmanaged file: $path" }
actualBackup.parentFile?.mkdirs()
move(actualDest, actualBackup)
}
check(dest.isDirectory || dest.mkdirs()) { "Could not create itch.io archive directory: $path" }
check(actualDest.isDirectory || actualDest.mkdirs()) { "Could not create itch.io archive directory: $path" }
done.put(relative, true)
writeJournal(journal, state)
} else {
if (protectedUserData(path) && dest.exists()) {
require(oldHash != null && dest.isFile && sha256(dest) == oldHash) {
if (protectedUserData(path) && actualDest.exists()) {
require((oldHash != null && actualDest.isFile && sha256(actualDest) == oldHash) || wasJournaledDone) {
"itch.io archive collides with untracked or modified user data: $path"
}
}
val currentHash = if (dest.isFile) sha256(dest) else null
val currentHash = if (actualDest.isFile) sha256(actualDest) else null
if (currentHash == expected) {
staged?.delete()
} else {
val stagedFile = staged?.takeIf { it.isFile } ?: throw IllegalStateException("Staged itch.io file is missing: $relative")
val emptyManagedDirectory = dest.isDirectory && !Files.isSymbolicLink(dest.toPath()) && dest.listFiles().isNullOrEmpty()
require(!dest.exists() || oldHash != null || emptyManagedDirectory || wasJournaledDone || backup.exists()) { "itch.io update collides with an unmanaged file: $relative" }
if (dest.isDirectory && !Files.isSymbolicLink(dest.toPath()) && dest.listFiles().isNullOrEmpty()) dest.delete()
require(!dest.exists() || dest.isFile) { "itch.io update collides with an unmanaged directory: $relative" }
require(!Files.isSymbolicLink(dest.toPath())) { "itch.io update collides with a symbolic link: $relative" }
if (dest.exists() && !backup.exists()) {
backup.parentFile?.mkdirs()
move(dest, backup)
val emptyManagedDirectory = actualDest.isDirectory && !Files.isSymbolicLink(actualDest.toPath()) && actualDest.listFiles().isNullOrEmpty()
require(!actualDest.exists() || oldHash != null || emptyManagedDirectory || wasJournaledDone) { "itch.io update collides with an unmanaged file: $relative" }
if (actualDest.isDirectory && !Files.isSymbolicLink(actualDest.toPath()) && actualDest.listFiles().isNullOrEmpty()) actualDest.delete()
require(!actualDest.exists() || actualDest.isFile) { "itch.io update collides with an unmanaged directory: $relative" }
require(!Files.isSymbolicLink(actualDest.toPath())) { "itch.io update collides with a symbolic link: $relative" }
if (actualDest.exists() && !actualBackup.exists()) {
actualBackup.parentFile?.mkdirs()
move(actualDest, actualBackup)
}
dest.parentFile?.mkdirs()
move(stagedFile, dest)
check(sha256(dest) == expected) { "itch.io replacement failed verification: $relative" }
actualDest.parentFile?.mkdirs()
move(stagedFile, actualDest)
check(sha256(actualDest) == expected) { "itch.io replacement failed verification: $relative" }
}
done.put(relative, true)
writeJournal(journal, state)
@@ -171,6 +237,7 @@ internal object ItchStaging {
count++
afterEntry?.invoke(count)
}
onCommitted?.invoke(next)
StoreInstallsSafeDelete.delete(from)
StoreInstallsSafeDelete.delete(backups)
journal.delete()
@@ -1,6 +1,7 @@
package com.droiddeck.launcher.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
@@ -17,6 +18,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -55,10 +57,11 @@ internal fun StoreMaintenanceActions(game: InstalledStoreGame) {
val key = game.key
val download = StoresState.download(key)
val active = download?.isActive == true
var busy by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
var proposal by remember { mutableStateOf<MaintenanceProposal?>(null) }
var recovery by remember { mutableStateOf(MaintenanceRecovery()) }
var busy by remember(key) { mutableStateOf(false) }
var error by remember(key) { mutableStateOf<String?>(null) }
var proposal by remember(key) { mutableStateOf<MaintenanceProposal?>(null) }
var recovery by remember(key) { mutableStateOf(MaintenanceRecovery()) }
var menu by remember(key) { mutableStateOf(false) }
LaunchedEffect(game.folder.path) { StoreMaintenance.check(context, game) }
LaunchedEffect(game.folder.path, active, download?.state) {
@@ -74,7 +77,7 @@ internal fun StoreMaintenanceActions(game: InstalledStoreGame) {
if (download.state == com.droiddeck.launcher.stores.download.DownloadState.PAUSED) DownloadQueue.resume(context, key) else DownloadQueue.pause(key)
}
} else if (recovery.state == RecoveryState.RESUMABLE) {
SecondaryButton("Resume", enabled = !busy) {
SecondaryButton("Resume", enabled = !busy, modifier = Modifier.testTag("store-resume")) {
busy = true
scope.launch {
runCatching { withContext(Dispatchers.IO) { StoreMaintenance.resume(context, game) } }
@@ -82,25 +85,42 @@ internal fun StoreMaintenanceActions(game: InstalledStoreGame) {
busy = false
}
}
} else {
if (MaintenanceAction.UPDATE in capabilities) {
val target = check?.takeIf { it.state == MaintenanceCheckState.AVAILABLE }?.version?.takeIf { it.isNotBlank() }
SecondaryButton(target?.let { "Update to $it" } ?: "Update", enabled = !busy) {
beginPrepare(MaintenanceAction.UPDATE, context, game, scope, { busy = it }, { proposal = it }, { error = it })
}
}
if (MaintenanceAction.REPAIR in capabilities) SecondaryButton("Repair", enabled = !busy) {
beginPrepare(MaintenanceAction.REPAIR, context, game, scope, { busy = it }, { proposal = it }, { error = it })
}
if (MaintenanceAction.REFRESH in capabilities) SecondaryButton("Refresh", enabled = !busy) {
beginPrepare(MaintenanceAction.REFRESH, context, game, scope, { busy = it }, { proposal = it }, { error = it })
}
if (MaintenanceAction.OPEN_CLIENT in capabilities) SecondaryButton(adapter.openClientLabel(game), enabled = !busy) {
runCatching { StoreMaintenance.openClient(context, game) }.onFailure { error = it.message }
} else if (MaintenanceAction.UPDATE in capabilities && check?.state == MaintenanceCheckState.AVAILABLE) {
val target = check.version.takeIf { it.isNotBlank() }
SecondaryButton(target?.let { "Update to $it" } ?: "Update", enabled = !busy, modifier = Modifier.testTag("store-update")) {
beginPrepare(MaintenanceAction.UPDATE, context, game, scope, { busy = it }, { proposal = it }, { error = it })
}
}
if (!active && game.sidecar.isInstalled) SecondaryButton("Check", enabled = !busy) {
StoreMaintenance.check(context, game, force = true)
if (!active) Box {
SecondaryButton("Maintenance", enabled = !busy, modifier = Modifier.testTag("store-maintenance")) { menu = !menu }
AnchoredMenu(menu, onDismiss = { menu = false }, title = "Maintenance", compact = true) { first ->
MenuItem("Check for updates", checked = false, enabled = !busy && game.sidecar.isInstalled,
modifier = Modifier.testTag("store-check"), focusRequester = first) {
menu = false
StoreMaintenance.check(context, game, force = true)
}
if (MaintenanceAction.UPDATE in capabilities) {
val legacyBootstrap = game.sidecar.store in setOf(com.droiddeck.launcher.stores.Store.EPIC, com.droiddeck.launcher.stores.Store.AMAZON) &&
check?.state != MaintenanceCheckState.AVAILABLE
MenuItem(if (legacyBootstrap) "Update / verify current build" else "Update", checked = false, enabled = !busy,
modifier = Modifier.testTag("store-update-menu")) {
beginPrepare(MaintenanceAction.UPDATE, context, game, scope, { busy = it }, { proposal = it }, { error = it })
}
}
if (MaintenanceAction.REPAIR in capabilities) MenuItem("Verify / Repair", checked = false, enabled = !busy,
modifier = Modifier.testTag("store-repair")) {
beginPrepare(MaintenanceAction.REPAIR, context, game, scope, { busy = it }, { proposal = it }, { error = it })
}
if (MaintenanceAction.REFRESH in capabilities) MenuItem("Refresh", checked = false, enabled = !busy,
modifier = Modifier.testTag("store-refresh")) {
beginPrepare(MaintenanceAction.REFRESH, context, game, scope, { busy = it }, { proposal = it }, { error = it })
}
if (MaintenanceAction.OPEN_CLIENT in capabilities) MenuItem(adapter.openClientLabel(game), checked = false, enabled = !busy,
modifier = Modifier.testTag("store-open-client")) {
menu = false
runCatching { StoreMaintenance.openClient(context, game) }.onFailure { error = it.message }
}
}
}
}
if (!active && recovery.state == RecoveryState.NEEDS_ATTENTION && recovery.message.isNotBlank())
@@ -1,14 +1,22 @@
package com.droiddeck.launcher.stores.itch
import com.droiddeck.launcher.stores.InstalledStoreGame
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreGameSidecar
import com.droiddeck.launcher.stores.maintenance.RecoveryState
import com.droiddeck.launcher.stores.maintenance.StoreMaintenance
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.rules.TemporaryFolder
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import java.io.File
@RunWith(RobolectricTestRunner::class)
@@ -117,7 +125,7 @@ class ItchStagingTest {
file(staged, "b.txt", "new-b")
val previous = ItchStaging.inventory(game)
assertThrows(IllegalStateException::class.java) {
ItchStaging.commit(staged, game, previous) { if (it == 1) error("simulated interruption") }
ItchStaging.commit(staged, game, previous, afterEntry = { if (it == 1) error("simulated interruption") })
}
assertTrue(ItchStaging.recover(game))
val next = ItchStaging.commit(staged, game, previous)
@@ -128,6 +136,20 @@ class ItchStagingTest {
assertFalse(ItchStaging.recover(game))
}
@Test fun failedInventoryPublicationKeepsCommitJournalUntilResume() {
val game = tmp.newFolder("inventory-publication-game")
val staged = tmp.newFolder("inventory-publication-stage")
file(staged, "game.exe", "publisher")
assertThrows(IllegalStateException::class.java) {
ItchStaging.commit(staged, game, emptyMap(), onCommitted = { error("simulated inventory failure") })
}
assertTrue(ItchStaging.recover(game))
val next = ItchStaging.commit(staged, game, emptyMap(), onCommitted = { ItchStaging.writeInventory(game, it) })
assertEquals("publisher", File(game, "game.exe").readText())
assertEquals(next, ItchStaging.readInventory(game))
assertFalse(ItchStaging.recover(game))
}
@Test fun unmanagedCollisionPreservesUserFile() {
val game = tmp.newFolder("collision-game")
val staged = tmp.newFolder("collision-stage")
@@ -135,6 +157,45 @@ class ItchStagingTest {
file(staged, "saves/slot1.sav", "archive-file")
assertThrows(IllegalArgumentException::class.java) { ItchStaging.commit(staged, game, emptyMap()) }
assertEquals("my-save", File(game, "saves/slot1.sav").readText())
assertFalse(ItchStaging.recover(game))
}
@Test fun legacyInventoryBootstrapRequiresAnAbsentInventoryAndExactTarget() {
val targetStage = tmp.newFolder("legacy-target")
file(targetStage, "game.exe", "publisher build")
file(targetStage, "notes.txt", "publisher notes")
val target = ItchStaging.inventory(targetStage)
assertEquals(target, ItchStaging.previousInventory(null, target, exactLegacyTarget = true, selectedExe = "game.exe"))
assertEquals(emptyMap<String, String>(), ItchStaging.previousInventory(null, target, exactLegacyTarget = false, selectedExe = "game.exe"))
assertEquals(emptyMap<String, String>(), ItchStaging.previousInventory(emptyMap(), target, exactLegacyTarget = true, selectedExe = "game.exe"))
assertEquals(mapOf("notes.txt" to target.getValue("notes.txt")),
ItchStaging.previousInventory(target, target - "game.exe", exactLegacyTarget = true, selectedExe = "game.exe"))
val game = tmp.newFolder("legacy-game")
file(game, "game.exe", "corrupted publisher build")
file(game, "user-notes.txt", "keep")
ItchStaging.commit(targetStage, game,
ItchStaging.previousInventory(null, target, exactLegacyTarget = true, selectedExe = "game.exe"))
assertEquals("publisher build", File(game, "game.exe").readText())
assertEquals("keep", File(game, "user-notes.txt").readText())
val unknownBuild = tmp.newFolder("unknown-legacy-game")
val unknownTargetStage = tmp.newFolder("unknown-legacy-target")
file(unknownTargetStage, "game.exe", "publisher build")
file(unknownTargetStage, "notes.txt", "publisher notes")
file(unknownBuild, "game.exe", "unknown installed build")
assertThrows(IllegalArgumentException::class.java) {
ItchStaging.commit(unknownTargetStage, unknownBuild,
ItchStaging.previousInventory(null, target, exactLegacyTarget = false, selectedExe = "game.exe"))
}
assertEquals("unknown installed build", File(unknownBuild, "game.exe").readText())
assertFalse(ItchStaging.recover(unknownBuild))
val inventoryState = tmp.newFolder("inventory-state")
assertNull(ItchStaging.readInventoryIfPresent(inventoryState))
ItchStaging.writeInventory(inventoryState, emptyMap())
assertEquals(emptyMap<String, String>(), ItchStaging.readInventoryIfPresent(inventoryState))
}
@Test fun publisherUserDataPlaceholdersAndEmptyDirectoriesAreInstalledAndManaged() {
@@ -145,7 +206,7 @@ class ItchStagingTest {
File(staged, "data/required-empty").mkdirs()
assertThrows(IllegalStateException::class.java) {
ItchStaging.commit(staged, game, emptyMap()) { if (it == 2) error("simulated interruption") }
ItchStaging.commit(staged, game, emptyMap(), afterEntry = { if (it == 2) error("simulated interruption") })
}
assertTrue(ItchStaging.recover(game))
val next = ItchStaging.commit(staged, game, emptyMap())
@@ -184,7 +245,7 @@ class ItchStagingTest {
file(staged, "b.txt", "new-b")
val previous = ItchStaging.inventory(game)
assertThrows(IllegalStateException::class.java) {
ItchStaging.commit(staged, game, previous) { if (it == 2) error("simulated interruption") }
ItchStaging.commit(staged, game, previous, afterEntry = { if (it == 2) error("simulated interruption") })
}
file(game, "a.txt", "tampered")
file(staged, "a.txt", "new-a")
@@ -228,4 +289,20 @@ class ItchStagingTest {
assertThrows(IllegalArgumentException::class.java) { ItchStaging.readInventory(game) }
assertEquals("keep", File(game, "publisher.txt").readText())
}
@Test fun corruptInventoryMakesApplyingRecoveryNeedAttention() {
val folder = tmp.newFolder("corrupt-recovery-inventory").canonicalFile
val sidecar = StoreGameSidecar(Store.ITCH, "game", "Example", "game.exe")
sidecar.write(folder)
val game = InstalledStoreGame(sidecar, folder)
StoreMaintenance.register(ItchMaintenanceAdapter)
val operation = StoreMaintenance.operationFile(folder)
operation.parentFile.mkdirs()
operation.writeText(JSONObject().put("schema", 1).put("store", Store.ITCH.id)
.put("id", sidecar.id).put("folder", folder.path).put("phase", "applying").toString())
File(operation.parentFile, "itch-inventory.json").writeText("{")
assertEquals(RecoveryState.NEEDS_ATTENTION,
StoreMaintenance.recovery(RuntimeEnvironment.getApplication(), game).state)
}
}
+13 -6
View File
@@ -11,24 +11,31 @@ its existing engine or client can perform.
- **Epic Games:** metadata checks, pinned manifest updates, and repair from the
retained installed manifest. Existing Epic install settings and locale tags
are preserved.
are preserved. For upstream capability context, see [Legendary's CLI](https://github.com/legendary-gl/legendary/blob/master/README.md)
and [download planner](https://github.com/legendary-gl/legendary/blob/master/legendary/downloader/mp/manager.py).
- **Amazon Games:** metadata checks, pinned manifest updates, and repair from
the retained installed manifest. Its Rust engine is bundled with this
checkout; it is not an optional engine pack.
checkout; it is not an optional engine pack. Upstream comparison: [Nile CLI](https://github.com/imLinguin/nile/blob/master/nile/cli.py),
[download manager](https://github.com/imLinguin/nile/blob/master/nile/downloading/manager.py),
and [active worker](https://github.com/imLinguin/nile/blob/master/nile/downloading/worker.py).
- **Battle.net:** checks and update handoff to the existing Agent workflow.
When unattended repair is unavailable, the UI opens Battle.net for its
Scan and Repair flow. The app does not claim the client repair already ran.
See [Blizzard's Scan and Repair instructions](https://eu.support.blizzard.com/es/help/article/6572).
- **Xbox:** update through Xodus's licensed package streaming path. The UI
reports an unknown package version when Xodus does not expose one; transfer
and space amounts are client-determined. Metadata checks do not claim success
when package identity or version information is missing.
when package identity or version information is missing. References: [Xodus](https://github.com/xodus-gaming/xodus),
[Nexus](https://github.com/domsvoboda/xodus-nexus), and the [pinned package-update query](https://github.com/domsvoboda/xodus-nexus/blob/396e7fb8fe0e5f00d956091195c614b8a0732ab1/assets/xodus-store-updates.patch).
- **Steam:** navigation to Steam downloads/library and guidance to use the
game's Properties → Installed Files → Verify integrity. The app does not run
Steam validation itself. Native Steam apps are distinguished from
third-party shortcuts.
third-party shortcuts. See [Valve's game-file validation instructions](https://help.steampowered.com/en/faqs/view/0C48-FCBD-DA71-93EB).
- **itch.io:** update, repair, and refresh from a pinned archive upload, with
per-file commit recovery. Butler cave migration is deferred; refresh uses
the current upload archive and does not migrate legacy Butler cave state.
per-file commit recovery. Butler/Wharf migration is deferred; current archive
operations do not migrate legacy Butler cave state or use Wharf deltas. See
the [Wharf specification](https://itch.io/docs/wharf/), [Butler patch and verification primitives](https://itch.io/docs/butler/offline.html),
and [Butler ARM64 release](https://github.com/itchio/butler/releases/tag/v15.25.0).
Unknown transfer or disk estimates use a negative sentinel and appear as
“Unknown (client-determined)”; zero remains a real zero-byte estimate.