mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-12 03:21:08 +00:00
fix: preserve legacy store maintenance
This commit is contained in:
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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user