fix: guard maintenance queue admission

This commit is contained in:
Kurt Himebauch committed 2026-10-28 18:12:58 -04:00
1 parent be51429fa4
commit 3ccf8ca01a
16 files changed
+362 -141

No files matched your search

@@ -64,10 +64,21 @@ object StoreLaunch {
private const val CODE_VAR = "DD_X"
private const val CLIENT_VAR = "DD_A"
internal fun matchingInstalls(context: Context, store: Store, id: String): List<Pair<File, StoreGameSidecar>> =
StoreInstallRoot.gameFolders(context.applicationContext).mapNotNull { folder ->
StoreGameSidecar.read(folder)?.takeIf { it.store == store && it.id == id }?.let { folder to it }
}
internal fun findInstalled(context: Context, store: Store, id: String): Pair<File, StoreGameSidecar>? =
matchingInstalls(context, store, id).firstOrNull { it.second.isInstalled }
private fun preferredInstall(context: Context, store: Store, id: String): Pair<File, StoreGameSidecar>? {
val matches = matchingInstalls(context, store, id)
return matches.firstOrNull { it.second.isInstalled } ?: matches.firstOrNull()
}
fun installedGame(context: Context, store: Store, id: String): Library.SteamGame? {
val folder = StoreInstallRoot.gameFolders(context).firstOrNull { folder ->
StoreGameSidecar.read(folder)?.let { it.store == store && it.id == id && it.isInstalled } == true
} ?: return null
val folder = findInstalled(context, store, id)?.first ?: return null
val game = AddedGames.single(context, folder.path) ?: return null
return Library.addedGame(context, game)
}
@@ -84,17 +95,19 @@ object StoreLaunch {
fun prepare(context: Context, store: Store, id: String, onReady: () -> Unit) {
val app = context.applicationContext
val folder = StoreInstallRoot.gameFolders(app).firstOrNull { f ->
StoreGameSidecar.read(f)?.let { it.store == store && it.id == id } == true
}
val sidecar = folder?.let { StoreGameSidecar.read(it) }
if (folder != null && sidecar != null && com.droiddeck.launcher.stores.maintenance.StoreMaintenance.launchExcluded(InstalledStoreGame(sidecar, folder))) {
Log.i(TAG, "launch blocked while ${store.label} maintenance is active: $id")
return
}
if (store != Store.EPIC) { onReady(); return }
Thread({
epicCode(app, id)
val selected = try {
preferredInstall(app, store, id)
} catch (e: Exception) {
Log.w(TAG, "could not inspect installed ${store.label} launch records for $id", e)
return@Thread
}
if (selected != null && com.droiddeck.launcher.stores.maintenance.StoreMaintenance.launchExcluded(
InstalledStoreGame(selected.second, selected.first))) {
Log.i(TAG, "launch blocked while ${store.label} maintenance is active: $id")
return@Thread
}
if (store == Store.EPIC) epicCode(app, id)
StoresState.post(onReady)
}, "stores-launch-prep").start()
}
@@ -104,9 +117,10 @@ object StoreLaunch {
fun epicCode(context: Context, id: String): CodeResult {
val app = context.applicationContext
val result = try {
val folder = StoreInstallRoot.gameFolders(app).firstOrNull { f -> StoreGameSidecar.read(f)?.let { it.store == Store.EPIC && it.id == id } == true }
val sidecar = folder?.let { StoreGameSidecar.read(it) }
sidecar?.let { refreshLauncher(folder, it) }
val selected = preferredInstall(app, Store.EPIC, id)
val folder = selected?.first
val sidecar = selected?.second
selected?.let { (installedFolder, installedSidecar) -> refreshLauncher(installedFolder, installedSidecar) }
val options = sidecar?.epic
if (folder == null) CodeResult(false, "not-installed")
else if (options != null && !options.wantsCode) {
@@ -135,7 +149,7 @@ object StoreLaunch {
fun prepare(context: Context, game: Library.SteamGame, onReady: () -> Unit) {
val store = Store.byId(game.source)
val id = game.storeId
if (store == null || id == null) { onReady(); return }
if (store == null || id == null) { StoresState.post(onReady); return }
prepare(context, store, id, onReady)
}
}
@@ -91,18 +91,19 @@ internal object AmazonMaintenanceAdapter : StoreMaintenanceAdapter {
)
}
override fun enqueue(context: Context, game: InstalledStoreGame, proposal: MaintenanceProposal) {
override fun enqueue(context: Context, game: InstalledStoreGame, proposal: MaintenanceProposal): Boolean {
check(proposal.operation == MaintenanceAction.UPDATE || proposal.operation == MaintenanceAction.REPAIR)
check(!DownloadQueue.isActive(game.key)) { "Amazon maintenance is already queued" }
val target = validateTarget(game, proposal.target)
enqueueJob(context, game, target, proposal.downloadBytes, proposal.operation)
return enqueueJob(context, game, target, proposal.downloadBytes, proposal.operation)
}
override fun resume(context: Context, game: InstalledStoreGame, operation: JSONObject) {
require(operation.optString("phase") in setOf("queued", "download", "applying")) { "The Amazon operation needs attention before it can resume" }
val target = validateTarget(game, operation.getJSONObject("target"))
val bytes = operation.optLong("downloadBytes", 0L).coerceAtLeast(0L)
enqueueJob(context, game, target, bytes, MaintenanceAction.valueOf(operation.getString("operation")))
check(enqueueJob(context, game, target, bytes, MaintenanceAction.valueOf(operation.getString("operation")))) {
"Amazon maintenance is already queued"
}
}
override fun recovery(context: Context, game: InstalledStoreGame): MaintenanceRecovery {
@@ -126,15 +127,16 @@ internal object AmazonMaintenanceAdapter : StoreMaintenanceAdapter {
return JSONObject(target.toString()).put("manifestPath", path.absolutePath)
}
private fun enqueueJob(context: Context, game: InstalledStoreGame, target: JSONObject, bytes: Long, action: MaintenanceAction) {
private fun enqueueJob(context: Context, game: InstalledStoreGame, target: JSONObject, bytes: Long, action: MaintenanceAction): Boolean {
val app = context.applicationContext
val sidecar = game.sidecar
val item = CatalogItem(Store.AMAZON, sidecar.id, sidecar.title, sidecar.hero, tallImageUrl = sidecar.cover,
extra = mapOf("entitlementId" to sidecar.extra["entitlementId"].orEmpty(), "sku" to sidecar.extra["sku"].orEmpty(),
StoreMaintenance.OPERATION_KEY to action.name, StoreMaintenance.FOLDER_KEY to game.folder.absolutePath))
SavedQueue.remember(app, item, null)
DownloadQueue.enqueue(app, DownloadEntry(Store.AMAZON, item.id, item.title, cover = item.tallImageUrl,
bytesTotal = bytes, location = StoreInstallRoot.labelFor(app, game.folder))) {
return DownloadQueue.enqueueIfIdle(app, DownloadEntry(Store.AMAZON, item.id, item.title, cover = item.tallImageUrl,
bytesTotal = bytes, location = StoreInstallRoot.labelFor(app, game.folder)), onAccepted = {
SavedQueue.remember(app, item, null)
}) {
AmazonInstaller(app, item, game.folder, target, game)
}
}
@@ -21,7 +21,6 @@ import com.droiddeck.launcher.stores.download.DownloadQueue
import com.droiddeck.launcher.stores.download.DownloadStage
import com.droiddeck.launcher.stores.maintenance.MaintenanceCheck
import com.droiddeck.launcher.stores.maintenance.MaintenanceCheckState
import com.droiddeck.launcher.stores.maintenance.StoreMaintenance
import org.json.JSONObject
import java.io.File
import java.net.HttpURLConnection
@@ -106,13 +105,6 @@ object BattleNetInstall {
DownloadQueue.enqueue(app, entry) { BattleNetInstaller(app, item, folder, update) }
}
fun enqueueMaintenance(context: Context, item: CatalogItem, game: InstalledStoreGame) {
val app = context.applicationContext
val entry = DownloadEntry(Store.BATTLENET, item.id, item.title, cover = item.tallImageUrl ?: item.imageUrl,
location = StoreInstallRoot.labelFor(app, game.folder))
DownloadQueue.enqueue(app, entry) { BattleNetInstaller(app, item, game.folder, update = true, maintenanceGame = game) }
}
fun update(context: Context, game: InstalledStoreGame) {
val app = context.applicationContext
Thread({
@@ -261,28 +253,23 @@ internal class BattleNetInstaller(
private val item: CatalogItem,
private val folder: File,
private val update: Boolean,
private val maintenanceGame: InstalledStoreGame? = null,
) : DownloadQueue.DownloadJob {
private val cancelled = AtomicBoolean(false)
private val started = AtomicBoolean(false)
private val ended = java.util.concurrent.CountDownLatch(1)
private var before = 0L
private var kept = -1L
@Volatile private var applyLease: AutoCloseable? = null
override fun run(handle: DownloadQueue.JobHandle): String? {
started.set(true)
try {
return if (cancelled.get()) null else work(handle)
} finally {
runCatching { applyLease?.close() }
applyLease = null
ended.countDown()
}
}
private fun work(handle: DownloadQueue.JobHandle): String? {
val maintenance = maintenanceGame
before = DownloadQueue.entry(handle.key)?.bytesTotal ?: 0L
handle.stage(DownloadStage.MANIFEST, app.getString(R.string.bnet_dl_starting))
if (!BattleNet.installed(app)) throw IllegalStateException(app.getString(R.string.bnet_dl_client_first))
@@ -296,17 +283,10 @@ internal class BattleNetInstaller(
else listOf("agent-install", uid, product, guest, BattleNetInstall.localeTag(app), country, region)
var done: JSONObject? = null
var reason: String? = null
var leaseFailure: String? = null
val code = BattleNetInstall.run(app, args, cancelled) { line ->
when {
line.startsWith(BattleNetInstall.PROGRESS) -> runCatching { JSONObject(line.removePrefix(BattleNetInstall.PROGRESS)) }.getOrNull()?.let {
progress(handle, it)
if (maintenance != null && applyLease == null &&
BattleNetInstall.step(it.optInt("state", 0), it.optInt("phase", 0), it.optLong("download_current", 0L), it.optLong("download_total", 0L)) == BattleNetInstall.Step.APPLYING) {
runCatching { StoreMaintenance.beginApplyLease(maintenance) }
.onSuccess { applyLease = it }
.onFailure { leaseFailure = it.message ?: "Close the game before maintenance"; cancelled.set(true) }
}
}
line == BattleNetInstall.STATE + "waiting" -> handle.stage(DownloadStage.MANIFEST, app.getString(R.string.bnet_dl_waiting))
line == BattleNetInstall.STATE + "queued" -> handle.stage(DownloadStage.MANIFEST, app.getString(R.string.bnet_dl_queued))
@@ -315,16 +295,9 @@ internal class BattleNetInstaller(
line.startsWith(BattleNetInstall.ERROR) -> reason = line.removePrefix(BattleNetInstall.ERROR)
}
}
leaseFailure?.let { throw IllegalStateException(it) }
if (cancelled.get()) return null
if (done == null) throw IllegalStateException(BattleNetInstall.failure(app, reason, code))
handle.log("Battle.net: ${item.title} ${if (update) "updated" else "installed"} ${done?.optString("version").orEmpty()}".trimEnd())
if (maintenance != null) {
if (applyLease == null) applyLease = StoreMaintenance.beginApplyLease(maintenance)
val installedBuild = runCatching { BattleNetInstall.buildKey(File(folder, ".build.info").readText())?.second }.getOrNull()
?: throw IllegalStateException("Battle.net completed, but the installed build could not be verified")
StoreMaintenance.complete(app, maintenance, installedBuild)
}
StoresState.post {
BattleNetInstall.updates.remove(item.id)
StoresState.notifyLibraryChanged(app)
@@ -1,87 +1,40 @@
package com.droiddeck.launcher.stores.battlenet
import android.content.Context
import com.droiddeck.launcher.stores.CatalogItem
import com.droiddeck.launcher.stores.InstalledStoreGame
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.download.SavedQueue
import com.droiddeck.launcher.stores.maintenance.MaintenanceAction
import com.droiddeck.launcher.stores.maintenance.MaintenanceCheck
import com.droiddeck.launcher.stores.maintenance.MaintenanceCheckState
import com.droiddeck.launcher.stores.maintenance.MaintenanceProposal
import com.droiddeck.launcher.stores.maintenance.MaintenanceRecovery
import com.droiddeck.launcher.stores.maintenance.RecoveryState
import com.droiddeck.launcher.stores.maintenance.StoreMaintenance
import com.droiddeck.launcher.stores.maintenance.StoreMaintenanceAdapter
import org.json.JSONObject
import java.io.File
internal object BattleNetMaintenanceAdapter : StoreMaintenanceAdapter {
override val store = Store.BATTLENET
private const val TARGET_SCHEMA = 1
override fun capabilities(game: InstalledStoreGame): Set<MaintenanceAction> = buildSet {
if (BattleNetInstall.product(game.sidecar.id) != null) add(MaintenanceAction.UPDATE)
add(MaintenanceAction.OPEN_CLIENT)
}
override fun capabilities(game: InstalledStoreGame): Set<MaintenanceAction> = setOf(MaintenanceAction.OPEN_CLIENT)
override fun check(context: Context, game: InstalledStoreGame): MaintenanceCheck =
BattleNetInstall.maintenanceCheck(context, game)
override fun prepare(context: Context, game: InstalledStoreGame, action: MaintenanceAction): MaintenanceProposal {
require(action == MaintenanceAction.UPDATE) { "Battle.net maintenance is delegated to Agent and its client" }
val check = BattleNetInstall.maintenanceCheck(context, game)
require(check.state != MaintenanceCheckState.CURRENT) { "Battle.net reports this installed build is current" }
val local = BattleNetInstall.buildKey(File(game.folder, ".build.info").readText())
?: error("The installed Battle.net build identity is unknown")
val target = JSONObject().put("schema", TARGET_SCHEMA).put("installUid", BattleNetInstall.installUid(context, game))
.put("region", local.first)
.put("installedBuild", local.second)
.put("targetBuild", check.version)
require(target.optString("installUid").isNotBlank() && target.optString("region").isNotBlank()) {
"Battle.net install identity is incomplete"
}
return MaintenanceProposal(game.folder.canonicalFile, game.sidecar, check.version.ifBlank { "latest Battle.net build" }, action,
description = if (check.state == MaintenanceCheckState.AVAILABLE) "Battle.net Agent applies the selected regional update in its existing client environment."
else "The target build could not be checked; Battle.net Agent selects the current regional build.", target = target)
}
override fun prepare(context: Context, game: InstalledStoreGame, action: MaintenanceAction): MaintenanceProposal =
error("Battle.net updates must use its Agent/client workflow")
override fun enqueue(context: Context, game: InstalledStoreGame, proposal: MaintenanceProposal) {
require(proposal.operation == MaintenanceAction.UPDATE)
enqueuePinned(context, game, proposal.target)
}
override fun enqueue(context: Context, game: InstalledStoreGame, proposal: MaintenanceProposal): Boolean =
error("Battle.net updates must use its Agent/client workflow")
override fun resume(context: Context, game: InstalledStoreGame, operation: JSONObject) {
val target = operation.optJSONObject("target") ?: error("Battle.net maintenance target is missing")
require(operation.optString("phase") in setOf("queued", "download", "applying"))
enqueuePinned(context, game, target)
}
override fun resume(context: Context, game: InstalledStoreGame, operation: JSONObject): Unit =
error("Resume this Battle.net operation through its Agent/client workflow")
override fun recovery(context: Context, game: InstalledStoreGame): MaintenanceRecovery {
val operation = StoreMaintenance.readOperation(game.folder) ?: return MaintenanceRecovery()
return when (operation.optString("phase")) {
"queued", "download", "applying" -> MaintenanceRecovery(RecoveryState.RESUMABLE, "Resume with Battle.net Agent")
else -> MaintenanceRecovery(RecoveryState.NEEDS_ATTENTION, "Inspect the Battle.net client before resuming")
}
if (StoreMaintenance.readOperation(game.folder) == null) return MaintenanceRecovery()
return MaintenanceRecovery(RecoveryState.NEEDS_ATTENTION, "Inspect this update in Battle.net before launching the game")
}
override fun openClient(context: Context, game: InstalledStoreGame) = BattleNetClient.open(emptyList())
override fun openClientLabel(game: InstalledStoreGame) = "Open Battle.net for Scan and Repair"
private fun enqueuePinned(context: Context, game: InstalledStoreGame, target: JSONObject) {
require(target.optInt("schema") == TARGET_SCHEMA && target.optString("installUid").isNotBlank()) {
"The saved Battle.net target is unsupported"
}
require(target.optString("region").matches(Regex("[a-z]{2}")) && target.optString("installedBuild").matches(Regex("[0-9a-f]{32}"))) {
"The saved Battle.net build identity is invalid"
}
val item = CatalogItem(Store.BATTLENET, game.sidecar.id, game.sidecar.title, game.sidecar.hero,
tallImageUrl = game.sidecar.cover, owned = true,
extra = game.sidecar.extra + mapOf(BattleNetInstall.INSTALL_UID to target.getString("installUid"),
StoreMaintenance.OPERATION_KEY to MaintenanceAction.UPDATE.name, StoreMaintenance.FOLDER_KEY to game.folder.canonicalPath))
val root = game.folder.parentFile?.parentFile ?: error("Battle.net install root is missing")
SavedQueue.remember(context.applicationContext, item, root)
BattleNetInstall.enqueueMaintenance(context, item, game)
}
}
@@ -129,9 +129,32 @@ object DownloadQueue {
fun isActive(key: String): Boolean = entry(key)?.isActive == true
fun enqueue(context: Context, entry: DownloadEntry, factory: () -> DownloadJob) {
enqueueInternal(context, entry, onAccepted = {}, factory = factory)
}
/** Enqueues only if the key is idle, running [onAccepted] atomically before the accepted row is saved. */
fun enqueueIfIdle(
context: Context,
entry: DownloadEntry,
onAccepted: () -> Unit,
factory: () -> DownloadJob,
): Boolean = enqueueInternal(context, entry, onAccepted = onAccepted, factory = factory)
private fun enqueueInternal(
context: Context,
entry: DownloadEntry,
onAccepted: () -> Unit,
factory: () -> DownloadJob,
): Boolean {
val app = context.applicationContext
synchronized(lock) {
items[entry.key]?.let { if (it.entry.isActive) { Log.i(TAG, "${entry.key}: already queued"); return } }
items[entry.key]?.let {
if (it.entry.isActive) {
Log.i(TAG, "${entry.key}: already queued")
return false
}
}
onAccepted()
items.remove(entry.key)
val state = if (heldPaused.remove(entry.key)) DownloadState.PAUSED else DownloadState.QUEUED
items[entry.key] = Item(entry.copy(state = state, stage = DownloadStage.MANIFEST, startedAt = System.currentTimeMillis()), factory)
@@ -140,6 +163,7 @@ object DownloadQueue {
StoresState.logLine("${entry.store.label}: queued \"${entry.name}\"" + if (entry.bytesTotal > 0) " (${com.droiddeck.launcher.stores.formatBytes(entry.bytesTotal)})" else "")
publish(app)
advance(app)
return true
}
/** Immediately records a failed download (e.g. from pre-flight storage check). */
@@ -103,18 +103,17 @@ internal object EpicMaintenanceAdapter : StoreMaintenanceAdapter {
)
}
override fun enqueue(context: Context, game: InstalledStoreGame, proposal: MaintenanceProposal) {
override fun enqueue(context: Context, game: InstalledStoreGame, proposal: MaintenanceProposal): Boolean {
check(proposal.operation == MaintenanceAction.UPDATE || proposal.operation == MaintenanceAction.REPAIR)
check(!DownloadQueue.isActive(game.key)) { "Epic maintenance is already queued" }
val target = validateTarget(game, proposal.target)
enqueueJob(context, game, target, proposal.downloadBytes, proposal.operation)
return enqueueJob(context, game, target, proposal.downloadBytes, proposal.operation)
}
override fun resume(context: Context, game: InstalledStoreGame, operation: JSONObject) {
require(operation.optString("phase") in setOf("queued", "download", "applying")) { "The Epic operation needs attention before it can resume" }
val target = validateTarget(game, operation.getJSONObject("target"))
enqueueJob(context, game, target, operation.optLong("downloadBytes", 0L).coerceAtLeast(0L),
MaintenanceAction.valueOf(operation.getString("operation")))
check(enqueueJob(context, game, target, operation.optLong("downloadBytes", 0L).coerceAtLeast(0L),
MaintenanceAction.valueOf(operation.getString("operation")))) { "Epic maintenance is already queued" }
}
override fun recovery(context: Context, game: InstalledStoreGame): MaintenanceRecovery {
@@ -155,15 +154,16 @@ internal object EpicMaintenanceAdapter : StoreMaintenanceAdapter {
EpicInstallTags.tagsFor(StoreLanguage.localeFor(game.sidecar.extra[StoreLanguage.EXTRA], context))
}
private fun enqueueJob(context: Context, game: InstalledStoreGame, target: JSONObject, bytes: Long, action: MaintenanceAction) {
private fun enqueueJob(context: Context, game: InstalledStoreGame, target: JSONObject, bytes: Long, action: MaintenanceAction): Boolean {
val app = context.applicationContext
val sidecar = game.sidecar
val extra = LinkedHashMap(sidecar.extra)
val item = CatalogItem(Store.EPIC, sidecar.id, sidecar.title, sidecar.hero, tallImageUrl = sidecar.cover,
extra = extra + mapOf(StoreMaintenance.OPERATION_KEY to action.name, StoreMaintenance.FOLDER_KEY to game.folder.absolutePath))
SavedQueue.remember(app, item, null)
DownloadQueue.enqueue(app, DownloadEntry(Store.EPIC, item.id, item.title, cover = item.tallImageUrl,
bytesTotal = bytes, location = StoreInstallRoot.labelFor(app, game.folder))) {
return DownloadQueue.enqueueIfIdle(app, DownloadEntry(Store.EPIC, item.id, item.title, cover = item.tallImageUrl,
bytesTotal = bytes, location = StoreInstallRoot.labelFor(app, game.folder)), onAccepted = {
SavedQueue.remember(app, item, null)
}) {
EpicInstaller(app, item, game.folder, target, game)
}
}
@@ -91,11 +91,12 @@ internal object ItchMaintenanceAdapter : StoreMaintenanceAdapter {
"Downloads one pinned archive. Extracted size and required free space are unknown until download; DroidDeck checks the measured extracted payload before replacing files.", target)
}
override fun enqueue(context: Context, game: InstalledStoreGame, proposal: MaintenanceProposal) = enqueuePinned(context, game, proposal.target)
override fun enqueue(context: Context, game: InstalledStoreGame, proposal: MaintenanceProposal): Boolean =
enqueuePinned(context, game, proposal.target)
override fun resume(context: Context, game: InstalledStoreGame, operation: JSONObject) {
val target = operation.optJSONObject("target") ?: throw IllegalStateException("itch.io maintenance target is missing")
enqueuePinned(context, game, target)
check(enqueuePinned(context, game, target)) { "itch.io maintenance is already queued" }
}
override fun recovery(context: Context, game: InstalledStoreGame): MaintenanceRecovery {
@@ -106,7 +107,7 @@ internal object ItchMaintenanceAdapter : StoreMaintenanceAdapter {
else MaintenanceRecovery()
}
private fun enqueuePinned(context: Context, game: InstalledStoreGame, target: JSONObject) {
private fun enqueuePinned(context: Context, game: InstalledStoreGame, target: JSONObject): Boolean {
val item = CatalogItem(
Store.ITCH, game.sidecar.id, game.sidecar.title, game.sidecar.hero,
tallImageUrl = game.sidecar.cover, isFree = game.sidecar.extra["downloadKeyId"].isNullOrBlank(), owned = true,
@@ -114,10 +115,11 @@ internal object ItchMaintenanceAdapter : StoreMaintenanceAdapter {
StoreMaintenance.OPERATION_KEY to (StoreMaintenance.readOperation(game.folder)?.optString("operation") ?: MaintenanceAction.REFRESH.name),
StoreMaintenance.FOLDER_KEY to game.folder.absolutePath),
)
SavedQueue.remember(context.applicationContext, item, null)
val entry = DownloadEntry(Store.ITCH, game.sidecar.id, game.sidecar.title,
bytesTotal = target.optLong("size"), location = StoreInstallRoot.labelFor(context, game.folder))
DownloadQueue.enqueue(context, entry) { ItchInstaller(context.applicationContext, item, game.folder, target) }
return DownloadQueue.enqueueIfIdle(context.applicationContext, entry, onAccepted = {
SavedQueue.remember(context.applicationContext, item, null)
}) { ItchInstaller(context.applicationContext, item, game.folder, target) }
}
private fun targetJson(game: InstalledStoreGame, upload: JSONObject): JSONObject = JSONObject()
@@ -53,7 +53,7 @@ interface StoreMaintenanceAdapter {
fun capabilities(game: InstalledStoreGame): Set<MaintenanceAction>
fun check(context: Context, game: InstalledStoreGame): MaintenanceCheck
fun prepare(context: Context, game: InstalledStoreGame, action: MaintenanceAction): MaintenanceProposal
fun enqueue(context: Context, game: InstalledStoreGame, proposal: MaintenanceProposal)
fun enqueue(context: Context, game: InstalledStoreGame, proposal: MaintenanceProposal): Boolean
fun resume(context: Context, game: InstalledStoreGame, operation: JSONObject)
fun recovery(context: Context, game: InstalledStoreGame): MaintenanceRecovery = MaintenanceRecovery()
fun openClient(context: Context, game: InstalledStoreGame) = Unit
@@ -71,6 +71,7 @@ object StoreMaintenance {
private val inFlight = ConcurrentHashMap<String, Long>()
private val revisions = ConcurrentHashMap<Store, Long>()
private val accounts = ConcurrentHashMap<Store, String>()
private val operationLocks = ConcurrentHashMap<String, Any>()
val checks = mutableStateMapOf<String, MaintenanceCheck>()
fun register(adapter: StoreMaintenanceAdapter) {
@@ -129,8 +130,25 @@ object StoreMaintenance {
require(game.folder.canonicalFile == proposal.folder.canonicalFile)
require(proposal.operation != MaintenanceAction.OPEN_CLIENT && proposal.operation in capabilities(game))
val adapter = adapters[game.sidecar.store] ?: error("${game.sidecar.store.label} maintenance is unavailable")
writeOperation(proposal, game)
adapter.enqueue(context.applicationContext, game, proposal)
val folder = game.folder.canonicalFile
synchronized(operationLock(folder)) {
require(!com.droiddeck.launcher.stores.download.DownloadQueue.isActive(game.key)) {
"${game.sidecar.store.label} already has an active operation"
}
val enqueueId = java.util.UUID.randomUUID().toString()
writeOperation(proposal, game, enqueueId)
val accepted = try {
adapter.enqueue(context.applicationContext, game, proposal)
} catch (error: Throwable) {
runCatching { clearNewQueuedOperation(game, enqueueId, preserveActive = true) }
.onFailure(error::addSuppressed)
throw error
}
if (!accepted) {
clearNewQueuedOperation(game, enqueueId, preserveActive = false)
error("${game.sidecar.store.label} already has an active operation")
}
}
}
fun resume(context: Context, game: InstalledStoreGame) {
@@ -192,6 +210,10 @@ object StoreMaintenance {
fun beginApplyLease(game: InstalledStoreGame): AutoCloseable {
val folder = game.folder.canonicalFile
synchronized(operationLock(folder)) { return beginApplyLeaseLocked(game, folder) }
}
private fun beginApplyLeaseLocked(game: InstalledStoreGame, folder: File): AutoCloseable {
require(folder == game.folder.absoluteFile.canonicalFile)
val operation = readOperation(folder) ?: error("${game.sidecar.store.label} operation journal is missing")
require(operation.optString("folder") == folder.path && operation.optString("store") == game.sidecar.store.id && operation.optString("id") == game.sidecar.id)
@@ -219,6 +241,20 @@ object StoreMaintenance {
return AutoCloseable { lock.close(); lease.close() }
}
private fun operationLock(folder: File): Any = operationLocks.computeIfAbsent(folder.path) { Any() }
private fun clearNewQueuedOperation(game: InstalledStoreGame, enqueueId: String, preserveActive: Boolean) {
val folder = game.folder.canonicalFile
if (preserveActive && com.droiddeck.launcher.stores.download.DownloadQueue.isActive(game.key)) return
val operation = readOperation(folder) ?: return
if (operation.optString("enqueueId") != enqueueId || operation.optString("phase") != "queued" ||
operation.optString("folder") != folder.path || operation.optString("store") != game.sidecar.store.id ||
operation.optString("id") != game.sidecar.id) return
val file = operationFile(folder)
require(!Files.isSymbolicLink(file.toPath())) { "Maintenance journal is a symbolic link" }
if (file.isFile) check(file.delete()) { "Could not clear the failed queued maintenance operation" }
}
fun complete(
context: Context,
game: InstalledStoreGame,
@@ -238,7 +274,7 @@ object StoreMaintenance {
StoresState.post { checks.remove(game.key) }
}
private fun writeOperation(proposal: MaintenanceProposal, game: InstalledStoreGame) {
private fun writeOperation(proposal: MaintenanceProposal, game: InstalledStoreGame, enqueueId: String) {
val folder = proposal.folder.canonicalFile
require(proposal.before.store == game.sidecar.store && proposal.before.id == game.sidecar.id)
val directory = File(folder, ".droiddeck-maintenance")
@@ -248,7 +284,7 @@ object StoreMaintenance {
require(!Files.isSymbolicLink(file.toPath())) { "Maintenance journal is a symbolic link" }
require(!file.exists()) { "Resume or recover the existing ${game.sidecar.store.label} operation first" }
val value = JSONObject().put("schema", 1).put("store", game.sidecar.store.id).put("id", game.sidecar.id)
.put("folder", folder.path).put("operation", proposal.operation.name).put("phase", "queued")
.put("folder", folder.path).put("enqueueId", enqueueId).put("operation", proposal.operation.name).put("phase", "queued")
.put("before", proposal.before.toJson()).put("targetLabel", proposal.targetLabel)
.put("downloadBytes", proposal.downloadBytes).put("diskBytes", proposal.diskBytes)
.put("description", proposal.description).put("target", proposal.target)
@@ -191,17 +191,18 @@ object XboxBackend : StoreBackend {
downloadBytes = size, diskBytes = size, description = "Xodus refreshes the installed package through its licensed streaming path.", target = target)
}
internal fun enqueueMaintenance(context: Context, game: InstalledStoreGame, proposal: MaintenanceProposal) {
internal fun enqueueMaintenance(context: Context, game: InstalledStoreGame, proposal: MaintenanceProposal): Boolean {
val sidecar = game.sidecar
val item = CatalogItem(store, sidecar.id, sidecar.title, sidecar.hero, tallImageUrl = sidecar.cover,
owned = sidecar.extra["access"] == "owned", subscription = sidecar.extra["access"] == "gamepass",
extra = sidecar.extra + mapOf("contentId" to proposal.target.getString("contentId"), "market" to proposal.target.getString("market"),
StoreMaintenance.OPERATION_KEY to MaintenanceAction.UPDATE.name, StoreMaintenance.FOLDER_KEY to game.folder.canonicalPath))
val root = game.folder.parentFile?.parentFile ?: error("Xbox install root is missing")
com.droiddeck.launcher.stores.download.SavedQueue.remember(context, item, root)
val entry = DownloadEntry(store, sidecar.id, sidecar.title, cover = sidecar.cover,
bytesTotal = proposal.downloadBytes, location = StoreInstallRoot.labelFor(context, game.folder))
DownloadQueue.enqueue(context.applicationContext, entry) { XboxInstaller(context.applicationContext, item, game.folder, game) }
return DownloadQueue.enqueueIfIdle(context.applicationContext, entry, onAccepted = {
com.droiddeck.launcher.stores.download.SavedQueue.remember(context, item, root)
}) { XboxInstaller(context.applicationContext, item, game.folder, game) }
}
private fun enqueue(context: Context, item: CatalogItem, root: File) {
@@ -26,9 +26,9 @@ internal object XboxMaintenanceAdapter : StoreMaintenanceAdapter {
return XboxBackend.prepareMaintenance(context, game)
}
override fun enqueue(context: Context, game: InstalledStoreGame, proposal: MaintenanceProposal) {
override fun enqueue(context: Context, game: InstalledStoreGame, proposal: MaintenanceProposal): Boolean {
require(proposal.operation == MaintenanceAction.UPDATE && proposal.target.optInt("schema") == 1)
XboxBackend.enqueueMaintenance(context, game, proposal)
return XboxBackend.enqueueMaintenance(context, game, proposal)
}
override fun resume(context: Context, game: InstalledStoreGame, operation: JSONObject) {
@@ -40,7 +40,7 @@ internal object XboxMaintenanceAdapter : StoreMaintenanceAdapter {
?: error("The original Xbox settings are missing"), operation.optString("targetLabel"), MaintenanceAction.UPDATE,
operation.optLong("downloadBytes", MaintenanceProposal.UNKNOWN_BYTES),
operation.optLong("diskBytes", MaintenanceProposal.UNKNOWN_BYTES), operation.optString("description"), JSONObject(target.toString()))
XboxBackend.enqueueMaintenance(context, game, proposal)
check(XboxBackend.enqueueMaintenance(context, game, proposal)) { "Xbox maintenance is already queued" }
}
override fun recovery(context: Context, game: InstalledStoreGame): MaintenanceRecovery {
@@ -129,7 +129,10 @@ internal fun StoreMaintenanceActions(game: InstalledStoreGame) {
when (current.state) {
MaintenanceCheckState.CHECKING -> Text("Checking for updates…", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
MaintenanceCheckState.CURRENT -> Text("Up to date", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
MaintenanceCheckState.AVAILABLE -> Unit
MaintenanceCheckState.AVAILABLE -> if (MaintenanceAction.UPDATE !in capabilities) {
Text("An update is available. Use ${adapter.openClientLabel(game)} to apply it.",
fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
MaintenanceCheckState.UNKNOWN, MaintenanceCheckState.FAILED -> if (current.reason.isNotBlank())
Text(current.reason, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
@@ -1,6 +1,7 @@
package com.droiddeck.launcher.stores
import android.app.Application
import android.os.Looper
import com.droiddeck.launcher.frontend.AddedGames
import com.droiddeck.launcher.session.SessionPrefs
import org.junit.Assert.*
@@ -10,6 +11,9 @@ import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import java.io.File
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33], application = Application::class)
@@ -18,9 +22,12 @@ class StoreLaunchTest {
private fun install(state: String = StoreGameSidecar.STATE_INSTALLED): File {
SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "")
val folder = File(StoreInstallRoot.storeDir(StoreInstallRoot.installRoot(app), Store.XBOX), "Deep Rock Galactic")
val storeDir = StoreInstallRoot.storeDir(StoreInstallRoot.installRoot(app), Store.XBOX)
storeDir.listFiles()?.filter { StoreGameSidecar.read(it)?.id == "9NHFVWX1V7QJ" }?.forEach { it.deleteRecursively() }
val folder = File(storeDir, "Deep Rock Galactic")
File(folder, "FSD/Binaries/WinGDK/FSD.exe").apply { parentFile!!.mkdirs(); writeText("licensed image") }
StoreGameSidecar(Store.XBOX, "9NHFVWX1V7QJ", "Deep Rock Galactic", "FSD/Binaries/WinGDK/FSD.exe", state = state).write(folder)
SessionPrefs.setAddedGameRemoved(app, folder.path, false)
return folder
}
@@ -53,4 +60,46 @@ class StoreLaunchTest {
File(folder, "FSD/Binaries/WinGDK/FSD.exe").delete()
assertNull(StoreLaunch.installedGame(app, Store.XBOX, "9NHFVWX1V7QJ"))
}
@Test fun installedTargetWinsOverAnEarlierUpdatingDuplicate() {
val updating = install(StoreGameSidecar.STATE_UPDATING)
val installed = File(updating.parentFile, "ZZZ installed copy")
File(installed, "FSD/Binaries/WinGDK/FSD.exe").apply { parentFile!!.mkdirs(); writeText("installed image") }
StoreGameSidecar(Store.XBOX, "9NHFVWX1V7QJ", "Deep Rock Galactic", "FSD/Binaries/WinGDK/FSD.exe").write(installed)
try {
assertEquals(installed, StoreLaunch.findInstalled(app, Store.XBOX, "9NHFVWX1V7QJ")!!.first)
} finally {
installed.deleteRecursively()
}
}
@Test fun prepareScansOffTheCallerAndPostsReadyBackToMainLooper() {
val updating = install(StoreGameSidecar.STATE_UPDATING)
val installed = File(updating.parentFile, "ZZZ installed copy")
File(installed, "FSD/Binaries/WinGDK/FSD.exe").apply { parentFile!!.mkdirs(); writeText("installed image") }
StoreGameSidecar(Store.XBOX, "9NHFVWX1V7QJ", "Deep Rock Galactic", "FSD/Binaries/WinGDK/FSD.exe").write(installed)
val callbackRan = AtomicBoolean(false)
val callbackOnMain = AtomicBoolean(false)
val callback = CountDownLatch(1)
try {
StoreLaunch.prepare(app, Store.XBOX, "9NHFVWX1V7QJ") {
callbackOnMain.set(Looper.myLooper() == Looper.getMainLooper())
callbackRan.set(true)
callback.countDown()
}
assertFalse("ready callback must not run inline on the caller", callbackRan.get())
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5)
while (callback.count > 0 && System.nanoTime() < deadline) {
org.robolectric.shadows.ShadowLooper.idleMainLooper()
Thread.sleep(10)
}
assertTrue("ready callback was not posted", callback.await(1, TimeUnit.SECONDS))
assertTrue("ready callback must run on the main looper", callbackOnMain.get())
} finally {
installed.deleteRecursively()
}
}
}
@@ -0,0 +1,18 @@
package com.droiddeck.launcher.stores.battlenet
import com.droiddeck.launcher.stores.InstalledStoreGame
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreGameSidecar
import com.droiddeck.launcher.stores.maintenance.MaintenanceAction
import org.junit.Assert.assertEquals
import org.junit.Test
import java.io.File
class BattleNetMaintenanceTest {
@Test fun agentManagedGamesOfferOnlyClientHandoffThroughSharedMaintenance() {
val folder = File("/tmp/battlenet-agent-game")
val game = InstalledStoreGame(StoreGameSidecar(Store.BATTLENET, "wow", "World of Warcraft", ""), folder)
assertEquals(setOf(MaintenanceAction.OPEN_CLIENT), BattleNetMaintenanceAdapter.capabilities(game))
}
}
@@ -7,6 +7,7 @@ import java.io.File
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicBoolean
import org.junit.After
import org.junit.Assert.*
import org.junit.Test
@@ -137,4 +138,40 @@ class DownloadQueueRetryTest {
assertTrue(ran.await(5, TimeUnit.SECONDS))
awaitFailure(entry.key, "restored")
}
@Test fun maintenanceAdmissionRejectsDuplicateWithoutReplacingSavedQueueItem() {
val id = "maintenance-admission-${java.util.UUID.randomUUID()}"
val first = CatalogItem(Store.EPIC, id, "First update", imageUrl = null, owned = true)
val second = CatalogItem(Store.EPIC, id, "Other operation", imageUrl = null, owned = true)
val entry = DownloadEntry(Store.EPIC, id, first.title)
val firstFactory = { object : DownloadQueue.DownloadJob {
override fun run(handle: DownloadQueue.JobHandle): String? = null
override fun cancel(deleteFiles: Boolean) = Unit
} }
DownloadQueue.holdPaused(entry.key)
val firstAccepted = DownloadQueue.enqueueIfIdle(app, entry,
onAccepted = { SavedQueue.remember(app, first, null) }, factory = firstFactory)
val duplicateRemembered = AtomicBoolean(false)
val secondAccepted = DownloadQueue.enqueueIfIdle(app, entry.copy(name = second.title),
onAccepted = { duplicateRemembered.set(true); SavedQueue.remember(app, second, null) }, factory = firstFactory)
assertTrue(firstAccepted)
assertFalse(secondAccepted)
assertFalse(duplicateRemembered.get())
assertEquals(DownloadState.PAUSED, DownloadQueue.entry(entry.key)?.state)
val saved = File(app.filesDir, "store-queue.json")
val until = System.nanoTime() + TimeUnit.SECONDS.toNanos(5)
var savedFirst = false
while (!savedFirst && System.nanoTime() < until) {
savedFirst = runCatching {
val rows = JSONArray(saved.readText())
(0 until rows.length()).any { i -> rows.getJSONObject(i).getJSONObject("item").getString("title") == first.title }
}.getOrDefault(false)
if (!savedFirst) Thread.sleep(10)
}
assertTrue("only the accepted operation's saved item should persist", savedFirst)
DownloadQueue.clear(app, entry.key)
}
}
@@ -4,9 +4,17 @@ import android.content.Context
import com.droiddeck.launcher.stores.InstalledStoreGame
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreGameSidecar
import com.droiddeck.launcher.stores.CatalogItem
import com.droiddeck.launcher.stores.download.DownloadEntry
import com.droiddeck.launcher.stores.download.DownloadQueue
import com.droiddeck.launcher.stores.download.DownloadState
import com.droiddeck.launcher.stores.download.SavedQueue
import org.json.JSONObject
import org.json.JSONArray
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
@@ -16,6 +24,7 @@ import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import java.io.File
import java.util.concurrent.TimeUnit
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
@@ -27,7 +36,7 @@ class StoreMaintenanceTest {
override fun capabilities(game: InstalledStoreGame) = setOf(MaintenanceAction.UPDATE)
override fun check(context: Context, game: InstalledStoreGame) = MaintenanceCheck(MaintenanceCheckState.CURRENT)
override fun prepare(context: Context, game: InstalledStoreGame, action: MaintenanceAction) = error("unused")
override fun enqueue(context: Context, game: InstalledStoreGame, proposal: MaintenanceProposal) = Unit
override fun enqueue(context: Context, game: InstalledStoreGame, proposal: MaintenanceProposal) = true
override fun resume(context: Context, game: InstalledStoreGame, operation: JSONObject) = Unit
}
@@ -90,6 +99,105 @@ class StoreMaintenanceTest {
assertEquals(RecoveryState.RESUMABLE, StoreMaintenance.recovery(RuntimeEnvironment.getApplication(), game).state)
}
@Test fun failedQueueSubmissionClearsOnlyItsNewQueuedJournalAndRethrowsOriginalError() {
val folder = temporary.newFolder().canonicalFile
val sidecar = StoreGameSidecar(Store.EPIC, "enqueue-failure", "Example", "bin/game.exe")
sidecar.write(folder)
val game = InstalledStoreGame(sidecar, folder)
val failure = IllegalStateException("queue rejected")
StoreMaintenance.register(object : StoreMaintenanceAdapter {
override val store = Store.EPIC
override fun capabilities(game: InstalledStoreGame) = setOf(MaintenanceAction.UPDATE)
override fun check(context: Context, game: InstalledStoreGame) = MaintenanceCheck()
override fun prepare(context: Context, game: InstalledStoreGame, action: MaintenanceAction) = error("unused")
override fun enqueue(context: Context, game: InstalledStoreGame, proposal: MaintenanceProposal): Boolean { throw failure }
override fun resume(context: Context, game: InstalledStoreGame, operation: JSONObject) = Unit
})
val proposal = MaintenanceProposal(folder, sidecar, "target", MaintenanceAction.UPDATE,
target = JSONObject().put("schema", 1).put("build", "target"))
val thrown = runCatching { StoreMaintenance.enqueue(RuntimeEnvironment.getApplication(), game, proposal) }.exceptionOrNull()
assertSame(failure, thrown)
assertNull(StoreMaintenance.readOperation(folder))
}
@Test fun failedQueueSubmissionPreservesJournalThatHasAdvancedFromQueued() {
val folder = temporary.newFolder().canonicalFile
val sidecar = StoreGameSidecar(Store.EPIC, "enqueue-advanced", "Example", "bin/game.exe")
sidecar.write(folder)
val game = InstalledStoreGame(sidecar, folder)
val failure = IllegalStateException("queue response lost")
StoreMaintenance.register(object : StoreMaintenanceAdapter {
override val store = Store.EPIC
override fun capabilities(game: InstalledStoreGame) = setOf(MaintenanceAction.UPDATE)
override fun check(context: Context, game: InstalledStoreGame) = MaintenanceCheck()
override fun prepare(context: Context, game: InstalledStoreGame, action: MaintenanceAction) = error("unused")
override fun enqueue(context: Context, game: InstalledStoreGame, proposal: MaintenanceProposal): Boolean {
val file = StoreMaintenance.operationFile(game.folder)
file.writeText(JSONObject(file.readText()).put("phase", "download").toString())
throw failure
}
override fun resume(context: Context, game: InstalledStoreGame, operation: JSONObject) = Unit
})
val proposal = MaintenanceProposal(folder, sidecar, "target", MaintenanceAction.UPDATE,
target = JSONObject().put("schema", 1).put("build", "target"))
val thrown = runCatching { StoreMaintenance.enqueue(RuntimeEnvironment.getApplication(), game, proposal) }.exceptionOrNull()
assertSame(failure, thrown)
assertEquals("download", StoreMaintenance.readOperation(folder)!!.getString("phase"))
}
@Test fun queueRaceRejectionClearsOnlyMaintenanceJournalAndKeepsExistingSavedJob() {
val app = RuntimeEnvironment.getApplication()
val folder = temporary.newFolder().canonicalFile
val sidecar = StoreGameSidecar(Store.EPIC, "queue-race", "Example", "bin/game.exe")
sidecar.write(folder)
val game = InstalledStoreGame(sidecar, folder)
val existing = CatalogItem(Store.EPIC, sidecar.id, "Existing job", imageUrl = null, owned = true)
val maintenance = CatalogItem(Store.EPIC, sidecar.id, "Maintenance job", imageUrl = null, owned = true)
val key = DownloadEntry(Store.EPIC, sidecar.id, existing.title).key
fun job() = object : DownloadQueue.DownloadJob {
override fun run(handle: DownloadQueue.JobHandle): String? = null
override fun cancel(deleteFiles: Boolean) = Unit
}
DownloadQueue.holdPaused(key)
StoreMaintenance.register(object : StoreMaintenanceAdapter {
override val store = Store.EPIC
override fun capabilities(game: InstalledStoreGame) = setOf(MaintenanceAction.UPDATE)
override fun check(context: Context, game: InstalledStoreGame) = MaintenanceCheck()
override fun prepare(context: Context, game: InstalledStoreGame, action: MaintenanceAction) = error("unused")
override fun enqueue(context: Context, game: InstalledStoreGame, proposal: MaintenanceProposal): Boolean {
SavedQueue.remember(context, existing, null)
DownloadQueue.enqueue(context, DownloadEntry(Store.EPIC, sidecar.id, existing.title), ::job)
return DownloadQueue.enqueueIfIdle(context, DownloadEntry(Store.EPIC, sidecar.id, maintenance.title), onAccepted = {
SavedQueue.remember(context, maintenance, null)
}, factory = ::job)
}
override fun resume(context: Context, game: InstalledStoreGame, operation: JSONObject) = Unit
})
val proposal = MaintenanceProposal(folder, sidecar, "target", MaintenanceAction.UPDATE,
target = JSONObject().put("schema", 1).put("build", "target"))
assertTrue(runCatching { StoreMaintenance.enqueue(app, game, proposal) }.isFailure)
assertNull(StoreMaintenance.readOperation(folder))
assertEquals(DownloadState.PAUSED, DownloadQueue.entry(key)?.state)
val saved = File(app.filesDir, "store-queue.json")
val until = System.nanoTime() + TimeUnit.SECONDS.toNanos(5)
var existingSaved = false
while (!existingSaved && System.nanoTime() < until) {
existingSaved = runCatching {
val rows = JSONArray(saved.readText())
(0 until rows.length()).any { i -> rows.getJSONObject(i).getJSONObject("item").getString("title") == existing.title }
}.getOrDefault(false)
if (!existingSaved) Thread.sleep(10)
}
assertTrue("rejected maintenance must not replace the active queue's saved item", existingSaved)
DownloadQueue.clear(app, key)
}
@Test fun applyLeaseIsExclusiveAndMarksTheJournalBeforeMutation() {
val folder = temporary.newFolder().canonicalFile
val sidecar = StoreGameSidecar(Store.EPIC, "app", "Example", "bin/game.exe")
+5 -4
View File
@@ -18,10 +18,11 @@ its existing engine or client can perform.
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).
- **Battle.net:** metadata checks and client handoff for Scan and Repair. Its
Agent-managed installs do not have the executable sidecar required by the
shared apply/restore flow, so updates remain in the existing Agent/Battle.net
workflow. The app does not claim client repair already ran. See [Blizzard's
Scan and Repair instructions](https://eu.support.blizzard.com/en/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