feat: add store maintenance

This commit is contained in:
Kurt Himebauch committed 2026-10-28 17:30:58 -04:00
1 parent aef1f85aa9
commit cd09deddba
46 files changed
+3830 -251

No files matched your search

@@ -83,8 +83,16 @@ object StoreLaunch {
}
fun prepare(context: Context, store: Store, id: String, onReady: () -> Unit) {
if (store != Store.EPIC) { onReady(); return }
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)
StoresState.post(onReady)
@@ -11,6 +11,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.stores.download.DownloadEntry
import com.droiddeck.launcher.stores.maintenance.StoreMaintenance
object StoresState {
private const val TAG = "Stores"
@@ -72,7 +73,10 @@ object StoresState {
fun backend(store: Store): StoreBackend? = backends[store]
fun register(backend: StoreBackend) { backends[backend.store] = backend }
fun register(backend: StoreBackend) {
backends[backend.store] = backend
backend.maintenanceAdapter()?.let(StoreMaintenance::register)
}
fun refresh(context: Context) {
val app = context.applicationContext
@@ -81,7 +85,7 @@ object StoresState {
runCatching { com.droiddeck.launcher.stores.battlenet.BattleNetClient.syncAccount(app) }
val found = Store.entries.mapNotNull { store -> StoreAccounts.displayName(app, store)?.let { store to it } }
val onDisk = StoreInstallRoot.gameFolders(app).mapNotNull { folder ->
StoreGameSidecar.read(folder)?.takeIf { it.isInstalled || it.store == Store.GOG && it.state == StoreGameSidecar.STATE_UPDATING }?.let { InstalledStoreGame(it, folder) }
StoreGameSidecar.read(folder)?.takeIf { it.isInstalled || it.state == StoreGameSidecar.STATE_UPDATING }?.let { InstalledStoreGame(it, folder) }
} + runCatching { com.droiddeck.launcher.stores.battlenet.BattleNetBackend.installedGames(app) }.getOrDefault(emptyList())
val partial = StoreInstallRoot.unfinished(app)
val version = if (StoresNative.available) StoresNative.version ?: "" else ""
@@ -95,6 +99,7 @@ object StoresState {
engine = version
accountsLoaded = true
}
StoreMaintenance.checkInstalled(app, onDisk)
com.droiddeck.launcher.stores.gog.GogMaintenance.checkInstalled(app, onDisk)
}
}
@@ -180,6 +185,7 @@ object StoresState {
}
fun signIn(context: Context, store: Store) {
StoreMaintenance.invalidate(store)
expired.remove(store)
libraryAsked.remove(store)
backends[store]?.signIn(context) ?: logLine("${store.label}: this build has no sign-in for it yet")
@@ -190,6 +196,7 @@ object StoresState {
background("stores-signout") {
val backend = backends[store]
backend?.signOut(app)
StoreMaintenance.invalidate(store)
if (store == Store.GOG) com.droiddeck.launcher.stores.gog.GogComet.replaceAccount { StoreAccounts.clear(app, store) }
else if (store != Store.XBOX || backend == null) StoreAccounts.clear(app, store)
if (store == Store.ITCH) {
@@ -348,4 +355,5 @@ interface StoreBackend {
fun search(context: Context, query: String): List<CatalogItem> = emptyList()
fun install(context: Context, item: CatalogItem, root: java.io.File)
fun uninstall(context: Context, game: InstalledStoreGame)
fun maintenanceAdapter(): com.droiddeck.launcher.stores.maintenance.StoreMaintenanceAdapter? = null
}
@@ -12,10 +12,12 @@ import com.droiddeck.launcher.stores.StoreLoginActivity
import com.droiddeck.launcher.stores.StoresState
import com.droiddeck.launcher.stores.download.DownloadEntry
import com.droiddeck.launcher.stores.download.DownloadQueue
import com.droiddeck.launcher.stores.maintenance.StoreMaintenanceAdapter
import org.json.JSONObject
object AmazonBackend : StoreBackend {
override val store: Store = Store.AMAZON
override fun maintenanceAdapter(): StoreMaintenanceAdapter = AmazonMaintenanceAdapter
init {
LoginFlows.amazonFlow = { AmazonLogin.create() }
@@ -11,6 +11,8 @@ import com.droiddeck.launcher.stores.StoreInstalls
import com.droiddeck.launcher.stores.download.DownloadQueue
import com.droiddeck.launcher.stores.download.DownloadStage
import com.droiddeck.launcher.stores.download.StoreDownloadTier
import com.droiddeck.launcher.stores.InstalledStoreGame
import com.droiddeck.launcher.stores.maintenance.StoreMaintenance
import org.json.JSONObject
import java.io.File
import java.util.concurrent.atomic.AtomicBoolean
@@ -20,6 +22,8 @@ internal class AmazonInstaller(
private val app: Context,
private val item: CatalogItem,
private val folder: File,
private val maintenance: JSONObject? = null,
private val maintenanceGame: InstalledStoreGame? = null,
) : DownloadQueue.DownloadJob {
private val cancelled = AtomicBoolean(false)
@@ -29,7 +33,7 @@ internal class AmazonInstaller(
val entitlementId = item.extra["entitlementId"].orEmpty()
val sku = item.extra["sku"].orEmpty()
val cover = item.tallImageUrl ?: item.imageUrl
StoreInstalls.begin(folder, Store.AMAZON, item.id, item.title, cover, item.imageUrl)
if (maintenance == null) StoreInstalls.begin(folder, Store.AMAZON, item.id, item.title, cover, item.imageUrl)
if (cancelled.get()) return null
val tier = StoreDownloadTier.current(app)
@@ -41,9 +45,14 @@ internal class AmazonInstaller(
.put("maxWorkers", tier.networkWindow.coerceIn(1, 128))
.put("processWorkers", tier.processWorkers)
.put("caBundle", "")
maintenance?.let {
request.put("targetManifestPath", it.getString("manifestPath"))
request.put("targetVersionId", it.getString("versionId"))
}
val shown = AtomicLong(-1)
val downloading = AtomicBoolean(false)
var applyLease: AutoCloseable? = null
val prefs = AmazonPrefs.get(app)
val listener = object : AmazonNative.InstallListener {
override fun onStatus(text: String) { handle.stage(DownloadStage.MANIFEST, text) }
@@ -70,7 +79,8 @@ internal class AmazonInstaller(
override fun onLog(line: String) { handle.log("amazon: $line") }
override fun beforeReplace(): Boolean = try {
StoreInstalls.markUpdating(folder)
if (maintenance == null) StoreInstalls.markUpdating(folder)
else if (applyLease == null) applyLease = StoreMaintenance.beginApplyLease(maintenanceGame!!)
true
} catch (e: Exception) {
handle.log("amazon: the install record could not be switched to updating (${e.javaClass.simpleName})")
@@ -80,12 +90,11 @@ internal class AmazonInstaller(
override fun onComplete(success: Boolean, cancelled: Boolean, error: String, result: String) = Unit
}
val outcome = AmazonNative.install(request, listener, cancelled)
val outcome = try { AmazonNative.install(request, listener, cancelled) }
finally { runCatching { applyLease?.close() } }
if (outcome.cancelled || cancelled.get() && !outcome.success) return null
if (!outcome.success) throw IllegalStateException(outcome.error.ifEmpty { "The Amazon install failed" })
val result = outcome.result ?: JSONObject()
handle.stage(DownloadStage.INSTALL, "Finishing…")
val versionId = result.optString("versionId")
val total = result.optLong("totalBytes", 0L)
prefs.edit().apply {
@@ -93,6 +102,14 @@ internal class AmazonInstaller(
putString("version_${item.id}", versionId)
}.apply()
if (maintenance != null && maintenanceGame != null) {
if (applyLease == null) StoreMaintenance.beginApplyLease(maintenanceGame).close()
StoreMaintenance.complete(app, maintenanceGame, versionId.ifEmpty { maintenance.optString("versionId") })
return folder.path
}
handle.stage(DownloadStage.INSTALL, "Finishing…")
handle.stage(DownloadStage.INSTALL, app.getString(R.string.stores_registering))
val spec = AmazonNative.call("launch.spec", JSONObject().put("gameDir", folder.absolutePath).put("title", item.title))
?: throw IllegalStateException(AmazonNative.ENGINE_MISSING)
@@ -0,0 +1,141 @@
package com.droiddeck.launcher.stores.amazon
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.StoreInstallRoot
import com.droiddeck.launcher.stores.download.DownloadEntry
import com.droiddeck.launcher.stores.download.DownloadQueue
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
import java.nio.file.Files
import java.nio.file.StandardCopyOption
internal object AmazonMaintenanceAdapter : StoreMaintenanceAdapter {
override val store: Store = Store.AMAZON
private const val TARGET_SCHEMA = 1
private const val INSTALLED_MANIFEST = ".droiddeck-maintenance/amazon/installed.manifest"
override fun capabilities(game: InstalledStoreGame): Set<MaintenanceAction> = buildSet {
if (game.sidecar.extra["entitlementId"].orEmpty().isNotBlank()) add(MaintenanceAction.UPDATE)
if (game.sidecar.installVersion.isNotBlank() && File(game.folder, INSTALLED_MANIFEST).isFile) add(MaintenanceAction.REPAIR)
}
override fun check(context: Context, game: InstalledStoreGame): MaintenanceCheck {
val token = AmazonCredentialStore.getValidAccessToken(context)
?: return MaintenanceCheck(MaintenanceCheckState.FAILED, reason = "Not signed in to Amazon Games")
val entitlement = game.sidecar.extra["entitlementId"].orEmpty()
require(entitlement.isNotBlank()) { "The installed Amazon record has no entitlement ID" }
val latest = AmazonApiClient.getGameDownload(token, entitlement)
?: error("Amazon's current game version could not be checked")
if (latest.versionId.isBlank()) return MaintenanceCheck(MaintenanceCheckState.UNKNOWN, reason = "Amazon did not provide a version ID")
val installed = game.sidecar.installVersion
if (installed.isBlank()) return MaintenanceCheck(MaintenanceCheckState.UNKNOWN, latest.versionId, "The installed Amazon version is unknown")
return MaintenanceCheck(
if (latest.versionId == installed) MaintenanceCheckState.CURRENT else MaintenanceCheckState.AVAILABLE,
latest.versionId,
)
}
override fun prepare(context: Context, game: InstalledStoreGame, action: MaintenanceAction): MaintenanceProposal {
require(action == MaintenanceAction.UPDATE || action == MaintenanceAction.REPAIR) { "Amazon supports Update and Repair" }
require(action in capabilities(game)) { "The installed Amazon record is missing the data required for ${action.name.lowercase()}" }
require(StoreMaintenance.readOperation(game.folder) == null) { "Resume or recover the existing Amazon operation first" }
val token = AmazonCredentialStore.getValidAccessToken(context) ?: error("Not signed in to Amazon Games")
val entitlement = game.sidecar.extra["entitlementId"].orEmpty()
val current = AmazonApiClient.getGameDownload(token, entitlement) ?: error("Amazon's current download is unavailable")
require(current.versionId.isNotBlank()) { "Amazon did not provide a version ID" }
val dir = File(game.folder, ".droiddeck-maintenance/amazon")
require(!Files.isSymbolicLink(File(game.folder, ".droiddeck-maintenance").toPath())) { "Amazon maintenance metadata is a symbolic link" }
require(!Files.isSymbolicLink(dir.toPath())) { "Amazon maintenance metadata is a symbolic link" }
dir.mkdirs()
val (version, path, bytes) = if (action == MaintenanceAction.REPAIR) {
require(game.sidecar.installVersion.isNotBlank()) { "The installed Amazon version is unknown; repair cannot safely choose a target" }
val installed = File(game.folder, INSTALLED_MANIFEST)
require(installed.isFile && !Files.isSymbolicLink(installed.toPath())) { "The installed Amazon manifest is unavailable; repair cannot safely choose a target" }
val raw = installed.readBytes()
val info = AmazonNative.inspect(raw) ?: error("The installed Amazon manifest could not be verified")
require(!info.has("error")) { info.optString("error", "The installed Amazon manifest could not be verified") }
require(info.optInt("unverifiableFiles", 0) == 0) { "Amazon's installed manifest has file hashes DroidDeck cannot verify; Repair cannot safely replace those files" }
Triple(game.sidecar.installVersion, installed, info.optLong("totalBytes", 0L))
} else {
val raw = AmazonApiClient.getBytes(AmazonApiClient.appendPath(current.downloadUrl, "manifest.proto"), token)
?: error("Amazon's update manifest could not be downloaded")
val info = AmazonNative.inspect(raw) ?: error("Amazon's update manifest could not be read")
require(!info.has("error")) { info.optString("error", "Amazon's update manifest could not be read") }
require(info.optInt("unverifiableFiles", 0) == 0) { "Amazon's update manifest has file hashes DroidDeck cannot verify; it cannot safely replace those files" }
val target = File(dir, "target.manifest")
val temp = File(dir, "target.manifest.tmp")
require(!Files.isSymbolicLink(temp.toPath())) { "Amazon maintenance metadata is a symbolic link" }
temp.writeBytes(raw)
try { Files.move(temp.toPath(), target.toPath(), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) }
catch (_: java.nio.file.AtomicMoveNotSupportedException) { Files.move(temp.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING) }
Triple(current.versionId, target, info.optLong("totalBytes", 0L))
}
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,
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,
)
}
override fun enqueue(context: Context, game: InstalledStoreGame, proposal: MaintenanceProposal) {
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)
}
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")))
}
override fun recovery(context: Context, game: InstalledStoreGame): MaintenanceRecovery {
val phase = runCatching { StoreMaintenance.readOperation(game.folder)?.optString("phase").orEmpty() }.getOrDefault("")
return if (phase in setOf("queued", "download", "applying")) MaintenanceRecovery(RecoveryState.RESUMABLE, phase)
else if (phase.isNotEmpty()) MaintenanceRecovery(RecoveryState.NEEDS_ATTENTION, phase) else MaintenanceRecovery()
}
private fun validateTarget(game: InstalledStoreGame, target: JSONObject): JSONObject {
require(target.optInt("schema") == TARGET_SCHEMA) { "The saved Amazon maintenance target is unsupported" }
require(target.optString("versionId").isNotBlank()) { "The saved Amazon target has no version ID" }
val rawPath = File(target.optString("manifestPath"))
require(!Files.isSymbolicLink(rawPath.toPath())) { "The saved Amazon manifest is a symbolic link" }
val path = rawPath.canonicalFile
require(path.path.startsWith(game.folder.canonicalPath + File.separator) && path.isFile) {
"The saved Amazon manifest is unavailable or outside the install folder"
}
val checked = AmazonNative.inspect(path.readBytes()) ?: error("The saved Amazon manifest could not be verified")
require(!checked.has("error")) { checked.optString("error", "The saved Amazon manifest could not be verified") }
require(checked.optInt("unverifiableFiles", 0) == 0) { "Amazon's saved manifest has file hashes DroidDeck cannot verify; it cannot safely replace those files" }
return JSONObject(target.toString()).put("manifestPath", path.absolutePath)
}
private fun enqueueJob(context: Context, game: InstalledStoreGame, target: JSONObject, bytes: Long, action: MaintenanceAction) {
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))) {
AmazonInstaller(app, item, game.folder, target, game)
}
}
}
@@ -19,6 +19,9 @@ import com.droiddeck.launcher.stores.StoresState
import com.droiddeck.launcher.stores.download.DownloadEntry
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
@@ -103,6 +106,13 @@ 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({
@@ -176,6 +186,18 @@ object BattleNetInstall {
fun checkUpdate(context: Context, game: InstalledStoreGame, force: Boolean = false) = check(game.sidecar.id, game.folder, force)
fun maintenanceCheck(context: Context, game: InstalledStoreGame): MaintenanceCheck {
val product = product(game.sidecar.id)
?: return MaintenanceCheck(MaintenanceCheckState.UNKNOWN, reason = "Battle.net does not support this installed product")
val local = runCatching { buildKey(File(game.folder, BUILD_INFO).readText()) }.getOrNull()
?: return MaintenanceCheck(MaintenanceCheckState.UNKNOWN, reason = "The installed Battle.net build is unknown")
val latest = runCatching { latestBuild(fetch(VERSIONS.format(Locale.ROOT, local.first, product)), local.first) }
.getOrElse { return MaintenanceCheck(MaintenanceCheckState.FAILED, reason = it.message ?: "Battle.net update check failed") }
?: return MaintenanceCheck(MaintenanceCheckState.UNKNOWN, reason = "Battle.net did not provide a build for region ${local.first}")
return if (latest == local.second) MaintenanceCheck(MaintenanceCheckState.CURRENT, latest)
else MaintenanceCheck(MaintenanceCheckState.AVAILABLE, latest)
}
internal fun check(id: String, folder: File, force: Boolean) {
val product = product(id) ?: return
val now = System.currentTimeMillis()
@@ -239,23 +261,28 @@ 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))
@@ -269,10 +296,18 @@ 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) }
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))
line == BattleNetInstall.STATE + "starting" -> handle.stage(DownloadStage.MANIFEST, app.getString(R.string.bnet_dl_starting))
@@ -280,9 +315,16 @@ 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)
@@ -0,0 +1,87 @@
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 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 enqueue(context: Context, game: InstalledStoreGame, proposal: MaintenanceProposal) {
require(proposal.operation == MaintenanceAction.UPDATE)
enqueuePinned(context, game, proposal.target)
}
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 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")
}
}
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)
}
}
@@ -19,6 +19,7 @@ import com.droiddeck.launcher.stores.StoresState
import com.droiddeck.launcher.stores.download.DownloadEntry
import com.droiddeck.launcher.stores.download.DownloadQueue
import com.droiddeck.launcher.stores.download.DownloadStage
import com.droiddeck.launcher.stores.maintenance.StoreMaintenanceAdapter
import org.json.JSONObject
import java.io.File
import java.io.FileOutputStream
@@ -153,6 +154,7 @@ object BattleNetClient {
object BattleNetBackend : StoreBackend {
override val store: Store = Store.BATTLENET
override fun maintenanceAdapter(): StoreMaintenanceAdapter = BattleNetMaintenanceAdapter
override fun signIn(context: Context) = BattleNetClient.signIn(context)
@@ -68,11 +68,12 @@ object SavedQueue {
for ((item, root, paused) in back) {
if (DownloadQueue.entry(key(item)) != null) continue
if (paused) DownloadQueue.holdPaused(key(item))
if (item.store == Store.GOG && item.extra.containsKey(com.droiddeck.launcher.stores.gog.GogMaintenance.OPERATION)) {
if (item.extra.containsKey(com.droiddeck.launcher.stores.maintenance.StoreMaintenance.OPERATION_KEY) ||
item.store == Store.GOG && item.extra.containsKey(com.droiddeck.launcher.stores.gog.GogMaintenance.OPERATION)) {
Thread({
runCatching { com.droiddeck.launcher.stores.gog.GogMaintenance.restore(app, item) }
.onFailure { DownloadQueue.recordFailed(app, DownloadEntry(item.store, item.id, item.title), it.message ?: "GOG recovery failed") }
}, "gog-restore").start()
runCatching { com.droiddeck.launcher.stores.maintenance.StoreMaintenance.restore(app, item) }
.onFailure { DownloadQueue.recordFailed(app, DownloadEntry(item.store, item.id, item.title), it.message ?: "${item.store.label} recovery failed") }
}, "store-maintenance-restore").start()
} else StoresState.install(app, item, root)
}
}
@@ -14,10 +14,12 @@ import com.droiddeck.launcher.stores.StoreLoginActivity
import com.droiddeck.launcher.stores.StoresState
import com.droiddeck.launcher.stores.download.DownloadEntry
import com.droiddeck.launcher.stores.download.DownloadQueue
import com.droiddeck.launcher.stores.maintenance.StoreMaintenanceAdapter
import com.droiddeck.launcher.stores.mergeItems
object EpicBackend : StoreBackend, EpicLaunchSupport {
override val store: Store = Store.EPIC
override fun maintenanceAdapter(): StoreMaintenanceAdapter = EpicMaintenanceAdapter
init {
LoginFlows.epicFlow = { EpicLogin.create() }
@@ -13,6 +13,8 @@ import com.droiddeck.launcher.stores.StoreLanguage
import com.droiddeck.launcher.stores.download.DownloadQueue
import com.droiddeck.launcher.stores.download.DownloadStage
import com.droiddeck.launcher.stores.download.StoreDownloadTier
import com.droiddeck.launcher.stores.InstalledStoreGame
import com.droiddeck.launcher.stores.maintenance.StoreMaintenance
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
@@ -23,6 +25,8 @@ internal class EpicInstaller(
private val app: Context,
private val item: CatalogItem,
private val folder: File,
private val maintenance: JSONObject? = null,
private val maintenanceGame: InstalledStoreGame? = null,
) : DownloadQueue.DownloadJob {
private val cancelled = AtomicBoolean(false)
private val namespace = item.extra["namespace"].orEmpty()
@@ -36,7 +40,14 @@ internal class EpicInstaller(
handle.stage(DownloadStage.MANIFEST, app.getString(R.string.stores_sync_sign_in))
val token = EpicCredentialStore.getValidAccessToken(app) ?: throw IllegalStateException("Not signed in to Epic Games")
val cover = item.tallImageUrl ?: item.imageUrl
StoreInstalls.begin(folder, Store.EPIC, item.id, item.title, cover, item.imageUrl)
val tags = maintenance?.optJSONArray("tags")?.let { a -> List(a.length()) { a.optString(it) } }
?: EpicInstallTags.tagsFor(installLocale())
val priorTagsRaw = maintenanceGame?.sidecar?.extra?.get("installTags")
?: StoreGameSidecar.read(folder)?.extra?.get("installTags").orEmpty()
val previousTags = runCatching {
if (priorTagsRaw.isBlank()) tags else JSONArray(priorTagsRaw).let { a -> List(a.length()) { a.optString(it) } }
}.getOrDefault(tags)
if (maintenance == null) StoreInstalls.begin(folder, Store.EPIC, item.id, item.title, cover, item.imageUrl)
if (cancelled.get()) return null
handle.stage(DownloadStage.MANIFEST, "Reading the build list…")
@@ -50,13 +61,16 @@ internal class EpicInstaller(
.put("gameDir", folder.absolutePath)
.put("scratchDir", if (StoreInstallRoot.isRemovable(app, folder)) scratch().absolutePath else "")
.put("storageLabel", StoreInstallRoot.labelFor(app, folder))
.put("tags", JSONArray(EpicInstallTags.tagsFor(installLocale())))
.put("tags", JSONArray(tags))
.put("previousTags", JSONArray(previousTags))
.put("maxWorkers", tier.networkWindow.coerceIn(1, 128))
.put("processWorkers", tier.processWorkers)
.put("caBundle", "")
maintenance?.let { request.put("targetManifestPath", it.getString("manifestPath")) }
val shown = AtomicLong(-1)
val installing = AtomicBoolean(false)
var applyLease: AutoCloseable? = null
val prefs = EpicPrefs.get(app)
val listener = object : EpicNative.InstallListener {
override fun onStatus(text: String) { handle.stage(DownloadStage.MANIFEST, text) }
@@ -90,7 +104,8 @@ internal class EpicInstaller(
override fun onLog(line: String) { handle.log("epic: $line") }
override fun beforeReplace(): Boolean = try {
StoreInstalls.markUpdating(folder)
if (maintenance == null) StoreInstalls.markUpdating(folder)
else if (applyLease == null) applyLease = StoreMaintenance.beginApplyLease(maintenanceGame!!)
true
} catch (e: Exception) {
handle.log("epic: the install record could not be switched to updating (${e.javaClass.simpleName})")
@@ -100,11 +115,21 @@ internal class EpicInstaller(
override fun onComplete(success: Boolean, cancelled: Boolean, error: String, result: String) = Unit
}
val outcome = EpicNative.install(request, listener, cancelled)
val outcome = try { EpicNative.install(request, listener, cancelled) }
finally { runCatching { applyLease?.close() } }
if (outcome.cancelled || cancelled.get() && !outcome.success) return null
if (!outcome.success) throw IllegalStateException(outcome.error.ifEmpty { "Native Epic install failed" })
val result = outcome.result ?: JSONObject()
if (maintenance != null && maintenanceGame != null) {
if (applyLease == null) StoreMaintenance.beginApplyLease(maintenanceGame).close()
val extra = LinkedHashMap(maintenanceGame.sidecar.extra)
maintenance.optString("deploymentId").takeIf { it.isNotBlank() }?.let { extra["deploymentId"] = it }
maintenance.optString("buildInfoVersion").takeIf { it.isNotBlank() }?.let { extra["buildInfoVersion"] = it }
StoreMaintenance.complete(app, maintenanceGame, result.optString("buildVersion"), extra)
return folder.path
}
handle.stage(DownloadStage.INSTALL, app.getString(R.string.stores_registering))
val picked = StoreExe.pick(folder, result.optString("launchExe"), item.title)
if (picked.isEmpty()) handle.log("epic: no exe found in ${folder.name}; pick one in Steam settings › Added games")
@@ -125,7 +150,9 @@ internal class EpicInstaller(
extra["namespace"] = namespace
extra["catalogItemId"] = catalogItemId
if (deployment.isNotEmpty()) extra["deploymentId"] = deployment
extra["buildInfoVersion"] = JSONObject(info).optString("buildVersion")
extra[StoreLanguage.EXTRA] = item.extra[StoreLanguage.EXTRA] ?: StoreLanguage.preferredCode(app)
extra["installTags"] = JSONArray(EpicInstallTags.tagsFor(installLocale())).toString()
extra[WinCompSources.EPIC_PREREQ_PATH] = result.optString("prereqPath")
extra[WinCompSources.EPIC_PREREQ_NAME] = result.optString("prereqName")
if (result.optBoolean("prereqRead", false)) extra[WinCompSources.LIST_READ] = "1"
@@ -0,0 +1,169 @@
package com.droiddeck.launcher.stores.epic
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.StoreInstallRoot
import com.droiddeck.launcher.stores.StoreLanguage
import com.droiddeck.launcher.stores.download.DownloadEntry
import com.droiddeck.launcher.stores.download.DownloadQueue
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.JSONArray
import org.json.JSONObject
import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption
internal object EpicMaintenanceAdapter : StoreMaintenanceAdapter {
override val store: Store = Store.EPIC
private const val TARGET_SCHEMA = 1
private const val INSTALLED_MANIFEST = ".droiddeck-maintenance/epic/installed.manifest"
override fun capabilities(game: InstalledStoreGame): Set<MaintenanceAction> = buildSet {
val extra = game.sidecar.extra
if (extra["namespace"].orEmpty().isNotBlank() && extra["catalogItemId"].orEmpty().isNotBlank()) add(MaintenanceAction.UPDATE)
if (game.sidecar.installVersion.isNotBlank() && File(game.folder, INSTALLED_MANIFEST).isFile) add(MaintenanceAction.REPAIR)
}
override fun check(context: Context, game: InstalledStoreGame): MaintenanceCheck {
val token = EpicCredentialStore.getValidAccessToken(context)
?: return MaintenanceCheck(MaintenanceCheckState.FAILED, reason = "Not signed in to Epic Games")
val info = currentInfo(token, game.sidecar.id, game.sidecar.extra)
val latest = info.optString("buildVersion")
if (latest.isBlank()) return MaintenanceCheck(MaintenanceCheckState.UNKNOWN, reason = "Epic did not provide a build version")
val installed = game.sidecar.extra["buildInfoVersion"].orEmpty().ifBlank { game.sidecar.installVersion }
if (installed.isBlank()) return MaintenanceCheck(MaintenanceCheckState.UNKNOWN, latest, "The installed Epic build is unknown")
return MaintenanceCheck(if (latest == installed) MaintenanceCheckState.CURRENT else MaintenanceCheckState.AVAILABLE, latest)
}
override fun prepare(context: Context, game: InstalledStoreGame, action: MaintenanceAction): MaintenanceProposal {
require(action == MaintenanceAction.UPDATE || action == MaintenanceAction.REPAIR) { "Epic supports Update and Repair" }
require(action in capabilities(game)) { "The installed Epic record is missing the data required for ${action.name.lowercase()}" }
require(StoreMaintenance.readOperation(game.folder) == null) { "Resume or recover the existing Epic operation first" }
val token = EpicCredentialStore.getValidAccessToken(context) ?: error("Not signed in to Epic Games")
val info = currentInfo(token, game.sidecar.id, game.sidecar.extra)
val tags = installedTags(context, game)
val buildInfoVersion = info.optString("buildVersion").ifBlank { error("Epic did not provide a build version") }
val dir = File(game.folder, ".droiddeck-maintenance/epic")
require(!Files.isSymbolicLink(File(game.folder, ".droiddeck-maintenance").toPath()) && !Files.isSymbolicLink(dir.toPath())) {
"Epic maintenance metadata is a symbolic link"
}
dir.mkdirs()
val targetPath: File
val manifestBytes: ByteArray
val targetBuild: String
val deploymentId: String
if (action == MaintenanceAction.REPAIR) {
targetPath = File(game.folder, INSTALLED_MANIFEST)
require(targetPath.isFile && !Files.isSymbolicLink(targetPath.toPath())) { "The installed Epic manifest is unavailable; repair cannot safely choose a target" }
manifestBytes = targetPath.readBytes()
val parsed = EpicNative.inspect(manifestBytes, JSONObject()) ?: error("The installed Epic manifest could not be read")
require(!parsed.has("error")) { parsed.optString("error", "The installed Epic manifest could not be read") }
targetBuild = parsed.optString("buildVersion").ifBlank { error("The installed Epic manifest has no build version") }
deploymentId = game.sidecar.extra["deploymentId"].orEmpty()
} else {
manifestBytes = EpicNative.fetch("manifest.download", JSONObject().put("info", info))
?: error("Epic's update manifest could not be downloaded")
val parsed = EpicNative.inspect(manifestBytes, JSONObject()) ?: error("Epic's update manifest could not be read")
require(!parsed.has("error")) { parsed.optString("error", "Epic's update manifest could not be read") }
targetBuild = parsed.optString("buildVersion").ifBlank { buildInfoVersion }
deploymentId = info.optString("deploymentId")
targetPath = File(dir, "target.manifest")
val temp = File(dir, "target.manifest.tmp")
require(!Files.isSymbolicLink(temp.toPath())) { "Epic maintenance metadata is a symbolic link" }
temp.writeBytes(manifestBytes)
try { Files.move(temp.toPath(), targetPath.toPath(), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) }
catch (_: java.nio.file.AtomicMoveNotSupportedException) { Files.move(temp.toPath(), targetPath.toPath(), StandardCopyOption.REPLACE_EXISTING) }
}
val estimate = EpicNative.inspect(manifestBytes, JSONObject().put("op", "estimate").put("tags", JSONArray(tags)))
?: error("Epic's target manifest could not be checked")
require(!estimate.has("error")) { estimate.optString("error", "Epic's target manifest could not be checked") }
val downloadBytes = estimate.optLong("downloadBytes", -1L)
val diskBytes = estimate.optLong("diskBytes", -1L)
require(downloadBytes >= 0 && diskBytes >= 0) { "Epic's target manifest has an invalid size estimate" }
val target = JSONObject().put("schema", TARGET_SCHEMA).put("buildVersion", targetBuild)
.put("buildInfoVersion", if (action == MaintenanceAction.REPAIR) game.sidecar.extra["buildInfoVersion"].orEmpty() else buildInfoVersion)
.put("deploymentId", deploymentId)
.put("manifestPath", targetPath.absolutePath).put("tags", JSONArray(tags))
return MaintenanceProposal(
game.folder.canonicalFile, game.sidecar, StoreInstallRoot.labelFor(context, game.folder), 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,
)
}
override fun enqueue(context: Context, game: InstalledStoreGame, proposal: MaintenanceProposal) {
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)
}
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")))
}
override fun recovery(context: Context, game: InstalledStoreGame): MaintenanceRecovery {
val phase = runCatching { StoreMaintenance.readOperation(game.folder)?.optString("phase").orEmpty() }.getOrDefault("")
return if (phase in setOf("queued", "download", "applying")) MaintenanceRecovery(RecoveryState.RESUMABLE, phase)
else if (phase.isNotEmpty()) MaintenanceRecovery(RecoveryState.NEEDS_ATTENTION, phase) else MaintenanceRecovery()
}
private fun validateTarget(game: InstalledStoreGame, target: JSONObject): JSONObject {
require(target.optInt("schema") == TARGET_SCHEMA) { "The saved Epic maintenance target is unsupported" }
require(target.optString("buildVersion").isNotBlank()) { "The saved Epic target has no build version" }
val rawPath = File(target.optString("manifestPath"))
require(!Files.isSymbolicLink(rawPath.toPath())) { "The saved Epic manifest is a symbolic link" }
val path = rawPath.canonicalFile
require(path.path.startsWith(game.folder.canonicalPath + File.separator) && path.isFile) {
"The saved Epic manifest is unavailable or outside the install folder"
}
val parsed = EpicNative.inspect(path.readBytes(), JSONObject()) ?: error("The saved Epic manifest could not be verified")
require(!parsed.has("error") && parsed.optString("buildVersion") == target.optString("buildVersion")) {
"The saved Epic manifest does not match the pinned build"
}
return JSONObject(target.toString()).put("manifestPath", path.absolutePath)
}
private fun currentInfo(token: String, id: String, extra: Map<String, String>): JSONObject {
val namespace = extra["namespace"].orEmpty().ifBlank { error("The Epic namespace is missing from the install record") }
val catalog = extra["catalogItemId"].orEmpty().ifBlank { error("The Epic catalog item ID is missing from the install record") }
val raw = EpicApiClient.getManifestApiJson(token, namespace, catalog, extra["appName"] ?: id)
?: error("Epic's current build information could not be fetched")
return JSONObject(raw)
}
private fun installedTags(context: Context, game: InstalledStoreGame): List<String> = runCatching {
val raw = game.sidecar.extra["installTags"].orEmpty()
val array = JSONArray(raw)
List(array.length()) { array.getString(it) }.filter { it.isNotBlank() }
}.getOrElse {
EpicInstallTags.tagsFor(StoreLanguage.localeFor(game.sidecar.extra[StoreLanguage.EXTRA], context))
}
private fun enqueueJob(context: Context, game: InstalledStoreGame, target: JSONObject, bytes: Long, action: MaintenanceAction) {
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))) {
EpicInstaller(app, item, game.folder, target, game)
}
}
}
@@ -10,6 +10,7 @@ import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreBackend
import com.droiddeck.launcher.stores.StoreInstallRoot
import com.droiddeck.launcher.stores.StoreSizes
import com.droiddeck.launcher.stores.maintenance.StoreMaintenanceAdapter
import com.droiddeck.launcher.stores.StoresState
import com.droiddeck.launcher.stores.download.DownloadEntry
import com.droiddeck.launcher.stores.download.DownloadQueue
@@ -18,6 +19,8 @@ import java.io.File
object ItchBackend : StoreBackend {
override val store: Store = Store.ITCH
override fun maintenanceAdapter(): StoreMaintenanceAdapter = ItchMaintenanceAdapter
override fun signIn(context: Context) {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(ItchApi.KEYS_PAGE)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
@@ -11,14 +11,17 @@ import com.droiddeck.launcher.stores.StoreGameSidecar
import com.droiddeck.launcher.stores.StoreInstallRoot
import com.droiddeck.launcher.stores.StoreInstalls
import com.droiddeck.launcher.stores.StoreNet
import com.droiddeck.launcher.stores.InstalledStoreGame
import com.droiddeck.launcher.stores.download.DownloadQueue
import com.droiddeck.launcher.stores.download.DownloadStage
import com.droiddeck.launcher.stores.maintenance.StoreMaintenance
import org.apache.commons.compress.archivers.sevenz.SevenZFile
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream
import org.apache.commons.compress.archivers.zip.ZipFile
import org.apache.commons.compress.compressors.bzip2.BZip2CompressorInputStream
import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream
import org.apache.commons.compress.compressors.xz.XZCompressorInputStream
import org.json.JSONObject
import java.io.BufferedInputStream
import java.io.File
import java.io.FileInputStream
@@ -31,6 +34,7 @@ internal class ItchInstaller(
private val app: Context,
private val item: CatalogItem,
private val folder: File,
private val maintenanceTarget: JSONObject? = null,
) : DownloadQueue.DownloadJob {
private val cancelled = AtomicBoolean(false)
private var work: File = folder
@@ -55,18 +59,27 @@ internal class ItchInstaller(
val upload = (if (chosen.isBlank()) ItchUploads.windows(uploads) else ItchUploads.byId(uploads, chosen))
?: throw IllegalStateException(app.getString(R.string.stores_itch_no_upload))
val uploadId = upload.optString("id")
val version = ItchUploads.updatedAt(upload)
val filename = ItchUploads.filename(upload)
val size = ItchUploads.size(upload)
maintenanceTarget?.let {
require(it.optInt("schema") == 1 && it.optString("gameId") == gameId &&
it.optString("downloadKeyId") == downloadKeyId && it.optString("uploadId") == uploadId &&
it.optString("updatedAt") == version && it.optString("filename") == filename && it.optLong("size") == size) {
"The pinned itch.io upload changed; prepare maintenance again"
}
}
val scratch = StoreInstallRoot.scratchDir(app, Store.ITCH, item.id)
scratch.mkdirs()
val archive = File(scratch, filename.ifBlank { "download.bin" })
val exe = try {
handle.stage(DownloadStage.DOWNLOAD)
download(key, downloadKeyId, uploadId, upload.optString("updated_at", upload.optString("updatedAt")), archive, size, handle)
download(key, downloadKeyId, uploadId, version, archive, size, handle)
if (cancelled.get()) return null
handle.stage(DownloadStage.INSTALL)
install(archive, filename, handle, resuming)
} catch (t: Throwable) {
if (maintenanceTarget != null) runCatching { handle.interruptible(true) }
if (!cancelled.get()) dropCaches(scratch)
throw t
}
@@ -77,6 +90,15 @@ internal class ItchInstaller(
}
archive.delete()
StoreInstalls.deleteTree(scratch)
if (maintenanceTarget != null) {
val beforeSidecar = previous ?: throw IllegalStateException("The installed itch.io sidecar disappeared")
try {
StoreMaintenance.complete(app, InstalledStoreGame(beforeSidecar, folder), version,
beforeSidecar.extra + ("uploadId" to maintenanceTarget.optString("uploadId")))
ItchUpdates.applied(app, item.key)
} finally { handle.interruptible(true) }
return folder.path
}
val sidecar = StoreGameSidecar(
store = Store.ITCH,
id = item.id,
@@ -84,13 +106,14 @@ internal class ItchInstaller(
exe = exe,
args = previous?.args.orEmpty(),
env = previous?.env.orEmpty(),
installVersion = upload.optString("updated_at", upload.optString("updatedAt")),
installVersion = version,
installedAt = System.currentTimeMillis(),
cover = item.imageUrl,
hero = item.imageUrl,
extra = mapOf("gameId" to gameId, "downloadKeyId" to downloadKeyId, "uploadId" to uploadId),
cloud = false,
)
if (!updating) ItchStaging.writeInventory(folder, ItchStaging.inventory(folder))
StoreInstalls.complete(app, folder, sidecar) { done, steps -> handle.stageProgress(done.toLong(), steps.toLong()) }
ItchUpdates.applied(app, item.key)
return folder.path
@@ -98,7 +121,13 @@ internal class ItchInstaller(
override fun cancel(deleteFiles: Boolean) {
cancelled.set(true)
if (deleteFiles) dropCaches(StoreInstallRoot.scratchDir(app, Store.ITCH, item.id))
if (deleteFiles) {
dropCaches(StoreInstallRoot.scratchDir(app, Store.ITCH, item.id))
if (maintenanceTarget != null) {
val phase = runCatching { StoreMaintenance.readOperation(folder)?.optString("phase") }.getOrNull()
if (phase in setOf("queued", "download")) runCatching { StoreMaintenance.clearOperation(folder) }
}
}
}
private fun dropCaches(scratch: File) {
@@ -193,8 +222,10 @@ internal class ItchInstaller(
"gz", "bz2", "xz" -> untar(archive, kind, handle) ?: single(archive, kind, handle)
else -> copy(archive, File(work, filename.ifBlank { "game" }))
}
val hint = filename.substringBeforeLast('.')
StoreExe.pick(work, hint.ifBlank { null }, item.title)
if (maintenanceTarget != null) previous?.exe.orEmpty() else {
val hint = filename.substringBeforeLast('.')
StoreExe.pick(work, hint.ifBlank { null }, item.title)
}
} catch (t: Throwable) {
if (updating) ItchStaging.delete(work)
throw t
@@ -204,6 +235,24 @@ internal class ItchInstaller(
if (updating) ItchStaging.delete(work)
return null
}
if (updating && maintenanceTarget != null) {
handle.interruptible(false)
synchronized(commit) {
if (cancelled.get()) {
handle.interruptible(true)
ItchStaging.delete(work)
return null
}
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)
}
}
return exe
}
if (updating) synchronized(commit) {
if (cancelled.get()) {
ItchStaging.delete(work)
@@ -0,0 +1,134 @@
package com.droiddeck.launcher.stores.itch
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.StoreInstallRoot
import com.droiddeck.launcher.stores.download.DownloadEntry
import com.droiddeck.launcher.stores.download.DownloadQueue
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.util.concurrent.ConcurrentHashMap
internal object ItchMaintenanceAdapter : StoreMaintenanceAdapter {
override val store = Store.ITCH
private val exactTargets = ConcurrentHashMap<String, JSONObject>()
private val exactCheckedAt = ConcurrentHashMap<String, Long>()
override fun capabilities(game: InstalledStoreGame): Set<MaintenanceAction> {
val result = linkedSetOf(MaintenanceAction.REFRESH)
if (StoreMaintenance.check(game).state == MaintenanceCheckState.AVAILABLE) result.add(MaintenanceAction.UPDATE)
val exact = exactTargets[game.key]
if (System.currentTimeMillis() - (exactCheckedAt[game.key] ?: 0L) < 30 * 60_000L &&
!game.sidecar.installVersion.isBlank() && exact != null && exact.optString("uploadId") == game.sidecar.extra["uploadId"] &&
exact.optString("updatedAt") == game.sidecar.installVersion) result.add(MaintenanceAction.REPAIR)
return result
}
override fun check(context: Context, game: InstalledStoreGame): MaintenanceCheck {
val key = ItchAuth.key(context)
if (key.isBlank()) { forgetExact(game.key); return MaintenanceCheck(MaintenanceCheckState.FAILED, reason = "Sign in to itch.io to check uploads") }
val gameId = game.sidecar.extra["gameId"].orEmpty()
if (gameId.isBlank()) { forgetExact(game.key); return MaintenanceCheck(MaintenanceCheckState.FAILED, reason = "The installed itch.io game has no game ID") }
val uploads = ItchApi.uploads(key, gameId, game.sidecar.extra["downloadKeyId"].orEmpty())
?: run { forgetExact(game.key); return MaintenanceCheck(MaintenanceCheckState.FAILED, reason = "itch.io upload check failed") }
if (game.sidecar.installVersion.isBlank()) {
forgetExact(game.key)
return MaintenanceCheck(MaintenanceCheckState.FAILED, reason = "The installed itch.io upload version is unknown; use Refresh")
}
val uploadId = game.sidecar.extra["uploadId"].orEmpty()
val installed = ItchUploads.byId(uploads, uploadId)
val exact = installed?.takeIf { game.sidecar.installVersion.isNotBlank() && ItchUploads.updatedAt(it) == game.sidecar.installVersion }
if (exact != null) {
exactTargets[game.key] = targetJson(game, exact)
exactCheckedAt[game.key] = System.currentTimeMillis()
} else forgetExact(game.key)
val update = ItchUpdates.evaluate(uploads, uploadId, game.sidecar.installVersion)
return if (update == null) MaintenanceCheck(MaintenanceCheckState.CURRENT, game.sidecar.installVersion)
else MaintenanceCheck(MaintenanceCheckState.AVAILABLE, update.updatedAt, if (update.replaced) "The installed upload was replaced" else "A newer upload is available")
}
override fun prepare(context: Context, game: InstalledStoreGame, action: MaintenanceAction): MaintenanceProposal {
val token = ItchAuth.key(context)
require(token.isNotBlank()) { "Sign in to itch.io to maintain this game" }
val extra = game.sidecar.extra
val gameId = extra["gameId"].orEmpty()
val keyId = extra["downloadKeyId"].orEmpty()
require(gameId.isNotBlank()) { "The installed itch.io game has no game ID" }
val uploads = ItchApi.uploads(token, gameId, keyId) ?: throw IllegalStateException("itch.io upload check failed")
val currentUpload = ItchUploads.byId(uploads, extra["uploadId"].orEmpty())
val chosenUpload = when (action) {
MaintenanceAction.REPAIR -> currentUpload?.takeIf { game.sidecar.installVersion.isNotBlank() && ItchUploads.updatedAt(it) == game.sidecar.installVersion }
?: throw IllegalStateException("The exact installed upload is no longer available; use Refresh for the current upload")
MaintenanceAction.UPDATE -> {
val next = ItchUpdates.evaluate(uploads, extra["uploadId"].orEmpty(), game.sidecar.installVersion)
?: throw IllegalStateException("No newer itch.io upload is available")
ItchUploads.byId(uploads, next.uploadId) ?: throw IllegalStateException("The selected itch.io upload disappeared")
}
MaintenanceAction.REFRESH -> currentUpload ?: ItchUploads.newestWindows(uploads)
?: throw IllegalStateException("No Windows itch.io upload is available")
else -> throw IllegalArgumentException("Unsupported itch.io maintenance action")
}
val target = targetJson(game, chosenUpload)
val id = target.optString("uploadId")
val stamp = target.optString("updatedAt")
val label = when (action) {
MaintenanceAction.REPAIR -> "installed upload $id ($stamp)"
MaintenanceAction.UPDATE -> "upload $id ($stamp)"
else -> if (id == extra["uploadId"] && stamp == game.sidecar.installVersion) "current upload $id ($stamp)" else "current upload $id ($stamp); the installed upload is no longer available"
}
val size = target.optLong("size")
return MaintenanceProposal(game.folder, game.sidecar, label, action, size, size,
"Downloads and safely replaces files from one pinned itch.io archive upload.", target)
}
override fun enqueue(context: Context, game: InstalledStoreGame, proposal: MaintenanceProposal) = 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)
}
override fun recovery(context: Context, game: InstalledStoreGame): MaintenanceRecovery {
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")
else MaintenanceRecovery()
}
private fun enqueuePinned(context: Context, game: InstalledStoreGame, target: JSONObject) {
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,
extra = game.sidecar.extra + mapOf("uploadId" to target.optString("uploadId"),
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) }
}
private fun targetJson(game: InstalledStoreGame, upload: JSONObject): JSONObject = JSONObject()
.put("schema", 1).put("gameId", game.sidecar.extra["gameId"].orEmpty())
.put("downloadKeyId", game.sidecar.extra["downloadKeyId"].orEmpty())
.put("uploadId", upload.optString("id"))
.put("updatedAt", ItchUploads.updatedAt(upload))
.put("filename", ItchUploads.filename(upload))
.put("size", ItchUploads.size(upload))
private fun forgetExact(gameKey: String) {
exactTargets.remove(gameKey)
exactCheckedAt.remove(gameKey)
}
}
@@ -1,30 +1,276 @@
package com.droiddeck.launcher.stores.itch
import org.json.JSONObject
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.security.MessageDigest
internal object ItchStaging {
const val DIR = ".droiddeck-update"
const val RECORD = ".droiddeck-store.json"
private const val JOURNAL = "itch-commit.json"
private const val INVENTORY = "itch-inventory.json"
private const val BACKUPS = "itch-backups"
private const val DIRECTORY = "<directory>"
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 }) {
if (Files.isSymbolicLink(child.toPath())) continue
val path = if (prefix.isEmpty()) child.name else "$prefix/${child.name}"
if (prefix.isEmpty() && (child.name == RECORD || child.name == DIR || child.name == ".droiddeck-maintenance")) continue
if (child.isDirectory) {
val children = child.listFiles()
if (children != null && children.isEmpty()) out["$path/"] = DIRECTORY
else visit(child, path)
} else if (child.isFile) out[path] = sha256(child)
}
}
visit(root, "")
}
private fun protectedUserData(relative: String): Boolean = relative.split('/').any {
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> {
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()
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) }
return linkedMapOf<String, String>().apply {
value.keys().forEach { path ->
val hash = value.optString(path)
require(validInventoryEntry(root.canonicalFile, path, hash)) { "itch.io inventory entry is invalid" }
put(path, hash)
}
}
}
fun writeInventory(root: File, inventory: Map<String, String>) {
val metadata = File(root, ".droiddeck-maintenance")
require(!Files.isSymbolicLink(metadata.toPath())) { "Maintenance metadata directory is a symbolic link" }
metadata.mkdirs()
val file = File(metadata, INVENTORY)
require(!Files.isSymbolicLink(file.toPath())) { "itch.io inventory is a symbolic link" }
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> {
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" }
val backups = File(metadata, BACKUPS)
val journal = File(metadata, JOURNAL)
val existing = readJournal(journal)
val discovered = inventory(from)
val existingNext = existing?.getJSONObject("next")
val next = existing?.optJSONObject("next")?.let { saved ->
linkedMapOf<String, String>().apply {
saved.keys().forEach { relative ->
val hash = saved.optString(relative)
require(validInventoryEntry(to.canonicalFile, relative, hash)) { "The interrupted itch.io inventory is invalid" }
put(relative, hash)
}
for ((relative, hash) in discovered) {
val becameEmptyAfterMove = hash == DIRECTORY && relative.endsWith('/') &&
keys.any { it != relative && it.startsWith(relative) }
require(this[relative] == hash || becameEmptyAfterMove) {
"The staged itch.io archive does not match the interrupted target"
}
}
}
} ?: discovered
val files = JSONObject().apply { next.forEach { (path, hash) -> put(path, hash) } }
val obsolete = previous.keys.filter { it !in next }.sorted()
val root = to.canonicalFile
val stage = from.canonicalFile
val paths = obsolete.sortedWith(compareByDescending<String> { it.count { ch -> ch == '/' } }.thenBy { it }) +
next.keys.sortedWith(compareBy<String> { it.count { ch -> ch == '/' } }.thenBy { it })
val state = existing ?: JSONObject().put("schema", 1).put("next", files).put("obsolete", JSONObject().apply { obsolete.forEach { put(it, previous[it]) } }).put("done", JSONObject())
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" }
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)
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 }
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)
}
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) {
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)
}
check(dest.isDirectory || dest.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) {
"itch.io archive collides with untracked or modified user data: $path"
}
}
val currentHash = if (dest.isFile) sha256(dest) 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)
}
dest.parentFile?.mkdirs()
move(stagedFile, dest)
check(sha256(dest) == expected) { "itch.io replacement failed verification: $relative" }
}
done.put(relative, true)
writeJournal(journal, state)
}
count++
afterEntry?.invoke(count)
}
StoreInstallsSafeDelete.delete(from)
StoreInstallsSafeDelete.delete(backups)
journal.delete()
return next
}
fun recover(to: File): Boolean {
val file = File(to, ".droiddeck-maintenance/$JOURNAL")
val state = readJournal(file) ?: return false
require(state.optInt("schema") == 1) { "The interrupted itch.io replacement has an unsupported journal" }
val next = state.optJSONObject("next") ?: throw IllegalStateException("The interrupted itch.io inventory is missing")
val done = state.optJSONObject("done") ?: throw IllegalStateException("The interrupted itch.io progress is missing")
val obsolete = state.optJSONObject("obsolete") ?: throw IllegalStateException("The interrupted itch.io cleanup list is missing")
for (relative in next.keys()) {
require(validInventoryEntry(to.canonicalFile, relative, next.optString(relative))) {
"The interrupted itch.io inventory is invalid"
}
}
for (relative in done.keys()) {
require(safe(to.canonicalFile, relative.removeSuffix("/")) != null && done.get(relative) is Boolean) {
"The interrupted itch.io progress is invalid"
}
}
for (relative in obsolete.keys()) {
require(validInventoryEntry(to.canonicalFile, relative, obsolete.optString(relative))) {
"The interrupted itch.io cleanup list is invalid"
}
}
return true
}
fun merge(from: File, to: File, top: Boolean = true) {
for (child in from.listFiles().orEmpty()) {
if (top && child.name == RECORD) continue
val dest = File(to, child.name)
val destPath = dest.toPath()
if (child.isDirectory && !Files.isSymbolicLink(child.toPath()) && dest.isDirectory && !Files.isSymbolicLink(destPath)) {
if (child.isDirectory && !Files.isSymbolicLink(child.toPath()) && dest.isDirectory && !Files.isSymbolicLink(dest.toPath())) {
merge(child, dest, false)
continue
} else {
if (Files.isSymbolicLink(dest.toPath())) dest.delete()
require(!dest.exists() || !dest.isDirectory) { "itch.io archive path collides with a directory: ${child.name}" }
dest.parentFile?.mkdirs()
move(child, dest)
}
if (Files.isSymbolicLink(destPath) || dest.exists()) {
delete(dest)
}
Files.move(child.toPath(), destPath)
}
}
fun delete(file: File) = StoreInstallsSafeDelete.delete(file)
private fun safe(root: File, relative: String): File? {
if (relative.isBlank() || relative.startsWith('/') || relative.contains('\\') || relative.split('/').any { it.isBlank() || it == "." || it == ".." }) return null
val file = File(root, relative)
var current = root
for (segment in relative.split('/')) {
current = File(current, segment)
if (Files.isSymbolicLink(current.toPath())) return null
}
val canonical = file.canonicalFile
return canonical.takeIf { it.path.startsWith(root.path + File.separator) }
}
private fun validInventoryEntry(root: File, relative: String, value: String): Boolean =
safe(root, relative.removeSuffix("/")) != null &&
(if (value == DIRECTORY) relative.endsWith('/') else value.matches(HASH) && !relative.endsWith('/'))
private fun sha256(file: File): String {
val digest = MessageDigest.getInstance("SHA-256")
FileInputStream(file).use { input ->
val bytes = ByteArray(128 * 1024)
while (true) { val n = input.read(bytes); if (n < 0) break; digest.update(bytes, 0, n) }
}
return digest.digest().joinToString("") { "%02x".format(it) }
}
private fun move(from: File, to: File) {
try { Files.move(from.toPath(), to.toPath(), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) }
catch (_: java.nio.file.AtomicMoveNotSupportedException) { Files.move(from.toPath(), to.toPath(), StandardCopyOption.REPLACE_EXISTING) }
}
private fun readJournal(file: File): JSONObject? {
file.parentFile?.let { require(!Files.isSymbolicLink(it.toPath())) { "Maintenance metadata directory is a symbolic link" } }
if (Files.isSymbolicLink(file.toPath())) throw IllegalStateException("The itch.io replacement journal is a symbolic link")
if (!file.exists()) return null
require(file.isFile) { "The itch.io replacement journal is not a regular file" }
return runCatching { JSONObject(file.readText()) }.getOrElse { throw IllegalStateException("The itch.io replacement journal is corrupt", it) }
}
private fun writeJournal(file: File, value: JSONObject) {
val temp = File.createTempFile(".itch-commit-", ".tmp", file.parentFile)
try {
FileOutputStream(temp).use { it.write(value.toString().toByteArray(Charsets.UTF_8)); it.fd.sync() }
move(temp, file)
} finally { temp.delete() }
}
private val HASH = Regex("[0-9a-f]{64}")
}
private object StoreInstallsSafeDelete {
fun delete(file: File) {
if (!Files.isSymbolicLink(file.toPath()) && file.isDirectory) file.listFiles().orEmpty().forEach { delete(it) }
if (!Files.isSymbolicLink(file.toPath()) && file.isDirectory) file.listFiles().orEmpty().forEach(::delete)
file.delete()
}
}
@@ -0,0 +1,305 @@
package com.droiddeck.launcher.stores.maintenance
import android.content.Context
import androidx.compose.runtime.mutableStateMapOf
import com.droiddeck.launcher.stores.InstalledStoreGame
import com.droiddeck.launcher.stores.CatalogItem
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreGameSidecar
import com.droiddeck.launcher.stores.StoreInstallRoot
import com.droiddeck.launcher.stores.StoreInstalls
import com.droiddeck.launcher.stores.StoreAccounts
import com.droiddeck.launcher.stores.StoresState
import org.json.JSONObject
import java.io.File
import java.io.FileOutputStream
import java.io.RandomAccessFile
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
enum class MaintenanceAction { UPDATE, REPAIR, REFRESH, OPEN_CLIENT }
enum class MaintenanceCheckState { UNKNOWN, CHECKING, CURRENT, AVAILABLE, FAILED }
data class MaintenanceCheck(
val state: MaintenanceCheckState = MaintenanceCheckState.UNKNOWN,
val version: String = "",
val reason: String = "",
)
enum class RecoveryState { NONE, RESUMABLE, NEEDS_ATTENTION }
data class MaintenanceRecovery(val state: RecoveryState = RecoveryState.NONE, val message: String = "")
data class MaintenanceProposal(
val folder: File,
val before: StoreGameSidecar,
val targetLabel: String,
val operation: MaintenanceAction,
val downloadBytes: Long = UNKNOWN_BYTES,
val diskBytes: Long = UNKNOWN_BYTES,
val description: String = "",
val target: JSONObject,
) {
companion object {
const val UNKNOWN_BYTES = -1L
}
}
interface StoreMaintenanceAdapter {
val store: Store
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 resume(context: Context, game: InstalledStoreGame, operation: JSONObject)
fun recovery(context: Context, game: InstalledStoreGame): MaintenanceRecovery = MaintenanceRecovery()
fun openClient(context: Context, game: InstalledStoreGame) = Unit
fun openClientLabel(game: InstalledStoreGame): String = "Open client"
}
object StoreMaintenance {
const val OPERATION_KEY = "maintenanceOperation"
const val FOLDER_KEY = "maintenanceFolder"
private const val JOURNAL = "operation.json"
private const val CHECK_INTERVAL_MS = 30 * 60_000L
private val adapters = ConcurrentHashMap<Store, StoreMaintenanceAdapter>()
private val workers = Executors.newFixedThreadPool(2)
private val checkedAt = ConcurrentHashMap<String, Long>()
private val inFlight = ConcurrentHashMap<String, Long>()
private val revisions = ConcurrentHashMap<Store, Long>()
private val accounts = ConcurrentHashMap<Store, String>()
val checks = mutableStateMapOf<String, MaintenanceCheck>()
fun register(adapter: StoreMaintenanceAdapter) {
adapters[adapter.store] = adapter
}
fun adapter(store: Store): StoreMaintenanceAdapter? = adapters[store]
fun adapter(game: InstalledStoreGame): StoreMaintenanceAdapter? = adapter(game.sidecar.store)
fun check(game: InstalledStoreGame): MaintenanceCheck = checks[game.key] ?: MaintenanceCheck()
fun checkInstalled(context: Context, games: List<InstalledStoreGame>) {
games.forEach { game -> if (game.sidecar.isInstalled && adapters.containsKey(game.sidecar.store)) check(context, game) }
}
fun check(context: Context, game: InstalledStoreGame, force: Boolean = false) {
val adapter = adapters[game.sidecar.store] ?: return
val account = StoreAccounts.displayName(context, game.sidecar.store).orEmpty()
val previous = accounts.put(game.sidecar.store, account)
if (previous != null && previous != account) invalidate(game.sidecar.store)
val key = "${game.sidecar.store.id}:${game.folder.canonicalPath}"
if (!force && System.currentTimeMillis() - (checkedAt[key] ?: 0L) < CHECK_INTERVAL_MS) return
val owner = revisions[game.sidecar.store] ?: 0L
if (inFlight.putIfAbsent(key, owner) != null) return
StoresState.post { if ((revisions[game.sidecar.store] ?: 0L) == owner) checks[game.key] = MaintenanceCheck(MaintenanceCheckState.CHECKING) }
workers.execute {
try {
val result = runCatching { adapter.check(context.applicationContext, game) }
.getOrElse { MaintenanceCheck(MaintenanceCheckState.FAILED, reason = it.message ?: "${game.sidecar.store.label} update check failed") }
if ((revisions[game.sidecar.store] ?: 0L) == owner && (result.state == MaintenanceCheckState.CURRENT || result.state == MaintenanceCheckState.AVAILABLE))
checkedAt[key] = System.currentTimeMillis()
StoresState.post { if ((revisions[game.sidecar.store] ?: 0L) == owner) checks[game.key] = result }
} finally { inFlight.remove(key, owner) }
}
}
fun invalidate(store: Store) {
revisions.compute(store) { _, revision -> (revision ?: 0L) + 1L }
checkedAt.keys.removeIf { it.startsWith("${store.id}:") }
inFlight.keys.removeIf { it.startsWith("${store.id}:") }
StoresState.post { checks.keys.removeIf { it.startsWith("${store.id}:") } }
}
fun prepare(context: Context, game: InstalledStoreGame, action: MaintenanceAction): MaintenanceProposal {
require(action in capabilities(game)) { "${game.sidecar.store.label} does not support ${action.name.lowercase()}" }
require(action != MaintenanceAction.OPEN_CLIENT)
return adapters[game.sidecar.store]?.prepare(context.applicationContext, game, action)
?: error("${game.sidecar.store.label} maintenance is unavailable")
}
fun capabilities(game: InstalledStoreGame): Set<MaintenanceAction> =
adapters[game.sidecar.store]?.capabilities(game).orEmpty()
fun enqueue(context: Context, game: InstalledStoreGame, proposal: MaintenanceProposal) {
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)
}
fun resume(context: Context, game: InstalledStoreGame) {
requireInstalledFolder(context, game.folder)
val adapter = adapters[game.sidecar.store] ?: error("${game.sidecar.store.label} maintenance is unavailable")
val operation = readOperation(game.folder) ?: error("${game.sidecar.store.label} operation journal is missing")
require(operation.optString("folder") == game.folder.canonicalPath && operation.optString("store") == game.sidecar.store.id && operation.optString("id") == game.sidecar.id)
adapter.resume(context.applicationContext, game, operation)
}
fun openClient(context: Context, game: InstalledStoreGame) {
require(MaintenanceAction.OPEN_CLIENT in capabilities(game))
adapters[game.sidecar.store]?.openClient(context.applicationContext, game)
}
fun recovery(context: Context, game: InstalledStoreGame): MaintenanceRecovery {
val adapterRecovery = try { adapters[game.sidecar.store]?.recovery(context.applicationContext, game) }
catch (error: Exception) { return MaintenanceRecovery(RecoveryState.NEEDS_ATTENTION, error.message ?: "The maintenance journal could not be read") }
if (adapterRecovery?.state != null && adapterRecovery.state != RecoveryState.NONE) return adapterRecovery
val operation = try { readOperation(game.folder) }
catch (error: Exception) { return MaintenanceRecovery(RecoveryState.NEEDS_ATTENTION, error.message ?: "The maintenance journal could not be read") }
if (operation != null) {
val state = if (operation.optString("phase") in setOf("queued", "download")) RecoveryState.RESUMABLE else RecoveryState.NEEDS_ATTENTION
return MaintenanceRecovery(state, operation.optString("phase"))
}
if (!game.sidecar.isInstalled) return MaintenanceRecovery(RecoveryState.NEEDS_ATTENTION, "The installation did not finish")
return adapterRecovery ?: MaintenanceRecovery()
}
fun operationFile(folder: File) = File(File(folder, ".droiddeck-maintenance"), JOURNAL)
fun readOperation(folder: File): JSONObject? {
val directory = File(folder, ".droiddeck-maintenance")
val file = File(directory, JOURNAL)
require(!Files.isSymbolicLink(directory.toPath()) && !Files.isSymbolicLink(file.toPath())) { "Maintenance journal is a symbolic link" }
return file.takeIf { it.isFile }?.let {
JSONObject(it.readText()).also { operation ->
require(operation.optInt("schema") == 1) { "Unsupported store maintenance journal" }
require(operation.optString("folder") == folder.canonicalPath) { "Maintenance journal folder does not match" }
}
}
}
fun clearOperation(folder: File) {
val directory = File(folder, ".droiddeck-maintenance")
val file = File(directory, JOURNAL)
require(!Files.isSymbolicLink(directory.toPath()) && !Files.isSymbolicLink(file.toPath())) { "Maintenance journal is a symbolic link" }
if (!file.exists()) return
require(file.isFile) { "Maintenance journal is not a regular file" }
val operation = readOperation(folder) ?: return
require(operation.optString("phase") in setOf("queued", "download")) { "An applying maintenance operation must be recovered" }
require(!Files.isSymbolicLink(directory.toPath()) && !Files.isSymbolicLink(file.toPath())) { "Maintenance journal is a symbolic link" }
check(file.delete()) { "Could not clear the queued maintenance operation" }
}
fun <T> withApplyLease(game: InstalledStoreGame, block: () -> T): T {
return beginApplyLease(game).use { block() }
}
fun beginApplyLease(game: InstalledStoreGame): AutoCloseable {
val folder = game.folder.canonicalFile
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)
val directory = File(folder, ".droiddeck-maintenance")
require(!Files.isSymbolicLink(directory.toPath())) { "Maintenance metadata directory is a symbolic link" }
directory.mkdirs()
val leaseFile = File(directory, "lease")
require(!Files.isSymbolicLink(leaseFile.toPath())) { "Maintenance lease is a symbolic link" }
val lease = RandomAccessFile(leaseFile, "rw")
val lock = runCatching { lease.channel.tryLock() }.getOrNull() ?: run {
lease.close()
error("Close the game before maintenance")
}
try {
val before = StoreGameSidecar.parse(operation.getJSONObject("before").toString())
?: error("The original ${game.sidecar.store.label} settings are missing")
require(before.store == game.sidecar.store && before.id == game.sidecar.id)
writeJson(operationFile(folder), operation.put("phase", "applying"))
before.copy(state = StoreGameSidecar.STATE_UPDATING).write(folder)
} catch (error: Throwable) {
lock.close()
lease.close()
throw error
}
return AutoCloseable { lock.close(); lease.close() }
}
fun complete(
context: Context,
game: InstalledStoreGame,
installVersion: String = game.sidecar.installVersion,
updatedExtra: Map<String, String>? = null,
) {
val folder = game.folder.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)
require(operation.optString("phase") == "applying") { "The ${game.sidecar.store.label} operation was not applied" }
val before = StoreGameSidecar.parse(operation.getJSONObject("before").toString())
?: error("The original ${game.sidecar.store.label} settings are missing")
StoreInstalls.complete(context.applicationContext, folder,
before.copy(installVersion = installVersion, extra = updatedExtra ?: before.extra, state = StoreGameSidecar.STATE_INSTALLED))
check(operationFile(folder).delete()) { "Could not finish the ${game.sidecar.store.label} recovery record" }
checkedAt.remove("${game.sidecar.store.id}:${folder.path}")
StoresState.post { checks.remove(game.key) }
}
private fun writeOperation(proposal: MaintenanceProposal, game: InstalledStoreGame) {
val folder = proposal.folder.canonicalFile
require(proposal.before.store == game.sidecar.store && proposal.before.id == game.sidecar.id)
val directory = File(folder, ".droiddeck-maintenance")
require(!Files.isSymbolicLink(directory.toPath())) { "Maintenance metadata directory is a symbolic link" }
directory.mkdirs()
val file = File(directory, JOURNAL)
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("before", proposal.before.toJson()).put("targetLabel", proposal.targetLabel)
.put("downloadBytes", proposal.downloadBytes).put("diskBytes", proposal.diskBytes)
.put("description", proposal.description).put("target", proposal.target)
writeJson(file, value)
}
private fun writeJson(file: File, value: JSONObject) {
require(!Files.isSymbolicLink(file.toPath())) { "Maintenance journal is a symbolic link" }
file.parentFile?.mkdirs()
val temp = File.createTempFile(".maintenance-", ".tmp", file.parentFile)
try {
FileOutputStream(temp).use { it.write(value.toString().toByteArray(Charsets.UTF_8)); it.fd.sync() }
try { Files.move(temp.toPath(), file.toPath(), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) }
catch (_: java.nio.file.AtomicMoveNotSupportedException) { Files.move(temp.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING) }
} finally { temp.delete() }
}
fun restore(context: Context, item: CatalogItem) {
if (item.store == Store.GOG && item.extra.containsKey("gogOperation")) {
com.droiddeck.launcher.stores.gog.GogMaintenance.restore(context, item)
return
}
if (adapters[item.store] == null) error("${item.store.label} maintenance is unavailable")
val folder = item.extra["maintenanceFolder"] ?: item.extra["gogFolder"]
?: error("Missing ${item.store.label} recovery folder")
requireInstalledFolder(context, File(folder))
val installed = InstalledStoreGame(
StoreGameSidecar.read(File(folder)) ?: error("${item.store.label} installation record is missing"),
File(folder),
)
require(installed.sidecar.store == item.store && installed.sidecar.id == item.id)
resume(context, installed)
}
private fun requireInstalledFolder(context: Context, folder: File) {
val canonical = folder.canonicalFile
require(StoreInstallRoot.gameFolders(context.applicationContext).any { it.canonicalFile == canonical }) {
"Maintenance folder is outside the installed game roots"
}
}
fun launchExcluded(game: InstalledStoreGame): Boolean {
if (!game.sidecar.isInstalled) return true
val gogJournal = if (game.sidecar.store == Store.GOG) File(game.folder, ".droiddeck-gog/operation.json") else null
if (gogJournal?.isFile == true) {
val phase = runCatching { JSONObject(gogJournal.readText()).optString("phase") }.getOrDefault("")
if (phase != "download") return true
}
val queued = com.droiddeck.launcher.stores.download.DownloadQueue.isActive(game.key)
val file = operationFile(game.folder)
val journal = file.exists() && !runCatching { readOperation(game.folder)?.optString("phase") in setOf("queued", "download") }.getOrDefault(false)
return queued || journal
}
}
@@ -7,10 +7,18 @@ import androidx.compose.runtime.setValue
import com.droiddeck.launcher.stores.*
import com.droiddeck.launcher.stores.download.DownloadEntry
import com.droiddeck.launcher.stores.download.DownloadQueue
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.MaintenanceAction
import com.droiddeck.launcher.stores.maintenance.StoreMaintenance
import com.droiddeck.launcher.stores.maintenance.StoreMaintenanceAdapter
import org.json.JSONObject
import java.io.File
object XboxBackend : StoreBackend {
override val store = Store.XBOX
override fun maintenanceAdapter(): StoreMaintenanceAdapter = XboxMaintenanceAdapter
@Volatile private var access: Map<String, String> = emptyMap()
@Volatile private var generation = 0
private var syncing: Int? = null
@@ -148,6 +156,54 @@ object XboxBackend : StoreBackend {
enqueue(context, item, root)
}
internal fun maintenanceCheck(context: Context, game: InstalledStoreGame): MaintenanceCheck {
return try {
if (StoreAccounts.signedInAs(context, store) == null)
return MaintenanceCheck(MaintenanceCheckState.FAILED, reason = "Sign in to Xbox before checking package updates")
val contentId = game.sidecar.extra["contentId"].orEmpty()
if (!contentId.matches(Regex("[a-fA-F0-9-]{36}")))
return MaintenanceCheck(MaintenanceCheckState.UNKNOWN, reason = "The installed Xbox package identity is unknown")
val market = game.sidecar.extra["market"]?.takeIf { it.matches(Regex("[A-Z]{2}")) } ?: Xodus.market()
val plan = Xodus.run(context.applicationContext, listOf("resolve-package", game.sidecar.id, "--json", "--market", market))
if (plan.optString("status") != "ready")
return MaintenanceCheck(MaintenanceCheckState.UNKNOWN, reason = "Xodus did not resolve a current Xbox package")
val choice = XboxCatalog.objects(plan.optJSONArray("choices")).firstOrNull { it.optString("contentId").equals(contentId, true) }
?: return MaintenanceCheck(MaintenanceCheckState.UNKNOWN, reason = "The installed Xbox package is not in the current package choices")
val version = listOf("version", "packageVersion", "package_version").firstNotNullOfOrNull { key -> choice.optString(key).takeIf { it.isNotBlank() } }
?: return MaintenanceCheck(MaintenanceCheckState.UNKNOWN, reason = "Xodus did not expose a current package version")
val installed = runCatching { XboxManifest.read(game.folder).version }.getOrDefault(game.sidecar.installVersion)
if (installed.isBlank()) MaintenanceCheck(MaintenanceCheckState.UNKNOWN, version, "The installed Xbox package version is unknown")
else MaintenanceCheck(if (installed == version) MaintenanceCheckState.CURRENT else MaintenanceCheckState.AVAILABLE, version)
} catch (error: Exception) {
MaintenanceCheck(MaintenanceCheckState.FAILED, reason = error.message ?: "Xbox package check failed")
}
}
internal fun prepareMaintenance(context: Context, game: InstalledStoreGame): MaintenanceProposal {
check(!XboxRuntime.isActive(game.sidecar.id)) { "Close this Xbox game before updating it." }
val contentId = game.sidecar.extra["contentId"].orEmpty()
require(contentId.matches(Regex("[a-fA-F0-9-]{36}"))) { "The installed Xbox package identity is unknown." }
val market = game.sidecar.extra["market"]?.takeIf { it.matches(Regex("[A-Z]{2}")) } ?: Xodus.market()
val version = maintenanceCheck(context, game).takeIf { it.state == MaintenanceCheckState.AVAILABLE }?.version.orEmpty()
val size = MaintenanceProposal.UNKNOWN_BYTES
val target = JSONObject().put("schema", 1).put("contentId", contentId).put("market", market).put("version", version)
return MaintenanceProposal(game.folder.canonicalFile, game.sidecar, version.ifBlank { "latest licensed Xbox package" }, MaintenanceAction.UPDATE,
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) {
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) }
}
private fun enqueue(context: Context, item: CatalogItem, root: File) {
val app = context.applicationContext
val folder = StoreInstallRoot.folderFor(app, store, item.id, item.title, root)
@@ -4,13 +4,15 @@ import android.content.Context
import com.droiddeck.launcher.stores.*
import com.droiddeck.launcher.stores.download.DownloadQueue
import com.droiddeck.launcher.stores.download.DownloadStage
import com.droiddeck.launcher.stores.maintenance.StoreMaintenance
import org.json.JSONObject
import java.io.File
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
internal class XboxInstaller(private val app: Context, private val item: CatalogItem, private val folder: File) : DownloadQueue.DownloadJob {
internal class XboxInstaller(private val app: Context, private val item: CatalogItem, private val folder: File,
private val maintenanceGame: InstalledStoreGame? = null) : DownloadQueue.DownloadJob {
private val cancelled = AtomicBoolean(false)
private val paused = CountDownLatch(1)
@Volatile private var child: Xodus.Child? = null
@@ -32,8 +34,10 @@ internal class XboxInstaller(private val app: Context, private val item: Catalog
folder.mkdirs()
} else {
check(!XboxRuntime.isActive(item.id)) { "Close this Xbox game before updating it." }
StoreInstalls.markUpdating(folder)
StoreInstalls.begin(folder, Store.XBOX, item.id, item.title, item.tallImageUrl, item.imageUrl)
if (maintenanceGame == null) {
StoreInstalls.markUpdating(folder)
StoreInstalls.begin(folder, Store.XBOX, item.id, item.title, item.tallImageUrl, item.imageUrl)
}
}
var complete = false
var sequence = -1L
@@ -41,8 +45,9 @@ internal class XboxInstaller(private val app: Context, private val item: Catalog
val source = if (content) selected else item.id
val guestFolder = StoreInstallRoot.guestPathForXodus(app, folder)
val selection = if (content) emptyList() else listOf("--selected-content-id", selected)
Xodus.start(app, listOf("streaming", source, guestFolder, "--market", market,
"--progress-json", "--control-stdin") + selection).use { process ->
val stream = stream@ {
Xodus.start(app, listOf("streaming", source, guestFolder, "--market", market,
"--progress-json", "--control-stdin") + selection).use { process ->
child = process
val rejected = AtomicBoolean(false)
val rejectedLine = Regex(Xodus.SIGN_IN_REJECTED)
@@ -67,18 +72,25 @@ internal class XboxInstaller(private val app: Context, private val item: Catalog
} }
check(process.process.waitFor(15, TimeUnit.SECONDS)) { "Xbox installer did not exit." }
diagnostics.join(2000)
if (cancelled.get()) return null
if (cancelled.get()) return@stream
check(!rejected.get()) { app.getString(com.droiddeck.launcher.R.string.stores_xbox_sign_in_again) }
check(process.process.exitValue() == 0 && complete) { "Xbox download did not complete. Resume to retry with fresh licenses." }
}
}
if (maintenanceGame != null) StoreMaintenance.withApplyLease(maintenanceGame, stream) else stream()
if (cancelled.get()) return null
if (content) {
XboxContent.complete(app, item, folder)
return folder.path
}
val exe = executable(folder)
StoreInstalls.complete(app, folder, StoreGameSidecar(Store.XBOX, item.id, item.title, exe,
installVersion = XboxManifest.read(folder).version, installedAt = System.currentTimeMillis(), cover = item.tallImageUrl, hero = item.imageUrl,
extra = item.extra + mapOf("contentId" to selected, "market" to market)))
val version = XboxManifest.read(folder).version
if (maintenanceGame != null) {
StoreMaintenance.complete(app, maintenanceGame, version,
maintenanceSidecarExtra(maintenanceGame.sidecar.extra, selected, market))
} else StoreInstalls.complete(app, folder, StoreGameSidecar(Store.XBOX, item.id, item.title, exe,
installVersion = version, installedAt = System.currentTimeMillis(), cover = item.tallImageUrl, hero = item.imageUrl,
extra = maintenanceSidecarExtra(item.extra, selected, market)))
return folder.path
} finally {
child = null
@@ -89,11 +101,11 @@ internal class XboxInstaller(private val app: Context, private val item: Catalog
override fun cancel(deleteFiles: Boolean) {
cancelled.set(true)
delete = deleteFiles
delete = deleteFiles && maintenanceGame == null
val process = child
if (process == null) {
if (deleteFiles && !item.extra["baseId"].isNullOrBlank()) StoreInstalls.deleteTree(folder)
else if (deleteFiles) StoreInstalls.discard(app, folder, emptyList())
if (delete && !item.extra["baseId"].isNullOrBlank()) StoreInstalls.deleteTree(folder)
else if (delete) StoreInstalls.discard(app, folder, emptyList())
return
}
Thread({
@@ -107,5 +119,13 @@ internal class XboxInstaller(private val app: Context, private val item: Catalog
companion object {
internal fun executable(folder: File): String = XboxManifest.executable(folder)
internal fun maintenanceSidecarExtra(before: Map<String, String>, contentId: String, market: String): Map<String, String> =
LinkedHashMap(before).apply {
remove(StoreMaintenance.OPERATION_KEY)
remove(StoreMaintenance.FOLDER_KEY)
put("contentId", contentId)
put("market", market)
}
}
}
@@ -0,0 +1,53 @@
package com.droiddeck.launcher.stores.xbox
import android.content.Context
import com.droiddeck.launcher.stores.InstalledStoreGame
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.maintenance.MaintenanceAction
import com.droiddeck.launcher.stores.maintenance.MaintenanceCheck
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 XboxMaintenanceAdapter : StoreMaintenanceAdapter {
override val store = Store.XBOX
override fun capabilities(game: InstalledStoreGame): Set<MaintenanceAction> =
if (game.sidecar.extra["contentId"].orEmpty().matches(Regex("[a-fA-F0-9-]{36}"))) setOf(MaintenanceAction.UPDATE) else emptySet()
override fun check(context: Context, game: InstalledStoreGame): MaintenanceCheck = XboxBackend.maintenanceCheck(context, game)
override fun prepare(context: Context, game: InstalledStoreGame, action: MaintenanceAction): MaintenanceProposal {
require(action == MaintenanceAction.UPDATE) { "Xbox Repair is not exposed by Xodus" }
return XboxBackend.prepareMaintenance(context, game)
}
override fun enqueue(context: Context, game: InstalledStoreGame, proposal: MaintenanceProposal) {
require(proposal.operation == MaintenanceAction.UPDATE && proposal.target.optInt("schema") == 1)
XboxBackend.enqueueMaintenance(context, game, proposal)
}
override fun resume(context: Context, game: InstalledStoreGame, operation: JSONObject) {
require(operation.optString("phase") in setOf("queued", "download", "applying"))
val target = operation.optJSONObject("target") ?: error("Xbox maintenance target is missing")
require(target.optInt("schema") == 1 && target.optString("contentId").matches(Regex("[a-fA-F0-9-]{36}")))
val proposal = MaintenanceProposal(game.folder.canonicalFile,
com.droiddeck.launcher.stores.StoreGameSidecar.parse(operation.getJSONObject("before").toString())
?: 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)
}
override fun recovery(context: Context, game: InstalledStoreGame): MaintenanceRecovery {
val journal = StoreMaintenance.readOperation(game.folder) ?: return MaintenanceRecovery()
return when (journal.optString("phase")) {
"queued", "download", "applying" -> MaintenanceRecovery(RecoveryState.RESUMABLE, "Resume the Xodus package update")
else -> MaintenanceRecovery(RecoveryState.NEEDS_ATTENTION, "The Xbox package update needs recovery")
}
}
}
@@ -90,6 +90,7 @@ import com.droiddeck.launcher.stores.StoresState
import com.droiddeck.launcher.stores.battlenet.BattleNetInstall
import com.droiddeck.launcher.stores.download.DownloadQueue
import com.droiddeck.launcher.stores.download.DownloadState
import com.droiddeck.launcher.stores.maintenance.StoreMaintenance
import com.droiddeck.launcher.stores.formatBytes
import com.droiddeck.launcher.stores.xbox.XboxBackend
import com.droiddeck.launcher.stores.xbox.XboxCloudGames
@@ -362,27 +363,19 @@ internal fun GameActions(
GameShortcutMenu(game, a)
EditArtButton(game)
}
if (store == Store.ITCH && installed != null) ItchUpdateButton(installed, download == null && StoresState.uninstalling[installed.key] == null)
if (store == Store.ITCH && installed != null && StoreMaintenance.adapter(installed) == null) ItchUpdateButton(installed, download == null && StoresState.uninstalling[installed.key] == null)
if (store == Store.GOG && installed != null) GogMaintenanceActions(installed)
if (store != Store.GOG && installed != null && StoreMaintenance.adapter(installed) != null) StoreMaintenanceActions(installed)
if (store == null && game != null && game.source == Library.SOURCE_STEAM && game.appId > 0) SteamClientMaintenanceActions(a)
if (store == Store.XBOX && id != null) XboxInstalledStream(id, game?.name ?: item?.title.orEmpty(), item)
if (store == Store.XBOX && installed != null && id != null) {
SecondaryButton(stringResource(R.string.stores_xbox_update), enabled = download == null && !XboxRuntime.isActive(id)) {
if (StoreMaintenance.adapter(installed) == null) SecondaryButton(stringResource(R.string.stores_xbox_update), enabled = download == null && !XboxRuntime.isActive(id)) {
runCatching { XboxBackend.update(ctx, installed) }
.onFailure { StoresState.problems[Store.XBOX] = it.message ?: ctx.getString(R.string.stores_xbox_close_first) }
}
SecondaryButton(stringResource(R.string.stores_xbox_manage), enabled = download == null) { XboxContent.manage(ctx, installed) }
}
if (store == Store.BATTLENET) {
if (installed != null && id != null) {
LaunchedEffect(id) { BattleNetInstall.checkUpdate(ctx, installed) }
if (download != null) {
ActionChip(downloadLabel(download), ok = false)
if (download.state != DownloadState.PAUSED) SecondaryButton(stringResource(R.string.stores_dl_pause)) { DownloadQueue.pause(download.key) }
else SecondaryButton(stringResource(R.string.stores_dl_resume)) { DownloadQueue.resume(ctx, download.key) }
} else if (BattleNetInstall.updates[id] == true) {
SecondaryButton(stringResource(R.string.bnet_update)) { BattleNetInstall.update(ctx, installed) }
}
}
if (store == Store.BATTLENET && installed == null) {
SecondaryButton(stringResource(R.string.bnet_open)) { a.onBattleNet() }
}
}
@@ -0,0 +1,180 @@
package com.droiddeck.launcher.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.stores.InstalledStoreGame
import com.droiddeck.launcher.stores.StoresState
import com.droiddeck.launcher.stores.download.DownloadQueue
import com.droiddeck.launcher.stores.maintenance.MaintenanceAction
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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
internal fun SteamClientMaintenanceActions(actions: FrontEndActions) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SecondaryButton("Open Steam downloads") { actions.onSteamUrl("steam://open/downloads") }
SecondaryButton("Open Steam library") { actions.onSteamUrl("steam://nav/library") }
}
Text("In Steam, open this game’s Properties → Installed Files → Verify integrity.",
fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@Composable
internal fun StoreMaintenanceActions(game: InstalledStoreGame) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val adapter = StoreMaintenance.adapter(game) ?: return
val capabilities = adapter.capabilities(game)
val check = StoreMaintenance.checks[game.key]
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()) }
LaunchedEffect(game.folder.path) { StoreMaintenance.check(context, game) }
LaunchedEffect(game.folder.path, active, download?.state) {
recovery = withContext(Dispatchers.IO) { StoreMaintenance.recovery(context, game) }
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
if (active) {
ActionChip(downloadLabel(download!!), ok = false)
if (download.interruptible) SecondaryButton(
stringResource(if (download.state == com.droiddeck.launcher.stores.download.DownloadState.PAUSED) com.droiddeck.launcher.R.string.stores_dl_resume else com.droiddeck.launcher.R.string.stores_dl_pause),
) {
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) {
busy = true
scope.launch {
runCatching { withContext(Dispatchers.IO) { StoreMaintenance.resume(context, game) } }
.onFailure { error = it.message }
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 }
}
}
if (!active && game.sidecar.isInstalled) SecondaryButton("Check", enabled = !busy) {
StoreMaintenance.check(context, game, force = true)
}
}
if (!active && recovery.state == RecoveryState.NEEDS_ATTENTION && recovery.message.isNotBlank())
Text(recovery.message, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
check?.let { current ->
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.UNKNOWN, MaintenanceCheckState.FAILED -> if (current.reason.isNotBlank())
Text(current.reason, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
error?.let { Text(it, fontSize = 12.sp, color = MaterialTheme.colorScheme.error) }
proposal?.let { selected ->
val shown = rememberShown { proposal = null }
val close = { shown.targetState = false }
AppDialog(shown, close, "storeMaintenance", wide = false, maxWidth = 460.dp) {
DialogTitleRow(selected.operation.label(), close)
Column(verticalArrangement = Arrangement.spacedBy(5.dp)) {
Text(game.sidecar.title, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(selected.targetLabel, fontSize = 15.sp, color = MaterialTheme.colorScheme.onBackground)
if (selected.description.isNotBlank()) Text(selected.description, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Row(horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) {
Column(Modifier.weight(1f)) {
Text("Download", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(maintenanceEstimate(selected.downloadBytes), fontSize = 14.sp)
}
Column(Modifier.weight(1f)) {
Text("Required space", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(maintenanceEstimate(selected.diskBytes), fontSize = 14.sp)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
PrimaryButton(selected.operation.label(), enabled = !busy) {
busy = true
close()
scope.launch {
runCatching { withContext(Dispatchers.IO) { StoreMaintenance.enqueue(context, game, selected) } }
.onFailure { error = it.message }
busy = false
}
}
SecondaryButton("Cancel", onClick = close)
}
}
}
}
private fun beginPrepare(
action: MaintenanceAction,
context: android.content.Context,
game: InstalledStoreGame,
scope: kotlinx.coroutines.CoroutineScope,
setBusy: (Boolean) -> Unit,
setProposal: (MaintenanceProposal) -> Unit,
setError: (String?) -> Unit,
) {
setBusy(true)
scope.launch {
runCatching { withContext(Dispatchers.IO) { StoreMaintenance.prepare(context, game, action) } }
.onSuccess(setProposal).onFailure { setError(it.message) }
setBusy(false)
}
}
private fun MaintenanceAction.label(): String = when (this) {
MaintenanceAction.UPDATE -> "Update"
MaintenanceAction.REPAIR -> "Repair files"
MaintenanceAction.REFRESH -> "Refresh installation"
MaintenanceAction.OPEN_CLIENT -> "Open client"
}
private fun maintenanceEstimate(bytes: Long): String =
if (bytes < 0L) "Unknown (client-determined)" else com.droiddeck.launcher.stores.formatBytes(bytes)
+20
View File
@@ -198,6 +198,7 @@ dependencies = [
"serde_json",
"sha1",
"sha2",
"sha3",
"tokio",
]
@@ -593,6 +594,15 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "keccak"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653"
dependencies = [
"cpufeatures",
]
[[package]]
name = "libc"
version = "0.2.186"
@@ -989,6 +999,16 @@ dependencies = [
"digest",
]
[[package]]
name = "sha3"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874"
dependencies = [
"digest",
"keccak",
]
[[package]]
name = "shlex"
version = "1.3.0"
+1
View File
@@ -20,6 +20,7 @@ futures-util = { version = "0.3", default-features = false, features = ["std"] }
serde_json = { version = "1", features = ["preserve_order"] }
sha1 = "0.10"
sha2 = "0.10"
sha3 = "0.10"
[build-dependencies]
cc = "1"
@@ -4,6 +4,10 @@ use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use sha2::{Digest, Sha256};
use sha3::{
digest::{ExtendableOutput, Update},
Shake128,
};
use crate::amazon::manifest::{GameFile, TEMP_SUFFIX};
@@ -15,7 +19,9 @@ pub enum State {
}
pub fn sha256_of(path: &Path, cancel: &AtomicBool) -> Result<Option<[u8; 32]>, ()> {
let Ok(mut f) = fs::File::open(path) else { return Ok(None) };
let Ok(mut f) = fs::File::open(path) else {
return Ok(None);
};
let mut hasher = Sha256::new();
let mut buf = vec![0u8; 1024 * 1024];
loop {
@@ -24,22 +30,73 @@ pub fn sha256_of(path: &Path, cancel: &AtomicBool) -> Result<Option<[u8; 32]>, (
}
match f.read(&mut buf) {
Ok(0) => return Ok(Some(hasher.finalize().into())),
Ok(n) => hasher.update(&buf[..n]),
Ok(n) => Digest::update(&mut hasher, &buf[..n]),
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
Err(_) => return Ok(None),
}
}
}
pub fn hash_matches(path: &Path, file: &GameFile, cancel: &AtomicBool) -> Result<Option<bool>, ()> {
if !file.has_supported_hash() {
return Ok(None);
}
let Ok(mut f) = fs::File::open(path) else {
return Ok(None);
};
let mut buf = vec![0u8; 1024 * 1024];
match file.algorithm {
0 => {
let mut h = Sha256::new();
loop {
if cancel.load(Ordering::Relaxed) {
return Err(());
}
match f.read(&mut buf) {
Ok(0) => break,
Ok(n) => Digest::update(&mut h, &buf[..n]),
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
Err(_) => return Ok(None),
}
}
Ok(Some(h.finalize().as_slice() == file.digest.as_slice()))
}
1 => {
let mut h = Shake128::default();
loop {
if cancel.load(Ordering::Relaxed) {
return Err(());
}
match f.read(&mut buf) {
Ok(0) => break,
Ok(n) => Update::update(&mut h, &buf[..n]),
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
Err(_) => return Ok(None),
}
}
let mut reader = h.finalize_xof();
let mut got = vec![0u8; file.digest.len()];
std::io::Read::read(&mut reader, &mut got).map_err(|_| ())?;
Ok(Some(got == file.digest))
}
_ => Ok(None),
}
}
pub fn state(dest: &Path, file: &GameFile, cancel: &AtomicBool) -> State {
if file.size == 0 && file.digest.is_empty() {
return match fs::symlink_metadata(dest) {
Ok(m) if m.is_file() && m.len() == 0 => State::Complete,
_ => State::Pending,
};
}
match fs::symlink_metadata(dest) {
Ok(m) if m.is_file() && m.len() == file.size => {}
_ => return State::Pending,
}
let Some(expected) = file.sha256() else { return State::Complete };
match sha256_of(dest, cancel) {
match hash_matches(dest, file, cancel) {
Err(()) => State::Cancelled,
Ok(Some(got)) if got == expected => State::Complete,
Ok(Some(true)) => State::Complete,
Ok(_) => State::Pending,
}
}
@@ -54,7 +111,11 @@ pub fn remove_stray_temps(game_dir: &Path, files: &[GameFile]) -> usize {
files
.iter()
.map(|f| temp_path(&game_dir.join(&f.path)))
.filter(|t| fs::symlink_metadata(t).map(|m| m.is_file() || m.file_type().is_symlink()).unwrap_or(false))
.filter(|t| {
fs::symlink_metadata(t)
.map(|m| m.is_file() || m.file_type().is_symlink())
.unwrap_or(false)
})
.filter(|t| fs::remove_file(t).is_ok())
.count()
}
@@ -73,7 +134,9 @@ pub fn free_bytes(path: &Path) -> i64 {
#[cfg(unix)]
fn available(path: &Path) -> i64 {
use std::os::unix::ffi::OsStrExt;
let Ok(c) = std::ffi::CString::new(path.as_os_str().as_bytes()) else { return -1 };
let Ok(c) = std::ffi::CString::new(path.as_os_str().as_bytes()) else {
return -1;
};
let mut st: libc::statvfs = unsafe { std::mem::zeroed() };
if unsafe { libc::statvfs(c.as_ptr(), &mut st) } != 0 {
return -1;
@@ -111,7 +174,12 @@ mod tests {
use super::*;
fn file(path: &str, size: u64, digest: Vec<u8>) -> GameFile {
GameFile { path: path.into(), size, algorithm: 0, digest }
GameFile {
path: path.into(),
size,
algorithm: 0,
digest,
}
}
#[test]
@@ -123,15 +191,76 @@ mod tests {
fs::write(&p, b"hello").unwrap();
let good = Sha256::digest(b"hello").to_vec();
let cancel = AtomicBool::new(false);
assert_eq!(state(&p, &file("a.bin", 5, good.clone()), &cancel), State::Complete);
assert_eq!(state(&p, &file("a.bin", 5, Sha256::digest(b"HELLO").to_vec()), &cancel), State::Pending);
assert_eq!(state(&p, &file("a.bin", 6, good.clone()), &cancel), State::Pending);
let other = GameFile { path: "a.bin".into(), size: 5, algorithm: 3, digest: vec![1, 2] };
assert_eq!(state(&p, &other, &cancel), State::Complete, "no SHA-256: size decides");
assert_eq!(state(&dir, &file("x", 0, vec![]), &cancel), State::Pending, "a directory is not a file");
assert_eq!(state(&dir.join("missing"), &file("m", 0, vec![]), &cancel), State::Pending);
assert_eq!(
state(&p, &file("a.bin", 5, good.clone()), &cancel),
State::Complete
);
assert_eq!(
state(
&p,
&file("a.bin", 5, Sha256::digest(b"HELLO").to_vec()),
&cancel
),
State::Pending
);
assert_eq!(
state(&p, &file("a.bin", 6, good.clone()), &cancel),
State::Pending
);
let other = GameFile {
path: "a.bin".into(),
size: 5,
algorithm: 3,
digest: vec![1, 2],
};
assert_eq!(
state(&p, &other, &cancel),
State::Pending,
"unsupported hashes cannot establish file integrity"
);
assert_eq!(
state(&dir, &file("x", 0, vec![]), &cancel),
State::Pending,
"a directory is not a file"
);
assert_eq!(
state(&dir.join("missing"), &file("m", 0, vec![]), &cancel),
State::Pending
);
cancel.store(true, Ordering::Relaxed);
assert_eq!(state(&p, &file("a.bin", 5, good), &cancel), State::Cancelled);
assert_eq!(
state(&p, &file("a.bin", 5, good), &cancel),
State::Cancelled
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn shake128_checks_the_manifest_digest_length() {
use sha3::{
digest::{ExtendableOutput, Update},
Shake128,
};
let dir = std::env::temp_dir().join(format!("dd-amazon-shake-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let p = dir.join("a.bin");
fs::write(&p, b"hello").unwrap();
let mut hasher = Shake128::default();
Update::update(&mut hasher, b"hello");
let mut reader = hasher.finalize_xof();
let mut digest = vec![0; 32];
std::io::Read::read(&mut reader, &mut digest).unwrap();
let file = GameFile {
path: "a.bin".into(),
size: 5,
algorithm: 1,
digest: digest.clone(),
};
let cancel = AtomicBool::new(false);
assert_eq!(state(&p, &file, &cancel), State::Complete);
fs::write(&p, b"HELLO").unwrap();
assert_eq!(state(&p, &file, &cancel), State::Pending);
let _ = fs::remove_dir_all(&dir);
}
@@ -142,7 +271,10 @@ mod tests {
fs::create_dir_all(dir.join("sub")).unwrap();
fs::write(dir.join("sub/a.bin.tmp"), b"x").unwrap();
fs::write(dir.join("save.tmp"), b"keep").unwrap();
let files = vec![file("sub/a.bin", 1, vec![0; 32]), file("b.bin", 1, vec![0; 32])];
let files = vec![
file("sub/a.bin", 1, vec![0; 32]),
file("b.bin", 1, vec![0; 32]),
];
assert_eq!(temp_path(&dir.join("sub/a.bin")), dir.join("sub/a.bin.tmp"));
assert_eq!(remove_stray_temps(&dir, &files), 1);
assert!(!dir.join("sub/a.bin.tmp").exists());
@@ -21,7 +21,10 @@ pub const NOT_SIGNED_IN: &str = "Not signed in to Amazon Games";
pub const NO_DOWNLOAD: &str = "Amazon gave no download for this game";
pub const NO_MANIFEST: &str = "The manifest could not be downloaded";
pub const FILE_FAILED: &str = "A file failed to download";
pub const REPLACE_REFUSED: &str = "The install record could not be switched to updating; nothing was replaced";
pub const NO_VERIFIABLE_HASH: &str =
"Amazon's manifest uses a file hash that DroidDeck cannot verify";
pub const REPLACE_REFUSED: &str =
"The install record could not be switched to updating; nothing was replaced";
const CHECK_REPORT_EVERY: Duration = Duration::from_millis(200);
@@ -66,6 +69,8 @@ pub struct Request {
pub access_token: String,
pub entitlement_id: String,
pub game_dir: PathBuf,
pub target_manifest_path: Option<PathBuf>,
pub target_version_id: Option<String>,
pub max_workers: usize,
pub process_workers: usize,
pub ca_bundle: String,
@@ -89,6 +94,14 @@ impl Request {
access_token: access_token.to_string(),
entitlement_id: entitlement_id.to_string(),
game_dir: PathBuf::from(game_dir),
target_manifest_path: match net::str_of(v, "targetManifestPath") {
"" => None,
path => Some(PathBuf::from(path)),
},
target_version_id: match net::str_of(v, "targetVersionId") {
"" => None,
version => Some(version.to_string()),
},
max_workers: v.get("maxWorkers").and_then(|n| n.as_u64()).unwrap_or(8) as usize,
process_workers: v.get("processWorkers").and_then(|n| n.as_u64()).unwrap_or(2) as usize,
ca_bundle: net::str_of(v, "caBundle").to_string(),
@@ -125,11 +138,12 @@ fn cancelled(cancel: &AtomicBool) -> Result<(), Stop> {
}
}
pub fn load_manifest(download: &Download, access_token: &str, log: &dyn Fn(&str)) -> Result<manifest::Manifest, String> {
let bytes = cdn::fetch_manifest(&download.base_url, access_token).map_err(|e| {
log(&format!("manifest download: {e}"));
NO_MANIFEST.to_string()
})?;
pub fn load_manifest(
download: &Download,
access_token: &str,
log: &dyn Fn(&str),
) -> Result<manifest::Manifest, String> {
let bytes = load_manifest_bytes(download, access_token, log)?;
manifest::parse(&bytes).map_err(|e| {
log(&format!("manifest parse: {e}"));
if e.starts_with("The manifest") {
@@ -140,6 +154,166 @@ pub fn load_manifest(download: &Download, access_token: &str, log: &dyn Fn(&str)
})
}
fn load_manifest_bytes(
download: &Download,
access_token: &str,
log: &dyn Fn(&str),
) -> Result<Vec<u8>, String> {
cdn::fetch_manifest(&download.base_url, access_token).map_err(|e| {
log(&format!("manifest download: {e}"));
NO_MANIFEST.to_string()
})
}
fn metadata_dir(game_dir: &PathBuf) -> PathBuf {
game_dir.join(".droiddeck-maintenance/amazon")
}
fn read_target_manifest(req: &Request) -> Result<Option<Vec<u8>>, String> {
let Some(path) = &req.target_manifest_path else {
return Ok(None);
};
let root = req
.game_dir
.canonicalize()
.map_err(|e| format!("Could not resolve the install folder: {e}"))?;
if fs::symlink_metadata(path)
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
{
return Err("The pinned Amazon manifest is a symbolic link".into());
}
let candidate = path
.canonicalize()
.map_err(|e| format!("The pinned Amazon manifest is unavailable: {e}"))?;
if !candidate.starts_with(&root) || !candidate.is_file() {
return Err("The pinned Amazon manifest is outside the install folder".into());
}
fs::read(candidate)
.map(Some)
.map_err(|e| format!("Could not read the pinned Amazon manifest: {e}"))
}
fn write_installed_manifest(game_dir: &PathBuf, bytes: &[u8]) -> Result<(), String> {
let metadata = game_dir.join(".droiddeck-maintenance");
if fs::symlink_metadata(&metadata)
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
{
return Err("Amazon maintenance metadata is a symbolic link".into());
}
let dir = metadata_dir(game_dir);
if fs::symlink_metadata(&dir)
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
{
return Err("Amazon maintenance metadata is a symbolic link".into());
}
fs::create_dir_all(&dir)
.map_err(|e| format!("Could not save the Amazon install manifest: {e}"))?;
let temp = dir.join("installed.manifest.tmp");
if fs::symlink_metadata(&temp)
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
{
return Err("Amazon maintenance metadata is a symbolic link".into());
}
fs::write(&temp, bytes)
.map_err(|e| format!("Could not save the Amazon install manifest: {e}"))?;
fs::rename(&temp, dir.join("installed.manifest"))
.map_err(|e| format!("Could not save the Amazon install manifest: {e}"))
}
fn read_installed_manifest(game_dir: &PathBuf, log: &dyn Fn(&str)) -> Option<manifest::Manifest> {
let path = metadata_dir(game_dir).join("installed.manifest");
match fs::symlink_metadata(&path) {
Ok(meta) if meta.is_file() && !meta.file_type().is_symlink() => {}
Ok(_) => {
log("obsolete Amazon files preserved: retained manifest is not a regular file");
return None;
}
Err(_) => return None,
}
match fs::read(path).ok().and_then(|bytes| manifest::parse(&bytes).ok()) {
Some(manifest) => Some(manifest),
None => {
log("obsolete Amazon files preserved: retained manifest is unavailable or invalid");
None
}
}
}
fn cleanup_obsolete(
game_dir: &PathBuf,
old: &[GameFile],
new: &[GameFile],
cancel: &AtomicBool,
log: &dyn Fn(&str),
) -> bool {
let key = |path: &str| -> Option<String> {
let normalized = manifest::check_path(path).ok()?;
Some(normalized.to_lowercase())
};
let inventory = |files: &[GameFile]| -> Option<std::collections::HashSet<String>> {
let mut out = std::collections::HashSet::new();
for file in files {
if !out.insert(key(&file.path)?) {
return None;
}
}
Some(out)
};
let Some(old_keys) = inventory(old) else {
log("obsolete Amazon files preserved: old inventory contains unsafe or case-colliding paths");
return cancel.load(Ordering::Relaxed);
};
let Some(new_keys) = inventory(new) else {
log("obsolete Amazon files preserved: target inventory contains unsafe or case-colliding paths");
return cancel.load(Ordering::Relaxed);
};
let _ = old_keys;
let Ok(root) = game_dir.canonicalize() else {
log("obsolete Amazon files preserved: install folder could not be resolved");
return cancel.load(Ordering::Relaxed);
};
let mut removed = 0usize;
let mut preserved = 0usize;
for file in old {
if cancel.load(Ordering::Relaxed) {
log("obsolete Amazon cleanup cancelled; remaining files were preserved");
return true;
}
let Some(k) = key(&file.path) else { preserved += 1; continue };
if new_keys.contains(&k) { continue; }
if !file.has_supported_hash() && !(file.size == 0 && file.digest.is_empty()) {
preserved += 1;
continue;
}
let mut path = game_dir.clone();
let mut safe = true;
for segment in file.path.split('/') {
path.push(segment);
if fs::symlink_metadata(&path).map(|m| m.file_type().is_symlink()).unwrap_or(false) {
safe = false;
break;
}
}
let Ok(metadata) = fs::symlink_metadata(&path) else { continue };
let Ok(canonical) = path.canonicalize() else { preserved += 1; continue };
if !safe || !metadata.is_file() || !canonical.starts_with(&root) {
preserved += 1;
continue;
}
if check::state(&canonical, file, cancel) != check::State::Complete {
preserved += 1;
continue;
}
if fs::remove_file(&canonical).is_ok() { removed += 1; } else { preserved += 1; }
}
log(&format!("obsolete Amazon cleanup removed={removed} preserved={preserved}"));
cancel.load(Ordering::Relaxed)
}
pub fn run(req: &Request, cancel: &AtomicBool, ev: &dyn Events) -> Result<Finished, Stop> {
ev.status("Asking Amazon for the download…");
fs::create_dir_all(&req.game_dir).map_err(|e| Stop::Failed(format!("Could not create the install folder: {e}")))?;
@@ -156,7 +330,12 @@ fn empty_sha256() -> [u8; 32] {
Sha256::digest(b"").into()
}
pub fn run_download(req: &Request, download: &Download, cancel: &AtomicBool, ev: &dyn Events) -> Result<Finished, Stop> {
pub fn run_download(
req: &Request,
download: &Download,
cancel: &AtomicBool,
ev: &dyn Events,
) -> Result<Finished, Stop> {
let failed = Stop::Failed;
let started = Instant::now();
fs::create_dir_all(&req.game_dir).map_err(|e| failed(format!("Could not create the install folder: {e}")))?;
@@ -164,7 +343,16 @@ pub fn run_download(req: &Request, download: &Download, cancel: &AtomicBool, ev:
ev.status("Downloading manifest…");
let log = |line: &str| ev.log(line);
let manifest = load_manifest(download, &req.access_token, &log).map_err(failed)?;
let pinned_bytes = read_target_manifest(req).map_err(failed)?;
let manifest_bytes = match pinned_bytes {
Some(bytes) => bytes,
None => load_manifest_bytes(download, &req.access_token, &log).map_err(failed)?,
};
let manifest = manifest::parse(&manifest_bytes).map_err(|e| {
ev.log(&format!("manifest parse: {e}"));
failed(format!("The manifest could not be read: {e}"))
})?;
let installed_manifest = read_installed_manifest(&req.game_dir, &log);
cancelled(cancel)?;
for w in &manifest.warnings {
ev.log(w);
@@ -216,6 +404,9 @@ pub fn run_download(req: &Request, download: &Download, cancel: &AtomicBool, ev:
let (local, remote): (Vec<&GameFile>, Vec<&GameFile>) = pending
.into_iter()
.partition(|f| f.size == 0 && (f.digest.is_empty() || f.sha256() == Some(empty)));
if remote.iter().any(|f| !f.has_supported_hash()) {
return Err(failed(NO_VERIFIABLE_HASH.into()));
}
for f in &local {
let dest = req.game_dir.join(&f.path);
if let Some(parent) = dest.parent() {
@@ -240,7 +431,8 @@ pub fn run_download(req: &Request, download: &Download, cancel: &AtomicBool, ev:
path: f.path.clone(),
dest: req.game_dir.join(&f.path),
size: f.size,
sha256: f.sha256(),
algorithm: f.algorithm,
digest: f.digest.clone(),
url: cdn::file_url(&download.base_url, &f.cdn_name()),
})
.collect();
@@ -273,10 +465,24 @@ pub fn run_download(req: &Request, download: &Download, cancel: &AtomicBool, ev:
));
let stats = result?;
let cleanup_cancelled = if let Some(installed) = installed_manifest.as_ref() {
cleanup_obsolete(&req.game_dir, &installed.files, &manifest.files, cancel, &log)
} else {
ev.log("obsolete Amazon files preserved: no retained prior manifest is available");
cancel.load(Ordering::Relaxed)
};
if cleanup_cancelled || cancel.load(Ordering::Relaxed) {
return Err(Stop::Cancelled);
}
let _ = fs::remove_file(req.game_dir.join(IN_PROGRESS_MARKER));
let _ = fs::File::create(req.game_dir.join(COMPLETE_MARKER));
write_installed_manifest(&req.game_dir, &manifest_bytes).map_err(failed)?;
Ok(Finished {
version_id: download.version_id.clone(),
version_id: req
.target_version_id
.as_deref()
.unwrap_or(&download.version_id)
.to_string(),
total_bytes: total,
file_count: manifest.files.len(),
files_written: stats.files_written + local.len(),
@@ -285,8 +491,12 @@ pub fn run_download(req: &Request, download: &Download, cancel: &AtomicBool, ev:
}
pub fn size_of(access_token: &str, entitlement_id: &str) -> u64 {
let Ok(download) = distribution::game_download(access_token, entitlement_id) else { return 0 };
load_manifest(&download, access_token, &|_: &str| {}).map(|m| m.total_size()).unwrap_or(0)
let Ok(download) = distribution::game_download(access_token, entitlement_id) else {
return 0;
};
load_manifest(&download, access_token, &|_: &str| {})
.map(|m| m.total_size())
.unwrap_or(0)
}
#[cfg(test)]
@@ -39,7 +39,11 @@ fn serve(files: HashMap<String, Vec<u8>>) -> Server {
std::thread::spawn(move || answer(stream, &files, &seen));
}
});
Server { base: format!("http://{addr}/v/abc?sig=s%2Bt&e=1"), seen, files }
Server {
base: format!("http://{addr}/v/abc?sig=s%2Bt&e=1"),
seen,
files,
}
}
fn answer(mut stream: TcpStream, files: &Mutex<HashMap<String, Vec<u8>>>, seen: &Mutex<Seen>) {
@@ -136,15 +140,26 @@ fn fixture_with(paths: &[(&'static str, Vec<u8>)]) -> Fixture {
let mut packages = vec![Vec::new(), Vec::new()];
for (i, (path, bytes)) in paths.iter().enumerate() {
let digest: &[u8] = if bytes.is_empty() { &[] } else { &digests[i] };
packages[i % 2].push(Entry { path, size: bytes.len() as u64, algorithm: Some(0), digest });
packages[i % 2].push(Entry {
path,
size: bytes.len() as u64,
algorithm: Some(0),
digest,
});
}
let manifest = frame(&message(&packages), b"\x08\x01\x12\x00", true);
let mut files = HashMap::new();
files.insert("/v/abc/manifest.proto".to_string(), manifest);
for (i, (_, bytes)) in paths.iter().enumerate() {
files.insert(format!("/v/abc/files/{}", net::hex_lower(&digests[i])), bytes.clone());
files.insert(
format!("/v/abc/files/{}", net::hex_lower(&digests[i])),
bytes.clone(),
);
}
Fixture {
server: serve(files),
expected: paths.iter().map(|(p, b)| (*p, b.clone())).collect(),
}
Fixture { server: serve(files), expected: paths.iter().map(|(p, b)| (*p, b.clone())).collect() }
}
fn fixture() -> Fixture {
@@ -153,15 +168,38 @@ fn fixture() -> Fixture {
("Data\\pak0.pak", pattern(2, 70_000)),
("Data\\Sub\\readme.txt", b"hello".to_vec()),
("Data\\empty.cfg", Vec::new()),
("fuel.json", br#"{"Main":{"Command":"Game.exe","Args":["-w"]}}"#.to_vec()),
(
"fuel.json",
br#"{"Main":{"Command":"Game.exe","Args":["-w"]}}"#.to_vec(),
),
])
}
fn unsupported_hash_fixture() -> Fixture {
let body = b"trusted old file".to_vec();
let message = message(&[vec![Entry {
path: "Game.exe",
size: body.len() as u64,
algorithm: Some(7),
digest: &[1, 2],
}]]);
let manifest = frame(&message, b"\x08\x01\x12\x00", true);
let mut files = HashMap::new();
files.insert("/v/abc/manifest.proto".to_string(), manifest);
files.insert("/v/abc/files/0102".to_string(), vec![0; body.len()]);
Fixture {
server: serve(files),
expected: vec![("Game.exe", body)],
}
}
fn request(game: &Path) -> Request {
Request {
access_token: "token-1".into(),
entitlement_id: "ent".into(),
game_dir: game.to_path_buf(),
target_manifest_path: None,
target_version_id: None,
max_workers: 4,
process_workers: 2,
ca_bundle: String::new(),
@@ -169,7 +207,10 @@ fn request(game: &Path) -> Request {
}
fn download(fx: &Fixture) -> Download {
Download { base_url: fx.server.base.clone(), version_id: "ver-9".into() }
Download {
base_url: fx.server.base.clone(),
version_id: "ver-9".into(),
}
}
fn temp_game(name: &str) -> PathBuf {
@@ -217,6 +258,11 @@ fn fresh_install_then_rerun_then_repair() {
assert!(!ev.backwards.load(Ordering::SeqCst));
assert!(game.join(COMPLETE_MARKER).exists());
assert!(!game.join(IN_PROGRESS_MARKER).exists());
let installed_manifest = game.join(".droiddeck-maintenance/amazon/installed.manifest");
assert!(
installed_manifest.is_file(),
"the exact installed manifest is retained without CDN credentials"
);
assert!(no_temps(&game));
{
let seen = fx.server.seen.lock().unwrap();
@@ -228,6 +274,26 @@ fn fresh_install_then_rerun_then_repair() {
}
let before = requests(&fx);
let mut pinned = request(&game);
pinned.target_manifest_path = Some(installed_manifest.clone());
pinned.target_version_id = Some("ver-installed".into());
let pinned_events = Recorder::default();
let pinned_result = run_download(&pinned, &download(&fx), &cancel, &pinned_events).unwrap();
assert_eq!(
pinned_result.version_id, "ver-installed",
"repair reports the pinned installed target"
);
assert_eq!(
pinned_events.gate_calls.load(Ordering::SeqCst),
0,
"an already-correct repair writes no files"
);
assert_eq!(
requests(&fx),
before,
"a pinned repair uses its retained manifest and downloads no current manifest"
);
let again = Recorder::default();
run_download(&request(&game), &download(&fx), &cancel, &again).unwrap();
assert_eq!(requests(&fx) - before, 1);
@@ -262,7 +328,61 @@ fn a_corrupt_file_fails_the_run_and_leaves_no_temporary_files() {
assert!(!game.join("Data/pak0.pak").exists());
assert!(no_temps(&game));
assert!(game.join(IN_PROGRESS_MARKER).exists());
assert!(ev.logs.lock().unwrap().iter().any(|l| l.contains("SHA-256 mismatch")));
assert!(ev
.logs
.lock()
.unwrap()
.iter()
.any(|l| l.contains("file hash mismatch")));
let _ = fs::remove_dir_all(&game);
}
#[test]
fn obsolete_cleanup_removes_only_unchanged_prior_manifest_files() {
let game = temp_game("obsolete-cleanup");
fs::create_dir_all(&game).unwrap();
fs::write(game.join("old.bin"), b"old").unwrap();
fs::write(game.join("modified.bin"), b"user edit").unwrap();
fs::write(game.join("untracked.bin"), b"untracked").unwrap();
let old = vec![
GameFile { path: "old.bin".into(), size: 3, algorithm: 0, digest: Sha256::digest(b"old").to_vec() },
GameFile { path: "modified.bin".into(), size: 8, algorithm: 0, digest: Sha256::digest(b"original").to_vec() },
];
let new = vec![GameFile { path: "current.bin".into(), size: 0, algorithm: 0, digest: vec![] }];
let cancel = AtomicBool::new(false);
assert!(!cleanup_obsolete(&game, &old, &new, &cancel, &|_| {}));
assert!(!game.join("old.bin").exists());
assert_eq!(fs::read(game.join("modified.bin")).unwrap(), b"user edit");
assert_eq!(fs::read(game.join("untracked.bin")).unwrap(), b"untracked");
let _ = fs::remove_dir_all(&game);
}
#[test]
fn obsolete_cleanup_cancellation_preserves_the_old_inventory() {
let game = temp_game("obsolete-cancel");
fs::create_dir_all(&game).unwrap();
fs::write(game.join("old.bin"), b"old").unwrap();
let old = vec![GameFile { path: "old.bin".into(), size: 3, algorithm: 0, digest: Sha256::digest(b"old").to_vec() }];
let new = vec![GameFile { path: "current.bin".into(), size: 0, algorithm: 0, digest: vec![] }];
let cancel = AtomicBool::new(true);
assert!(cleanup_obsolete(&game, &old, &new, &cancel, &|_| {}));
assert_eq!(fs::read(game.join("old.bin")).unwrap(), b"old");
let _ = fs::remove_dir_all(&game);
}
#[test]
fn unsupported_hash_does_not_trust_size_or_replace_existing_content() {
let fx = unsupported_hash_fixture();
let game = temp_game("unsupported-hash");
fs::create_dir_all(&game).unwrap();
fs::write(game.join("Game.exe"), &fx.expected[0].1).unwrap();
let cancel = AtomicBool::new(false);
let ev = Recorder::default();
let err = run_download(&request(&game), &download(&fx), &cancel, &ev).unwrap_err();
assert_eq!(err, Stop::Failed(NO_VERIFIABLE_HASH.into()));
assert_eq!(fs::read(game.join("Game.exe")).unwrap(), fx.expected[0].1);
assert_eq!(ev.gate_calls.load(Ordering::SeqCst), 0);
assert_eq!(requests(&fx), 1, "only the signed manifest was requested");
let _ = fs::remove_dir_all(&game);
}
@@ -290,14 +410,23 @@ fn cancel_and_bad_manifests() {
let bad = fixture_with(&[("..\\escape.dll", b"x".to_vec())]);
let cancel = AtomicBool::new(false);
let err = run_download(&request(&game), &download(&bad), &cancel, &Recorder::default()).unwrap_err();
let err = run_download(
&request(&game),
&download(&bad),
&cancel,
&Recorder::default(),
)
.unwrap_err();
match err {
Stop::Failed(m) => assert!(m.contains("..\\escape.dll"), "{m}"),
other => panic!("{other:?}"),
}
assert!(!game.parent().unwrap().join("escape.dll").exists());
let missing = Download { base_url: format!("{}x", fx.server.base.split('?').next().unwrap()), version_id: String::new() };
let missing = Download {
base_url: format!("{}x", fx.server.base.split('?').next().unwrap()),
version_id: String::new(),
};
let err = run_download(&request(&game), &missing, &cancel, &Recorder::default()).unwrap_err();
assert_eq!(err, Stop::Failed(NO_MANIFEST.into()));
let _ = fs::remove_dir_all(&game);
@@ -305,7 +434,10 @@ fn cancel_and_bad_manifests() {
#[test]
fn requests_from_json() {
let ok = Request::from_json(&json!({"accessToken":"t","entitlementId":"e","gameDir":"/g","maxWorkers":16})).unwrap();
let ok = Request::from_json(
&json!({"accessToken":"t","entitlementId":"e","gameDir":"/g","maxWorkers":16}),
)
.unwrap();
assert_eq!((ok.max_workers, ok.process_workers), (16, 2));
assert!(Request::from_json(&json!({"accessToken":"t","entitlementId":"e","gameDir":"rel"})).is_err());
assert_eq!(Request::from_json(&json!({"entitlementId":"e","gameDir":"/g"})).err().unwrap(), NOT_SIGNED_IN);
@@ -6,10 +6,16 @@ use std::sync::Mutex;
use std::time::{Duration, Instant};
use sha2::{Digest, Sha256};
use sha3::{
digest::{ExtendableOutput, Update, XofReader},
Shake128,
};
use super::check::temp_path;
use super::{Events, ReplaceGate, Stop};
use crate::fetch_core::{distinct_host_count, run_fetch, FetchItem, FetchOptions, FetchSink, SinkError};
use crate::fetch_core::{
distinct_host_count, run_fetch, FetchItem, FetchOptions, FetchSink, SinkError,
};
const PROGRESS_EVERY_MS: u64 = 50;
const REQUEST_TIMEOUT: Duration = Duration::from_secs(120);
@@ -18,10 +24,44 @@ pub struct Job {
pub path: String,
pub dest: PathBuf,
pub size: u64,
pub sha256: Option<[u8; 32]>,
pub algorithm: u64,
pub digest: Vec<u8>,
pub url: String,
}
enum HashState {
Sha256(Sha256),
Shake128(Shake128),
}
impl HashState {
fn for_algorithm(algorithm: u64) -> Self {
match algorithm {
1 => Self::Shake128(Shake128::default()),
_ => Self::Sha256(Sha256::new()),
}
}
fn update(&mut self, data: &[u8]) {
match self {
Self::Sha256(h) => Digest::update(h, data),
Self::Shake128(h) => Update::update(h, data),
}
}
fn matches(self, digest: &[u8]) -> bool {
match self {
Self::Sha256(h) => h.finalize().as_slice() == digest,
Self::Shake128(h) => {
let mut reader = h.finalize_xof();
let mut got = vec![0; digest.len()];
reader.read(&mut got);
got == digest
}
}
}
}
pub struct Settings {
pub max_workers: usize,
pub process_workers: usize,
@@ -47,7 +87,7 @@ pub struct Stats {
struct Active {
file: File,
tmp: PathBuf,
hasher: Sha256,
hasher: HashState,
written: u64,
}
@@ -113,7 +153,12 @@ impl Placer<'_> {
}
let tmp = temp_path(&job.dest);
let file = File::create(&tmp).map_err(|e| format!("Could not write {}: {e}", job.path))?;
Ok(Active { file, tmp, hasher: Sha256::new(), written: 0 })
Ok(Active {
file,
tmp,
hasher: HashState::for_algorithm(job.algorithm),
written: 0,
})
}
fn piece(&self, i: usize, offset: u64, data: &[u8]) -> Result<(), SinkError> {
@@ -124,7 +169,10 @@ impl Placer<'_> {
self.in_flight.fetch_sub(old.written, Ordering::Relaxed);
discard(old);
}
*slot = Some(self.start(job).map_err(|e| SinkError::Fatal(self.fail(e)))?);
*slot = Some(
self.start(job)
.map_err(|e| SinkError::Fatal(self.fail(e)))?,
);
}
let Some(active) = slot.as_mut() else {
return Err(SinkError::Retry(format!("{}: data arrived before the start of the file", job.path)));
@@ -135,10 +183,9 @@ impl Placer<'_> {
if active.written + data.len() as u64 > job.size {
return Err(SinkError::Retry(format!("{}: the CDN sent more than the manifest size", job.path)));
}
active
.file
.write_all(data)
.map_err(|e| SinkError::Fatal(self.fail(format!("Could not write {}: {e}", job.path))))?;
active.file.write_all(data).map_err(|e| {
SinkError::Fatal(self.fail(format!("Could not write {}: {e}", job.path)))
})?;
active.hasher.update(data);
active.written += data.len() as u64;
self.in_flight.fetch_add(data.len() as u64, Ordering::Relaxed);
@@ -161,19 +208,23 @@ impl Placer<'_> {
discard(active);
return Err(SinkError::Retry(format!("{}: {got} bytes arrived, the manifest says {}", job.path, job.size)));
}
let Active { file, tmp, hasher, .. } = active;
if let Some(expected) = job.sha256 {
let got: [u8; 32] = hasher.finalize().into();
if got != expected {
drop(file);
let _ = fs::remove_file(&tmp);
return Err(SinkError::Retry(format!("{}: SHA-256 mismatch", job.path)));
}
let Active {
file, tmp, hasher, ..
} = active;
if !hasher.matches(&job.digest) {
drop(file);
let _ = fs::remove_file(&tmp);
return Err(SinkError::Retry(format!(
"{}: file hash mismatch",
job.path
)));
}
if let Err(e) = file.sync_all() {
drop(file);
let _ = fs::remove_file(&tmp);
return Err(SinkError::Fatal(self.fail(format!("Could not write {}: {e}", job.path))));
return Err(SinkError::Fatal(
self.fail(format!("Could not write {}: {e}", job.path)),
));
}
drop(file);
if self.stop.load(Ordering::SeqCst) {
@@ -186,7 +237,9 @@ impl Placer<'_> {
}
if let Err(e) = fs::rename(&tmp, &job.dest) {
let _ = fs::remove_file(&tmp);
return Err(SinkError::Fatal(self.fail(format!("Could not put {} in place: {e}", job.path))));
return Err(SinkError::Fatal(
self.fail(format!("Could not put {} in place: {e}", job.path)),
));
}
self.placed[i].store(true, Ordering::SeqCst);
self.placed_bytes.fetch_add(job.size, Ordering::Relaxed);
@@ -213,11 +266,23 @@ impl FetchSink for Sink<'_, '_> {
}
pub fn worker_counts(max_workers: usize, process_workers: usize) -> (usize, usize) {
let cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(2);
(max_workers.clamp(1, 128), process_workers.max(cores / 2).max(1))
let cores = std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(2);
(
max_workers.clamp(1, 128),
process_workers.max(cores / 2).max(1),
)
}
pub fn run(jobs: &[Job], settings: &Settings, base: Baseline, gate: &ReplaceGate, stop: &AtomicBool, events: &dyn Events) -> Result<Stats, Stop> {
pub fn run(
jobs: &[Job],
settings: &Settings,
base: Baseline,
gate: &ReplaceGate,
stop: &AtomicBool,
events: &dyn Events,
) -> Result<Stats, Stop> {
let placer = Placer {
jobs,
slots: jobs.iter().map(|_| Mutex::new(None)).collect(),
@@ -250,7 +315,12 @@ pub fn run(jobs: &[Job], settings: &Settings, base: Baseline, gate: &ReplaceGate
let items: Vec<FetchItem> = jobs
.iter()
.enumerate()
.map(|(i, j)| FetchItem { id: i as u64, urls: vec![j.url.clone()], reserve: j.size, range: None })
.map(|(i, j)| FetchItem {
id: i as u64,
urls: vec![j.url.clone()],
reserve: j.size,
range: None,
})
.collect();
events.log(&format!(
"engine files={} window={window} process_workers={process} per_host_cap={} hosts={}",
@@ -263,7 +333,15 @@ pub fn run(jobs: &[Job], settings: &Settings, base: Baseline, gate: &ReplaceGate
let outcome = if items.is_empty() {
None
} else {
Some(run_fetch(items, &settings.hosts, &options, &Sink(&placer), stop, &|_: u64, _: u64| {}, &log))
Some(run_fetch(
items,
&settings.hosts,
&options,
&Sink(&placer),
stop,
&|_: u64, _: u64| {},
&log,
))
};
for slot in &placer.slots {
@@ -299,5 +377,7 @@ pub fn run(jobs: &[Job], settings: &Settings, base: Baseline, gate: &ReplaceGate
if stop.load(Ordering::SeqCst) {
return Err(Stop::Cancelled);
}
Err(Stop::Failed("The download ended before every file was written".into()))
Err(Stop::Failed(
"The download ended before every file was written".into(),
))
}
+106 -15
View File
@@ -5,6 +5,7 @@ use super::net::hex_lower;
use super::proto::{Fields, Value};
pub const SHA256_ALGORITHM: u64 = 0;
pub const SHAKE128_ALGORITHM: u64 = 1;
pub const RESERVED_ROOT_NAMES: &[&str] = &[
".droiddeck-store.json",
@@ -12,6 +13,7 @@ pub const RESERVED_ROOT_NAMES: &[&str] = &[
".droiddeck-launch.bat",
".amazon_download_in_progress",
".amazon_download_complete",
".droiddeck-maintenance",
];
pub const TEMP_SUFFIX: &str = ".tmp";
@@ -33,6 +35,14 @@ impl GameFile {
}
}
pub fn has_supported_hash(&self) -> bool {
match self.algorithm {
SHA256_ALGORITHM => self.digest.len() == 32,
SHAKE128_ALGORITHM => !self.digest.is_empty(),
_ => false,
}
}
pub fn cdn_name(&self) -> String {
hex_lower(&self.digest)
}
@@ -70,6 +80,13 @@ pub fn check_path(raw: &str) -> Result<String, String> {
_ => {}
}
}
if path
.split('/')
.next()
.is_some_and(|first| first.eq_ignore_ascii_case(".droiddeck-maintenance"))
{
return refuse("DroidDeck's own metadata");
}
if !path.contains('/') && RESERVED_ROOT_NAMES.iter().any(|r| r.eq_ignore_ascii_case(&path)) {
return refuse("DroidDeck's own file");
}
@@ -118,7 +135,12 @@ fn parse_file(data: &[u8]) -> Result<Option<GameFile>, String> {
if algorithm == SHA256_ALGORITHM && !digest.is_empty() && digest.len() != 32 {
return Err(format!("The manifest has a {}-byte SHA-256 for {path}", digest.len()));
}
Ok(Some(GameFile { path, size, algorithm, digest }))
Ok(Some(GameFile {
path,
size,
algorithm,
digest,
}))
}
pub fn parse_message(data: &[u8]) -> Result<Manifest, String> {
@@ -138,7 +160,9 @@ pub fn parse_message(data: &[u8]) -> Result<Manifest, String> {
(2, _) => return Err("file field has the wrong type".into()),
_ => continue,
};
let Some(file) = parse_file(entry)? else { continue };
let Some(file) = parse_file(entry)? else {
continue;
};
out.entries += 1;
match index.get(&file.path) {
Some(&i) => {
@@ -219,10 +243,25 @@ mod tests {
let d2: Vec<u8> = Sha256::digest(b"two").to_vec();
let msg = message(&[
vec![
Entry { path: "Binaries\\Win64\\Game.exe", size: 3, algorithm: Some(0), digest: &d1 },
Entry { path: "", size: 9, algorithm: None, digest: &d1 },
Entry {
path: "Binaries\\Win64\\Game.exe",
size: 3,
algorithm: Some(0),
digest: &d1,
},
Entry {
path: "",
size: 9,
algorithm: None,
digest: &d1,
},
],
vec![Entry { path: "fuel.json", size: 3, algorithm: None, digest: &d2 }],
vec![Entry {
path: "fuel.json",
size: 3,
algorithm: None,
digest: &d2,
}],
]);
for xz in [true, false] {
let m = parse(&frame(&msg, b"\x0a\x00", xz)).unwrap();
@@ -238,7 +277,12 @@ mod tests {
#[test]
fn other_algorithms_are_not_verified_but_still_named() {
let msg = message(&[vec![Entry { path: "a", size: 1, algorithm: Some(7), digest: &[0xab, 0xcd] }]]);
let msg = message(&[vec![Entry {
path: "a",
size: 1,
algorithm: Some(7),
digest: &[0xab, 0xcd],
}]]);
let m = parse_message(&msg).unwrap();
assert_eq!(m.files[0].sha256(), None);
assert_eq!(m.files[0].cdn_name(), "abcd");
@@ -249,8 +293,18 @@ mod tests {
let a = [1u8; 32];
let b = [2u8; 32];
let msg = message(&[vec![
Entry { path: "x\\y.bin", size: 1, algorithm: Some(0), digest: &a },
Entry { path: "x/y.bin", size: 2, algorithm: Some(0), digest: &b },
Entry {
path: "x\\y.bin",
size: 1,
algorithm: Some(0),
digest: &a,
},
Entry {
path: "x/y.bin",
size: 2,
algorithm: Some(0),
digest: &b,
},
]]);
let m = parse_message(&msg).unwrap();
assert_eq!(m.files.len(), 1);
@@ -273,9 +327,16 @@ mod tests {
".droiddeck-store.json",
".DroidDeck-Launch.BAT",
".amazon_download_complete",
".droiddeck-maintenance/operation.json",
".DROIDDECK-MAINTENANCE/amazon/installed.manifest",
] {
assert!(check_path(bad).is_err(), "{bad}");
let msg = message(&[vec![Entry { path: bad, size: 1, algorithm: Some(0), digest: &[0; 32] }]]);
let msg = message(&[vec![Entry {
path: bad,
size: 1,
algorithm: Some(0),
digest: &[0; 32],
}]]);
let err = parse_message(&msg).unwrap_err();
assert!(err.contains(bad), "{err}");
}
@@ -285,18 +346,48 @@ mod tests {
#[test]
fn malformed_entries_are_errors() {
let no_hash = message(&[vec![Entry { path: "a", size: 5, algorithm: Some(0), digest: &[] }]]);
let no_hash = message(&[vec![Entry {
path: "a",
size: 5,
algorithm: Some(0),
digest: &[],
}]]);
assert!(parse_message(&no_hash).is_err());
let empty_ok = message(&[vec![Entry { path: "e", size: 0, algorithm: Some(0), digest: &[] }]]);
let empty_ok = message(&[vec![Entry {
path: "e",
size: 0,
algorithm: Some(0),
digest: &[],
}]]);
assert_eq!(parse_message(&empty_ok).unwrap().files[0].sha256(), None);
let short = message(&[vec![Entry { path: "a", size: 5, algorithm: Some(0), digest: &[1, 2] }]]);
let short = message(&[vec![Entry {
path: "a",
size: 5,
algorithm: Some(0),
digest: &[1, 2],
}]]);
assert!(parse_message(&short).is_err());
let clash = message(&[vec![
Entry { path: "a.bin", size: 1, algorithm: Some(0), digest: &[1; 32] },
Entry { path: "a.bin.tmp", size: 1, algorithm: Some(0), digest: &[2; 32] },
Entry {
path: "a.bin",
size: 1,
algorithm: Some(0),
digest: &[1; 32],
},
Entry {
path: "a.bin.tmp",
size: 1,
algorithm: Some(0),
digest: &[2; 32],
},
]]);
assert!(parse_message(&clash).is_err());
let mut truncated = message(&[vec![Entry { path: "a", size: 1, algorithm: Some(0), digest: &[1; 32] }]]);
let mut truncated = message(&[vec![Entry {
path: "a",
size: 1,
algorithm: Some(0),
digest: &[1; 32],
}]]);
truncated.truncate(truncated.len() - 3);
assert!(parse_message(&truncated).is_err());
assert!(parse_message(&[0x08, 0x01]).is_err());
+55 -9
View File
@@ -65,14 +65,18 @@ fn dispatch(op: &str, req: &Value) -> Value {
let games = library::visible(all);
json!({ "total": total, "games": games.iter().map(library::Game::to_json).collect::<Vec<_>>() })
}
Err(Failure::SignIn) => json!({ "error": "Amazon refused the session", "signIn": true }),
Err(Failure::SignIn) => {
json!({ "error": "Amazon refused the session", "signIn": true })
}
Err(Failure::Other(e)) => error(e),
},
"download.lookup" => match distribution::game_download(token, str_of(req, "entitlementId")) {
Ok(d) => json!({ "downloadUrl": d.base_url, "versionId": d.version_id }),
Err(f) => error(f.message()),
},
"download.size" => json!({ "bytes": install::size_of(token, str_of(req, "entitlementId")) }),
"download.size" => {
json!({ "bytes": install::size_of(token, str_of(req, "entitlementId")) })
}
"launch.spec" => {
let dir = str_of(req, "gameDir");
if dir.is_empty() {
@@ -82,7 +86,11 @@ fn dispatch(op: &str, req: &Value) -> Value {
json!({ "exe": spec.exe, "args": spec.args, "source": spec.source, "warning": spec.warning })
}
"launch.env" => {
let env = launch::fuel_environment(str_of(req, "entitlementId"), str_of(req, "sku"), str_of(req, "displayName"));
let env = launch::fuel_environment(
str_of(req, "entitlementId"),
str_of(req, "sku"),
str_of(req, "displayName"),
);
json!({ "env": env.iter().map(|(k, v)| json!([k, v])).collect::<Vec<_>>() })
}
_ => error(format!("unknown operation {op}")),
@@ -105,7 +113,11 @@ pub fn fetch(op: &str, request: &str) -> Option<Vec<u8>> {
pub fn inspect(bytes: &[u8], _request: &str) -> String {
match manifest::parse(bytes) {
Ok(m) => json!({ "totalBytes": m.total_size(), "files": m.files.len() }).to_string(),
Ok(m) => json!({
"totalBytes": m.total_size(),
"files": m.files.len(),
"unverifiableFiles": m.files.iter().filter(|f| !f.has_supported_hash() && !(f.size == 0 && f.digest.is_empty())).count(),
}).to_string(),
Err(e) => error(e).to_string(),
}
}
@@ -123,12 +135,23 @@ mod tests {
assert_eq!(v["redirectMarker"], "openid.oa2.authorization_code=");
assert!(v["userAgent"].as_str().unwrap().contains("Firefox"));
assert_eq!(v["serial"].as_str().unwrap().len(), 32);
let v: Value = serde_json::from_str(&call("auth.register", r#"{"url":"https://www.amazon.com/?a=1"}"#)).unwrap();
let v: Value = serde_json::from_str(&call(
"auth.register",
r#"{"url":"https://www.amazon.com/?a=1"}"#,
))
.unwrap();
assert_eq!(v["noCode"], true);
let v: Value = serde_json::from_str(&call("auth.register", r#"{"code":"c"}"#)).unwrap();
assert!(v["error"].is_string() && v.get("noCode").is_none());
let v: Value = serde_json::from_str(&call("launch.env", r#"{"entitlementId":"e","sku":"s","displayName":""}"#)).unwrap();
assert_eq!(v["env"][2], json!(["AMAZON_GAMES_FUEL_ENTITLEMENT_ID", "e"]));
let v: Value = serde_json::from_str(&call(
"launch.env",
r#"{"entitlementId":"e","sku":"s","displayName":""}"#,
))
.unwrap();
assert_eq!(
v["env"][2],
json!(["AMAZON_GAMES_FUEL_ENTITLEMENT_ID", "e"])
);
assert_eq!(v["env"][4][1], "Player");
let v: Value = serde_json::from_str(&call("nope", "{}")).unwrap();
assert!(v["error"].is_string());
@@ -138,13 +161,36 @@ mod tests {
#[test]
fn inspect_sizes() {
let msg = message(&[vec![
Entry { path: "a", size: 10, algorithm: Some(0), digest: &[1; 32] },
Entry { path: "b\\c", size: 5, algorithm: Some(0), digest: &[2; 32] },
Entry {
path: "a",
size: 10,
algorithm: Some(0),
digest: &[1; 32],
},
Entry {
path: "b\\c",
size: 5,
algorithm: Some(0),
digest: &[2; 32],
},
]]);
let v: Value = serde_json::from_str(&inspect(&frame(&msg, b"", true), "{}")).unwrap();
assert_eq!(v["totalBytes"], 15);
assert_eq!(v["files"], 2);
assert_eq!(v["unverifiableFiles"], 0);
let v: Value = serde_json::from_str(&inspect(b"xx", "{}")).unwrap();
assert!(v["error"].is_string());
}
#[test]
fn inspect_reports_unsupported_file_hashes() {
let msg = message(&[vec![Entry {
path: "a",
size: 2,
algorithm: Some(7),
digest: &[1, 2],
}]]);
let v: Value = serde_json::from_str(&inspect(&frame(&msg, b"", true), "{}")).unwrap();
assert_eq!(v["unverifiableFiles"], 1);
}
}
@@ -1,12 +1,13 @@
use std::collections::HashMap;
use std::collections::HashSet;
use std::fs;
use std::io::{Read, Write};
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use sha1::{Digest, Sha1};
use crate::epic::manifest::{guid_from_hex, guid_text, ChunkInfo};
use crate::epic::manifest::{guid_from_hex, guid_text, ChunkInfo, Manifest};
pub fn entry_name(chunk: &ChunkInfo, copy: u32) -> String {
if copy == 0 {
@@ -85,7 +86,9 @@ pub fn prepare(
let mut candidates: Vec<(u32, u32, PathBuf)> = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
let Ok(meta) = fs::symlink_metadata(&path) else { continue };
let Ok(meta) = fs::symlink_metadata(&path) else {
continue;
};
let name = entry.file_name().to_string_lossy().into_owned();
let chunk = by_name.get(&name).copied().filter(|_| name.len() == 32 && guid_from_hex(&name).is_some());
let usable = meta.is_file() && !meta.file_type().is_symlink();
@@ -128,10 +131,169 @@ pub fn prepare(
kept
}
struct Rebuilt {
bytes: Vec<u8>,
covered: Vec<bool>,
valid: bool,
}
pub fn seed_from_verified_installed(
dir: &Path,
game_dir: &Path,
installed: &Manifest,
target: &Manifest,
needed: &HashSet<u32>,
cached: &[u32],
budget: u64,
cancel: &AtomicBool,
) -> (Vec<u32>, u64) {
const MAX_WINDOW: u32 = 64 * 1024 * 1024;
const MAX_REBUILD_MEMORY: u64 = 32 * 1024 * 1024;
let cached: HashSet<u32> = cached.iter().copied().collect();
let mut rebuilt: HashMap<u32, Rebuilt> = HashMap::new();
let mut reserved = 0u64;
let budget = budget.min(MAX_REBUILD_MEMORY);
for file in &installed.files {
if cancel.load(Ordering::Relaxed) {
break;
}
let Some(expected) = file.sha1 else { continue };
if !super::safe_relative(&file.path) {
continue;
}
let path = game_dir.join(&file.path);
let mut safe = game_dir.to_path_buf();
let mut no_links = true;
for segment in file.path.split('/') {
safe.push(segment);
if fs::symlink_metadata(&safe)
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
{
no_links = false;
break;
}
}
if !no_links {
continue;
}
let Ok(meta) = fs::symlink_metadata(&path) else {
continue;
};
if !meta.is_file() || meta.file_type().is_symlink() {
continue;
}
match super::delta::check(&path, file.size, Some(expected), cancel) {
super::delta::Check::Cancelled => break,
super::delta::Check::UpToDate => {}
super::delta::Check::Pending { .. } => continue,
}
let mut source = match fs::File::open(&path) {
Ok(f) => f,
Err(_) => continue,
};
let mut file_offset = 0u64;
for part in &file.parts {
let Some(&target_index) = target.chunk_index.get(&part.guid) else {
file_offset += part.size as u64;
continue;
};
let target_index = target_index as u32;
if !needed.contains(&target_index) || cached.contains(&target_index) {
file_offset += part.size as u64;
continue;
}
let info = &target.chunks[target_index as usize];
if !info.window_exact
|| info.window > MAX_WINDOW
|| part.offset.saturating_add(part.size) > info.window
{
file_offset += part.size as u64;
continue;
}
if !rebuilt.contains_key(&target_index) {
let size = info.window as u64;
if reserved.saturating_add(size) > budget {
file_offset += part.size as u64;
continue;
}
reserved += size;
rebuilt.insert(
target_index,
Rebuilt {
bytes: vec![0; info.window as usize],
covered: vec![false; info.window as usize],
valid: true,
},
);
}
let Some(chunk) = rebuilt.get_mut(&target_index) else {
file_offset += part.size as u64;
continue;
};
if file_offset.saturating_add(part.size as u64) > file.size {
chunk.valid = false;
file_offset += part.size as u64;
continue;
}
let mut data = vec![0; part.size as usize];
if source.seek(SeekFrom::Start(file_offset)).is_err()
|| source.read_exact(&mut data).is_err()
{
chunk.valid = false;
file_offset += part.size as u64;
continue;
}
for (n, byte) in data.into_iter().enumerate() {
let at = part.offset as usize + n;
if chunk.covered[at] && chunk.bytes[at] != byte {
chunk.valid = false;
break;
}
chunk.bytes[at] = byte;
chunk.covered[at] = true;
}
file_offset += part.size as u64;
}
}
let mut seeded = Vec::new();
let mut bytes_written = 0u64;
for (index, chunk) in rebuilt {
if cancel.load(Ordering::Relaxed) {
break;
}
if !chunk.valid || chunk.covered.iter().any(|present| !present) {
continue;
}
if target.chunks[index as usize]
.sha1
.is_some_and(|expected| <[u8; 20]>::from(Sha1::digest(&chunk.bytes)) != expected)
{
continue;
}
if store(
dir,
&guid_text(&target.chunks[index as usize].guid),
&chunk.bytes,
)
.is_ok()
{
bytes_written += chunk.bytes.len() as u64;
seeded.push(index);
}
}
(seeded, bytes_written)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::epic::manifest::testing::chunk;
use crate::epic::manifest::{
testing::{build, chunk, TestFile, TestManifest},
PartRef,
};
use sha1::Digest;
#[test]
fn keeps_only_valid_needed_entries_within_budget() {
@@ -159,4 +321,72 @@ mod tests {
assert_eq!(left, want);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn reconstructs_needed_chunks_only_from_sha1_verified_installed_files() {
let root =
std::env::temp_dir().join(format!("dd-epic-installed-chunks-{}", std::process::id()));
let game = root.join("game");
let cache = root.join("cache");
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&game).unwrap();
let info = chunk(41, 4);
let make = || TestManifest {
version: 21,
build: "installed".into(),
exe: "Game.exe".into(),
prereq: None,
chunks: vec![info.clone()],
files: vec![TestFile {
path: "source.bin".into(),
sha1: Sha1::digest(b"abcd").into(),
tags: Vec::new(),
parts: vec![PartRef {
guid: info.guid,
offset: 0,
size: 4,
}],
}],
compress: false,
meta_tail: Vec::new(),
};
let installed = crate::epic::manifest::parse(&build(&make())).unwrap();
let target = crate::epic::manifest::parse(&build(&make())).unwrap();
fs::write(game.join("source.bin"), b"abcd").unwrap();
let cancel = AtomicBool::new(false);
let needed = HashSet::from([0]);
let (seeded, written) = seed_from_verified_installed(
&cache,
&game,
&installed,
&target,
&needed,
&[],
4,
&cancel,
);
assert_eq!(seeded, vec![0]);
assert_eq!(written, 4);
assert_eq!(
fs::read(cache.join(guid_text(&info.guid))).unwrap(),
b"abcd"
);
fs::remove_dir_all(&cache).unwrap();
fs::write(game.join("source.bin"), b"abXd").unwrap();
let (seeded, written) = seed_from_verified_installed(
&cache,
&game,
&installed,
&target,
&needed,
&[],
4,
&cancel,
);
assert!(seeded.is_empty());
assert_eq!(written, 0);
assert!(!cache.join(guid_text(&info.guid)).exists());
let _ = fs::remove_dir_all(&root);
}
}
@@ -14,7 +14,9 @@ pub enum Check {
}
fn hash_file(path: &Path, cancel: &AtomicBool) -> Option<Option<Hash>> {
let Ok(mut f) = fs::File::open(path) else { return Some(None) };
let Ok(mut f) = fs::File::open(path) else {
return Some(None);
};
let mut h = Sha1::new();
let mut buf = vec![0u8; 1024 * 1024];
loop {
@@ -31,10 +33,13 @@ fn hash_file(path: &Path, cancel: &AtomicBool) -> Option<Option<Hash>> {
}
pub fn check(dest: &Path, size: u64, sha1: Option<Hash>, cancel: &AtomicBool) -> Check {
let old_size = match fs::metadata(dest) {
Ok(m) if m.is_file() => m.len(),
let old_size = match fs::symlink_metadata(dest) {
Ok(m) if m.is_file() && !m.file_type().is_symlink() => m.len(),
_ => return Check::Pending { old_size: 0 },
};
if old_size == 0 && size == 0 && sha1.is_none() {
return Check::UpToDate;
}
let Some(expected) = sha1 else {
return Check::Pending { old_size };
};
@@ -61,10 +66,25 @@ mod tests {
let good: Hash = Sha1::digest(b"hello").into();
let cancel = AtomicBool::new(false);
assert!(matches!(check(&f, 5, Some(good), &cancel), Check::UpToDate));
assert!(matches!(check(&f, 5, None, &cancel), Check::Pending { old_size: 5 }));
assert!(matches!(check(&f, 6, Some(good), &cancel), Check::Pending { old_size: 5 }));
assert!(matches!(check(&f, 5, Some([0; 20]), &cancel), Check::Pending { .. }));
assert!(matches!(check(&dir.join("missing"), 5, Some(good), &cancel), Check::Pending { old_size: 0 }));
let empty = dir.join("empty.bin");
fs::write(&empty, b"").unwrap();
assert!(matches!(check(&empty, 0, None, &cancel), Check::UpToDate));
assert!(matches!(
check(&f, 5, None, &cancel),
Check::Pending { old_size: 5 }
));
assert!(matches!(
check(&f, 6, Some(good), &cancel),
Check::Pending { old_size: 5 }
));
assert!(matches!(
check(&f, 5, Some([0; 20]), &cancel),
Check::Pending { .. }
));
assert!(matches!(
check(&dir.join("missing"), 5, Some(good), &cancel),
Check::Pending { old_size: 0 }
));
cancel.store(true, Ordering::Relaxed);
assert!(matches!(check(&f, 5, Some(good), &cancel), Check::Cancelled));
let _ = fs::remove_dir_all(&dir);
+303 -14
View File
@@ -14,7 +14,7 @@ use serde_json::{json, Value};
use super::build_info::{self, BuildInfo};
use super::locale;
use super::manifest::{self, Manifest};
use super::manifest::{self, FileEntry, Manifest};
use super::net;
use schedule::{PlanFile, PlanPart};
@@ -40,9 +40,11 @@ pub enum Stop {
pub struct Request {
pub info: BuildInfo,
pub game_dir: PathBuf,
pub target_manifest_path: Option<PathBuf>,
pub scratch_dir: Option<PathBuf>,
pub storage_label: String,
pub tags: Vec<String>,
pub previous_tags: Vec<String>,
pub max_workers: usize,
pub process_workers: usize,
pub ca_bundle: String,
@@ -56,17 +58,32 @@ impl Request {
return Err("No install folder was given".into());
}
let scratch = net::str_of(v, "scratchDir");
let tags = v
let tags: Vec<String> = v
.get("tags")
.and_then(|t| t.as_array())
.map(|a| a.iter().filter_map(|t| t.as_str()).map(str::to_string).collect())
.map(|a| {
a.iter()
.filter_map(|t| t.as_str())
.map(str::to_string)
.collect()
})
.unwrap_or_default();
let previous_tags = v
.get("previousTags")
.and_then(|t| t.as_array())
.map(|a| a.iter().filter_map(|t| t.as_str()).map(str::to_string).collect())
.unwrap_or_else(|| tags.clone());
Ok(Request {
info,
game_dir: PathBuf::from(game_dir),
target_manifest_path: match net::str_of(v, "targetManifestPath") {
"" => None,
path => Some(PathBuf::from(path)),
},
scratch_dir: (!scratch.is_empty()).then(|| PathBuf::from(scratch)),
storage_label: Some(net::str_of(v, "storageLabel")).filter(|s| !s.is_empty()).unwrap_or("storage").to_string(),
tags,
previous_tags,
max_workers: v.get("maxWorkers").and_then(|n| n.as_u64()).unwrap_or(32) as usize,
process_workers: v.get("processWorkers").and_then(|n| n.as_u64()).unwrap_or(2) as usize,
ca_bundle: net::str_of(v, "caBundle").to_string(),
@@ -106,7 +123,11 @@ impl Finished {
pub fn safe_relative(path: &str) -> bool {
!path.is_empty()
&& !path.starts_with('/')
&& path.split('/').all(|seg| !seg.is_empty() && seg != "." && seg != "..")
&& path != ".droiddeck-maintenance"
&& !path.starts_with(".droiddeck-maintenance/")
&& path
.split('/')
.all(|seg| !seg.is_empty() && seg != "." && seg != "..")
}
fn dir_has_entries(dir: &Path) -> bool {
@@ -140,14 +161,174 @@ pub fn fetch_manifest(info: &BuildInfo, log: &dyn Fn(&str)) -> Result<Manifest,
})
}
fn installed_manifest_path(game_dir: &Path) -> PathBuf {
game_dir.join(".droiddeck-maintenance/epic/installed.manifest")
}
fn read_installed_manifest(game_dir: &Path, log: &dyn Fn(&str)) -> Option<Manifest> {
let path = installed_manifest_path(game_dir);
match fs::symlink_metadata(&path) {
Ok(meta) if meta.is_file() && !meta.file_type().is_symlink() => {}
Ok(_) => {
log("obsolete Epic files preserved: retained manifest is not a regular file");
return None;
}
Err(_) => return None,
}
match fs::read(path).ok().and_then(|bytes| manifest::parse(&bytes).ok()) {
Some(manifest) => Some(manifest),
None => {
log("obsolete Epic files preserved: retained manifest is unavailable or invalid");
None
}
}
}
fn read_target_manifest(req: &Request) -> Result<Option<Vec<u8>>, String> {
let Some(path) = &req.target_manifest_path else {
return Ok(None);
};
let root = req
.game_dir
.canonicalize()
.map_err(|e| format!("Could not resolve the install folder: {e}"))?;
if fs::symlink_metadata(path)
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
{
return Err("The pinned Epic manifest is a symbolic link".into());
}
let candidate = path
.canonicalize()
.map_err(|e| format!("The pinned Epic manifest is unavailable: {e}"))?;
if !candidate.starts_with(&root) || !candidate.is_file() {
return Err("The pinned Epic manifest is outside the install folder".into());
}
fs::read(candidate)
.map(Some)
.map_err(|e| format!("Could not read the pinned Epic manifest: {e}"))
}
fn write_installed_manifest(game_dir: &Path, bytes: &[u8]) -> Result<(), String> {
let metadata = game_dir.join(".droiddeck-maintenance");
if fs::symlink_metadata(&metadata)
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
{
return Err("Epic maintenance metadata is a symbolic link".into());
}
let dir = metadata.join("epic");
if fs::symlink_metadata(&dir)
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
{
return Err("Epic maintenance metadata is a symbolic link".into());
}
fs::create_dir_all(&dir)
.map_err(|e| format!("Could not save the Epic install manifest: {e}"))?;
let temp = dir.join("installed.manifest.tmp");
if fs::symlink_metadata(&temp)
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
{
return Err("Epic maintenance metadata is a symbolic link".into());
}
fs::write(&temp, bytes)
.map_err(|e| format!("Could not save the Epic install manifest: {e}"))?;
fs::rename(&temp, installed_manifest_path(game_dir))
.map_err(|e| format!("Could not save the Epic install manifest: {e}"))
}
fn cleanup_obsolete(
game_dir: &Path,
old: &[FileEntry],
old_tags: &[String],
new: &[FileEntry],
new_tags: &[String],
cancel: &AtomicBool,
log: &dyn Fn(&str),
) -> bool {
let old_selected = locale::select(old, Some(old_tags));
let new_selected = locale::select(new, Some(new_tags));
let key = |path: &str| -> Option<String> {
if !safe_relative(path) {
return None;
}
Some(path.replace('\\', "/").to_lowercase())
};
let inventory = |files: &[FileEntry], indices: &[usize]| -> Option<HashSet<String>> {
let mut out = HashSet::new();
for &i in indices {
if !out.insert(key(&files[i].path)?) {
return None;
}
}
Some(out)
};
let Some(old_keys) = inventory(old, &old_selected) else {
log("obsolete Epic files preserved: old inventory contains unsafe or case-colliding paths");
return cancel.load(Ordering::Relaxed);
};
let Some(new_keys) = inventory(new, &new_selected) else {
log("obsolete Epic files preserved: target inventory contains unsafe or case-colliding paths");
return cancel.load(Ordering::Relaxed);
};
let _ = old_keys;
let Ok(root) = game_dir.canonicalize() else {
log("obsolete Epic files preserved: install folder could not be resolved");
return cancel.load(Ordering::Relaxed);
};
let mut removed = 0usize;
let mut preserved = 0usize;
for i in old_selected {
if cancel.load(Ordering::Relaxed) {
log("obsolete Epic cleanup cancelled; remaining files were preserved");
return true;
}
let file = &old[i];
let Some(k) = key(&file.path) else { preserved += 1; continue };
if new_keys.contains(&k) { continue; }
let Some(expected) = file.sha1 else { preserved += 1; continue };
let mut path = game_dir.to_path_buf();
let mut safe = true;
for segment in file.path.split('/') {
path.push(segment);
if fs::symlink_metadata(&path).map(|m| m.file_type().is_symlink()).unwrap_or(false) {
safe = false;
break;
}
}
let Ok(metadata) = fs::symlink_metadata(&path) else { continue };
let Ok(canonical) = path.canonicalize() else { preserved += 1; continue };
if !safe || !metadata.is_file() || !canonical.starts_with(&root) {
preserved += 1;
continue;
}
if !matches!(delta::check(&canonical, file.size, Some(expected), cancel), delta::Check::UpToDate) {
preserved += 1;
continue;
}
if fs::remove_file(&canonical).is_ok() { removed += 1; } else { preserved += 1; }
}
log(&format!("obsolete Epic cleanup removed={removed} preserved={preserved}"));
cancel.load(Ordering::Relaxed)
}
fn resume_from(stage: &Path, parts: &[PlanPart], size: u64) -> Option<HashSet<u64>> {
let meta = fs::symlink_metadata(stage).ok()?;
if !meta.is_file() || meta.len() > size {
return None;
}
let list = fs::read_to_string(staging::list_path(stage)).ok()?;
let kept: HashSet<u64> = list.lines().map(|l| l.parse().ok()).collect::<Option<_>>()?;
let fits = |o: &u64| parts.iter().any(|p| p.file_offset == *o && p.file_offset + p.size as u64 <= meta.len());
let kept: HashSet<u64> = list
.lines()
.map(|l| l.parse().ok())
.collect::<Option<_>>()?;
let fits = |o: &u64| {
parts
.iter()
.any(|p| p.file_offset == *o && p.file_offset + p.size as u64 <= meta.len())
};
(!kept.is_empty() && kept.iter().all(fits)).then_some(kept)
}
@@ -155,7 +336,17 @@ pub fn run(req: &Request, cancel: &AtomicBool, ev: &dyn Events) -> Result<Finish
let failed = |m: String| Stop::Failed(m);
ev.status("Downloading the manifest…");
let log = |line: &str| ev.log(line);
let manifest = fetch_manifest(&req.info, &log).map_err(failed)?;
let pinned_manifest = read_target_manifest(req).map_err(failed)?;
let manifest_bytes = match pinned_manifest {
Some(bytes) => bytes,
None => build_info::download_manifest(&req.info, &req.info.cdns, &log)
.ok_or_else(|| failed("The manifest could not be downloaded".into()))?,
};
let manifest = manifest::parse(&manifest_bytes).map_err(|e| {
ev.log(&format!("manifest parse: {e}"));
failed("The manifest could not be parsed".into())
})?;
let installed_manifest = read_installed_manifest(&req.game_dir, &log);
cancelled(cancel)?;
ev.status("Reading the manifest…");
@@ -166,7 +357,9 @@ pub fn run(req: &Request, cancel: &AtomicBool, ev: &dyn Events) -> Result<Finish
return Err(failed(format!("The manifest has an unsafe path: {path}")));
}
if staging::conflicts(path) {
return Err(failed("The manifest conflicts with the staging folder".into()));
return Err(failed(
"The manifest conflicts with the staging folder".into(),
));
}
}
let disk_bytes: u64 = selected.iter().map(|&i| manifest.files[i].size).sum();
@@ -229,6 +422,24 @@ pub fn run(req: &Request, cancel: &AtomicBool, ev: &dyn Events) -> Result<Finish
ev.log("every selected file is already up to date");
clean_caches();
ev.installing(disk_bytes, disk_bytes, total_selected, total_selected);
let cleanup_cancelled = if let Some(installed) = installed_manifest.as_ref() {
cleanup_obsolete(
&req.game_dir,
&installed.files,
&req.previous_tags,
&manifest.files,
&req.tags,
cancel,
&log,
)
} else {
ev.log("obsolete Epic files preserved: no retained prior manifest is available");
cancel.load(Ordering::Relaxed)
};
if cleanup_cancelled || cancel.load(Ordering::Relaxed) {
return Err(Stop::Cancelled);
}
write_installed_manifest(&req.game_dir, &manifest_bytes).map_err(failed)?;
return Ok(finished);
}
@@ -248,7 +459,12 @@ pub fn run(req: &Request, cancel: &AtomicBool, ev: &dyn Events) -> Result<Finish
if info.window_exact && part.offset as u64 + part.size as u64 > info.window as u64 {
return Err(failed(format!("A chunk is too short for {}", f.path)));
}
parts.push(PlanPart { chunk: c as u32, chunk_offset: part.offset, size: part.size, file_offset: offset });
parts.push(PlanPart {
chunk: c as u32,
chunk_offset: part.offset,
size: part.size,
file_offset: offset,
});
offset += part.size as u64;
}
let mut resume = None;
@@ -261,13 +477,19 @@ pub fn run(req: &Request, cancel: &AtomicBool, ev: &dyn Events) -> Result<Finish
resumed_bytes += bytes;
kept_size = bytes;
parts.retain(|p| !kept.contains(&p.file_offset));
resume = Some(writer::Resume { stage, kept: kept.into_iter().collect() });
resume = Some(writer::Resume {
stage,
kept: kept.into_iter().collect(),
});
}
None => staging::remove_partial(&stage),
}
}
kept_sizes.push(kept_size);
plan_files.push(PlanFile { size: f.size, parts: parts.clone() });
plan_files.push(PlanFile {
size: f.size,
parts: parts.clone(),
});
engine_files.push(writer::EngineFile {
path: f.path.clone(),
dest: req.game_dir.join(&f.path),
@@ -292,10 +514,15 @@ pub fn run(req: &Request, cancel: &AtomicBool, ev: &dyn Events) -> Result<Finish
let sizes: Vec<space::PendingSize> = pending
.iter()
.zip(&kept_sizes)
.map(|(&(i, old), kept)| space::PendingSize { new_size: manifest.files[i].size - kept, old_size: old })
.map(|(&(i, old), kept)| space::PendingSize {
new_size: manifest.files[i].size - kept,
old_size: old,
})
.collect();
let free_game = space::free_bytes(&req.game_dir);
let scratch_space = scratch.as_ref().map(|s| space::Scratch { free: space::free_bytes(s) });
let scratch_space = scratch.as_ref().map(|s| space::Scratch {
free: space::free_bytes(s),
});
let plan = space::plan(&sizes, free_game, scratch_space, &req.storage_label).map_err(failed)?;
let cache_dir = match (&scratch, plan.cache_on_scratch) {
(Some(s), true) => s.clone(),
@@ -311,7 +538,51 @@ pub fn run(req: &Request, cancel: &AtomicBool, ev: &dyn Events) -> Result<Finish
position += 1;
}
}
let reused = cache::prepare(&cache_dir, &manifest.chunks, &first_use, plan.cache_budget, cancel);
let mut reused = cache::prepare(
&cache_dir,
&manifest.chunks,
&first_use,
plan.cache_budget,
cancel,
);
if let Some(installed) = installed_manifest.as_ref() {
let needed_chunks: HashSet<u32> = first_use.keys().copied().collect();
let cached_bytes: u64 = reused
.iter()
.map(|i| manifest.chunks[*i as usize].window as u64)
.sum();
let remaining_budget = plan.cache_budget.saturating_sub(cached_bytes);
let (seeded, _) = cache::seed_from_verified_installed(
&cache_dir,
&req.game_dir,
installed,
&manifest,
&needed_chunks,
&reused,
remaining_budget,
cancel,
);
if !seeded.is_empty() {
reused = cache::prepare(
&cache_dir,
&manifest.chunks,
&first_use,
plan.cache_budget,
cancel,
);
let retained: Vec<u32> = seeded.into_iter().filter(|i| reused.contains(i)).collect();
let bytes: u64 = retained
.iter()
.map(|i| manifest.chunks[*i as usize].window as u64)
.sum();
if !retained.is_empty() {
ev.log(&format!(
"reused {} verified installed-file chunks ({bytes} bytes)",
retained.len()
));
}
}
}
cancelled(cancel)?;
let windows: Vec<u32> = manifest.chunks.iter().map(|c| c.window).collect();
let schedule = schedule::build(&plan_files, &windows, plan.cache_budget, &reused);
@@ -356,6 +627,24 @@ cache_budget_mib={} overlap_mib={} free_game={free_game} workers={} process_work
finished.files_written = stats.files_written;
clean_caches();
ev.installing(disk_bytes, disk_bytes, total_selected, total_selected);
let cleanup_cancelled = if let Some(installed) = installed_manifest.as_ref() {
cleanup_obsolete(
&req.game_dir,
&installed.files,
&req.previous_tags,
&manifest.files,
&req.tags,
cancel,
&log,
)
} else {
ev.log("obsolete Epic files preserved: no retained prior manifest is available");
cancel.load(Ordering::Relaxed)
};
if cleanup_cancelled || cancel.load(Ordering::Relaxed) {
return Err(Stop::Cancelled);
}
write_installed_manifest(&req.game_dir, &manifest_bytes).map_err(failed)?;
Ok(finished)
}
@@ -95,7 +95,13 @@ pub fn build(files: &[PlanFile], windows: &[u32], budget: u64, cached: &[u32]) -
}
occupancy += window(c);
let id = producers.len() as u32;
producers.push(Producer { chunk: c, anchor: NONE, preloaded: true, copy: 0, uses: Vec::new() });
producers.push(Producer {
chunk: c,
anchor: NONE,
preloaded: true,
copy: 0,
uses: Vec::new(),
});
copies.insert(c, 1);
active.insert(c, id);
resident.insert(c, u);
@@ -114,7 +120,13 @@ pub fn build(files: &[PlanFile], windows: &[u32], budget: u64, cached: &[u32]) -
None => {
let id = producers.len() as u32;
let copy = copies.entry(c).or_insert(0);
producers.push(Producer { chunk: c, anchor: f, preloaded: false, copy: *copy, uses: Vec::new() });
producers.push(Producer {
chunk: c,
anchor: f,
preloaded: false,
copy: *copy,
uses: Vec::new(),
});
*copy += 1;
fetch.push(id);
active.insert(c, id);
@@ -161,8 +173,19 @@ pub fn build(files: &[PlanFile], windows: &[u32], budget: u64, cached: &[u32]) -
}
}
let refetches = producers.iter().filter(|p| !p.preloaded && p.copy > 0).count() as u32;
Schedule { file_start, use_file, use_producer, producers, fetch, peak_cache: peak, refetches }
let refetches = producers
.iter()
.filter(|p| !p.preloaded && p.copy > 0)
.count() as u32;
Schedule {
file_start,
use_file,
use_producer,
producers,
fetch,
peak_cache: peak,
refetches,
}
}
#[cfg(test)]
@@ -176,7 +199,12 @@ mod tests {
parts: chunks
.iter()
.map(|&c| {
let p = PlanPart { chunk: c, chunk_offset: 0, size: 10, file_offset: off };
let p = PlanPart {
chunk: c,
chunk_offset: 0,
size: 10,
file_offset: off,
};
off += 10;
p
})
@@ -228,7 +256,12 @@ mod tests {
#[test]
fn cached_chunks_are_reused_soonest_first() {
let s = build(&[file(&[0]), file(&[1]), file(&[2])], &[10, 10, 10], 20, &[2, 0, 1, 7]);
let s = build(
&[file(&[0]), file(&[1]), file(&[2])],
&[10, 10, 10],
20,
&[2, 0, 1, 7],
);
assert!(s.fetch.is_empty() || s.fetch.iter().all(|&p| s.producers[p as usize].chunk == 2));
let preloaded: Vec<u32> = s.producers.iter().filter(|p| p.preloaded).map(|p| p.chunk).collect();
assert_eq!(preloaded, vec![0, 1]);
@@ -115,7 +115,12 @@ pub fn net_peak(pending: &[PendingSize]) -> u64 {
peak.max(0) as u64
}
pub fn plan(pending: &[PendingSize], free_game: i64, scratch: Option<Scratch>, label: &str) -> Result<SpacePlan, String> {
pub fn plan(
pending: &[PendingSize],
free_game: i64,
scratch: Option<Scratch>,
label: &str,
) -> Result<SpacePlan, String> {
let peak = net_peak(pending);
let largest_replaced = pending.iter().filter(|p| p.old_size > 0).map(|p| p.new_size).max();
@@ -139,7 +144,11 @@ pub fn plan(pending: &[PendingSize], free_game: i64, scratch: Option<Scratch>, l
if free_game < 0 {
largest
} else {
largest.max((free_game as u64).saturating_sub(peak).saturating_sub(cache_on_game))
largest.max(
(free_game as u64)
.saturating_sub(peak)
.saturating_sub(cache_on_game),
)
}
});
let game_need = peak + overlap.unwrap_or(0) + cache_on_game;
@@ -184,7 +193,9 @@ pub fn plan(pending: &[PendingSize], free_game: i64, scratch: Option<Scratch>, l
human(peak.saturating_sub(free))
);
if moved {
message.push_str(". The download cache had to move beside the game because internal storage is full");
message.push_str(
". The download cache had to move beside the game because internal storage is full",
);
}
Err(message)
}
@@ -196,15 +207,27 @@ mod tests {
const GB: u64 = 1024 * MIB;
fn fresh(sizes: &[u64]) -> Vec<PendingSize> {
sizes.iter().map(|&s| PendingSize { new_size: s, old_size: 0 }).collect()
sizes
.iter()
.map(|&s| PendingSize {
new_size: s,
old_size: 0,
})
.collect()
}
#[test]
fn net_peak_credits_old_copies_at_their_swap() {
assert_eq!(net_peak(&fresh(&[10, 20])), 30);
let update = vec![
PendingSize { new_size: 100, old_size: 90 },
PendingSize { new_size: 50, old_size: 60 },
PendingSize {
new_size: 100,
old_size: 90,
},
PendingSize {
new_size: 50,
old_size: 60,
},
];
assert_eq!(net_peak(&update), 100);
assert_eq!(net_peak(&[PendingSize { new_size: 5, old_size: 500 }]), 5);
@@ -224,13 +247,24 @@ mod tests {
let p = plan(&fresh(&[50 * GB]), -1, Some(Scratch { free: -1 }), "Card").unwrap();
assert!(p.cache_on_scratch);
assert_eq!(p.cache_budget, CACHE_WHEN_UNKNOWN);
let update = vec![PendingSize { new_size: GB, old_size: GB }];
let update = vec![PendingSize {
new_size: GB,
old_size: GB,
}];
assert_eq!(plan(&update, -1, None, "Card").unwrap().overlap, Some(GB));
}
#[test]
fn scratch_too_small_moves_the_cache_beside_the_game() {
let p = plan(&fresh(&[GB]), (20 * GB) as i64, Some(Scratch { free: (100 * MIB) as i64 }), "Card").unwrap();
let p = plan(
&fresh(&[GB]),
(20 * GB) as i64,
Some(Scratch {
free: (100 * MIB) as i64,
}),
"Card",
)
.unwrap();
assert!(!p.cache_on_scratch && p.cache_moved_beside_game);
}
@@ -256,8 +290,14 @@ mod tests {
#[test]
fn updates_get_an_overlap_budget_of_at_least_the_largest_replaced_file() {
let update = vec![
PendingSize { new_size: GB, old_size: GB },
PendingSize { new_size: 2 * GB, old_size: 2 * GB },
PendingSize {
new_size: GB,
old_size: GB,
},
PendingSize {
new_size: 2 * GB,
old_size: 2 * GB,
},
];
let p = plan(&update, (40 * GB) as i64, None, "Internal").unwrap();
assert!(p.overlap.unwrap() >= 2 * GB);
@@ -44,7 +44,9 @@ pub fn remove_partial(partial: &Path) {
}
pub fn partials(dir: &Path) -> HashMap<String, PathBuf> {
let Ok(entries) = fs::read_dir(dir) else { return HashMap::new() };
let Ok(entries) = fs::read_dir(dir) else {
return HashMap::new();
};
entries
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
@@ -77,7 +79,11 @@ pub fn recover(game_dir: &Path) -> Result<PathBuf, String> {
let game = fs::canonicalize(game_dir).map_err(|e| format!("game folder: {e}"))?;
let expected = game.join(DIR_NAME);
let dir = game_dir.join(DIR_NAME);
let refuse = |why: &str| Err(format!("The staging folder {DIR_NAME} is not usable ({why}); nothing was changed"));
let refuse = |why: &str| {
Err(format!(
"The staging folder {DIR_NAME} is not usable ({why}); nothing was changed"
))
};
match fs::symlink_metadata(&dir) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
@@ -140,8 +146,13 @@ pub fn recover(game_dir: &Path) -> Result<PathBuf, String> {
}
pub fn remove_if_idle(dir: &Path) {
let Ok(entries) = fs::read_dir(dir) else { return };
let names: Vec<String> = entries.flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect();
let Ok(entries) = fs::read_dir(dir) else {
return;
};
let names: Vec<String> = entries
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
if names.iter().all(|n| n == OWNER_FILE) {
let marker = dir.join(OWNER_FILE);
if names.is_empty() || fs::read(&marker).map(|c| c == OWNER_MARKER).unwrap_or(false) {
@@ -117,8 +117,16 @@ fn fixture() -> Fixture {
sha1: sha(&a),
tags: vec![],
parts: vec![
PartRef { guid: c1.guid, offset: 0, size: 3000 },
PartRef { guid: c2.guid, offset: 100, size: 1000 },
PartRef {
guid: c1.guid,
offset: 0,
size: 3000,
},
PartRef {
guid: c2.guid,
offset: 100,
size: 1000,
},
],
},
TestFile {
@@ -126,22 +134,43 @@ fn fixture() -> Fixture {
sha1: sha(&c),
tags: vec!["English".into()],
parts: vec![
PartRef { guid: c1.guid, offset: 3000, size: 1096 },
PartRef { guid: c3.guid, offset: 0, size: 1000 },
PartRef {
guid: c1.guid,
offset: 3000,
size: 1096,
},
PartRef {
guid: c3.guid,
offset: 0,
size: 1000,
},
],
},
TestFile {
path: "d.bin".into(),
sha1: sha(&d),
tags: vec![],
parts: vec![PartRef { guid: c2.guid, offset: 0, size: 100 }],
parts: vec![PartRef {
guid: c2.guid,
offset: 0,
size: 100,
}],
},
TestFile {
path: "e.txt".into(),
sha1: [0; 20],
tags: vec![],
parts: vec![],
},
TestFile { path: "e.txt".into(), sha1: [0; 20], tags: vec![], parts: vec![] },
TestFile {
path: "fr.txt".into(),
sha1: [0; 20],
tags: vec!["French".into()],
parts: vec![PartRef { guid: c3.guid, offset: 0, size: 10 }],
parts: vec![PartRef {
guid: c3.guid,
offset: 0,
size: 10,
}],
},
];
let m = TestManifest {
@@ -159,7 +188,10 @@ fn fixture() -> Fixture {
let mut served = HashMap::new();
served.insert("/Builds/x/test.manifest".to_string(), manifest_bytes);
for (info, data) in [(&c1, &d1), (&c2, &d2), (&c3, &d3)] {
served.insert(format!("/Builds/x/{}", parsed.chunk_path(info)), encode(data, true));
served.insert(
format!("/Builds/x/{}", parsed.chunk_path(info)),
encode(data, true),
);
}
let hits = Arc::new(AtomicUsize::new(0));
let base = serve(served, Arc::clone(&hits));
@@ -178,15 +210,21 @@ fn fixture() -> Fixture {
fn request(fx: &Fixture, game: &Path) -> Request {
Request {
info: BuildInfo {
cdns: vec![Cdn { base: fx.base.clone(), dir: "/Builds/x".into(), auth: String::new() }],
cdns: vec![Cdn {
base: fx.base.clone(),
dir: "/Builds/x".into(),
auth: String::new(),
}],
manifest_file: "test.manifest".into(),
build_version: "9.9".into(),
deployment_id: "dep".into(),
},
game_dir: game.to_path_buf(),
target_manifest_path: None,
scratch_dir: None,
storage_label: "Test storage".into(),
tags: vec!["English".into(), "en-US".into(), "en".into()],
previous_tags: vec!["English".into(), "en-US".into(), "en".into()],
max_workers: 4,
process_workers: 2,
ca_bundle: String::new(),
@@ -216,6 +254,11 @@ fn fresh_install_then_rerun_then_repair() {
assert_eq!(done.prereq_path, "redist/setup.exe");
assert!(done.prereq_read);
assert_eq!(done.deployment_id, "dep");
let installed_manifest = game.join(".droiddeck-maintenance/epic/installed.manifest");
assert!(
installed_manifest.is_file(),
"the parsed installed manifest is retained without CDN credentials"
);
assert_eq!(done.disk_bytes, 4000 + 2096 + 100);
assert_eq!(events.replaced.load(Ordering::SeqCst), 1);
assert_eq!(*events.last_progress.lock().unwrap(), (6196, 6196));
@@ -223,17 +266,41 @@ fn fresh_install_then_rerun_then_repair() {
assert!(!game.join(staging::DIR_NAME).exists());
let hits_before = fx.hits.load(Ordering::Relaxed);
let mut pinned = request(&fx, &game);
pinned.target_manifest_path = Some(installed_manifest);
let pinned_events = Recorder::default();
let pinned_result = run(&pinned, &cancel, &pinned_events).unwrap();
assert_eq!(pinned_result.build_version, "9.9");
assert_eq!(
pinned_events.replaced.load(Ordering::SeqCst),
0,
"an already-correct repair writes no files"
);
assert_eq!(
fx.hits.load(Ordering::Relaxed),
hits_before,
"pinned repair does not fetch the current manifest"
);
let again = Recorder::default();
run(&request(&fx, &game), &cancel, &again).unwrap();
assert_eq!(fx.hits.load(Ordering::Relaxed) - hits_before, 1, "only the manifest is fetched");
assert_eq!(again.sizes.lock().unwrap().0, 0);
assert_eq!(again.replaced.load(Ordering::SeqCst), 1);
assert_eq!(
again.replaced.load(Ordering::SeqCst),
0,
"the pinned files are already correct"
);
fs::write(game.join("b/c.bin"), b"damaged").unwrap();
let repair = Recorder::default();
run(&request(&fx, &game), &cancel, &repair).unwrap();
assert_eq!(fs::read(game.join("b/c.bin")).unwrap(), fx.expected[1].1);
assert_eq!(repair.sizes.lock().unwrap().0, 2096 + 0, "the damaged file and the hashless empty file");
assert_eq!(
repair.sizes.lock().unwrap().0,
2096,
"only the damaged file needs repair"
);
let _ = fs::remove_dir_all(&game);
}
@@ -254,6 +321,39 @@ fn a_refused_update_switch_replaces_nothing() {
let _ = fs::remove_dir_all(&game);
}
#[test]
fn obsolete_cleanup_removes_only_unchanged_prior_manifest_files() {
let game = temp_game("obsolete-cleanup");
fs::create_dir_all(&game).unwrap();
fs::write(game.join("old.bin"), b"old").unwrap();
fs::write(game.join("modified.bin"), b"user edit").unwrap();
fs::write(game.join("untracked.bin"), b"untracked").unwrap();
let old = vec![
FileEntry { path: "old.bin".into(), sha1: Some(Sha1::digest(b"old").into()), tags: vec![], parts: vec![], size: 3 },
FileEntry { path: "modified.bin".into(), sha1: Some(Sha1::digest(b"original").into()), tags: vec![], parts: vec![], size: 8 },
];
let new = vec![FileEntry { path: "current.bin".into(), sha1: None, tags: vec![], parts: vec![], size: 0 }];
let cancel = AtomicBool::new(false);
assert!(!cleanup_obsolete(&game, &old, &[], &new, &[], &cancel, &|_| {}));
assert!(!game.join("old.bin").exists());
assert_eq!(fs::read(game.join("modified.bin")).unwrap(), b"user edit");
assert_eq!(fs::read(game.join("untracked.bin")).unwrap(), b"untracked");
let _ = fs::remove_dir_all(&game);
}
#[test]
fn obsolete_cleanup_cancellation_preserves_the_old_inventory() {
let game = temp_game("obsolete-cancel");
fs::create_dir_all(&game).unwrap();
fs::write(game.join("old.bin"), b"old").unwrap();
let old = vec![FileEntry { path: "old.bin".into(), sha1: Some(Sha1::digest(b"old").into()), tags: vec![], parts: vec![], size: 3 }];
let new = vec![FileEntry { path: "current.bin".into(), sha1: None, tags: vec![], parts: vec![], size: 0 }];
let cancel = AtomicBool::new(true);
assert!(cleanup_obsolete(&game, &old, &[], &new, &[], &cancel, &|_| {}));
assert_eq!(fs::read(game.join("old.bin")).unwrap(), b"old");
let _ = fs::remove_dir_all(&game);
}
#[test]
fn a_cancelled_run_reports_cancel() {
let fx = fixture();
@@ -267,7 +367,11 @@ fn a_cancelled_run_reports_cancel() {
fn place_partial(fx: &Fixture, game: &Path, data: &[u8], list: &str) -> PathBuf {
let stage = staging::recover(game).unwrap();
let a = &fx.expected[0].1;
let partial = stage.join(staging::partial_name("a.bin", 4000, Some(Sha1::digest(a).into())));
let partial = stage.join(staging::partial_name(
"a.bin",
4000,
Some(Sha1::digest(a).into()),
));
fs::write(&partial, data).unwrap();
fs::write(staging::list_path(&partial), list).unwrap();
partial
@@ -285,7 +389,11 @@ fn a_paused_install_resumes_from_its_written_parts() {
place_done_c(&fx, &game);
place_partial(&fx, &game, &fx.expected[0].1[..3000], "0\n");
let stage = game.join(staging::DIR_NAME);
fs::write(stage.join(staging::partial_name("gone.bin", 9, None)), b"old").unwrap();
fs::write(
stage.join(staging::partial_name("gone.bin", 9, None)),
b"old",
)
.unwrap();
let events = Recorder::default();
run(&request(&fx, &game), &AtomicBool::new(false), &events).unwrap();
@@ -334,7 +442,12 @@ fn a_damaged_partial_fails_its_check_and_is_not_kept() {
let fx = fixture();
let game = temp_game("damagedpartial");
place_partial(&fx, &game, &[0u8; 3000], "0\n");
let err = run(&request(&fx, &game), &AtomicBool::new(false), &Recorder::default()).unwrap_err();
let err = run(
&request(&fx, &game),
&AtomicBool::new(false),
&Recorder::default(),
)
.unwrap_err();
assert_eq!(err, Stop::Failed("SHA-1 mismatch for a.bin".into()));
assert!(staging::partials(&game.join(staging::DIR_NAME)).is_empty());
let retry = Recorder::default();
@@ -182,10 +182,14 @@ impl<'a> Engine<'a> {
let Some(handle) = handle else {
return Err(format!("internal: {} is not open", file.path));
};
handle
.write_all_at(piece, part.file_offset)
.map_err(|e| format!("Could not assemble or atomically replace {}: {e}", file.path))?;
self.written.fetch_add(piece.len() as u64, Ordering::Relaxed);
handle.write_all_at(piece, part.file_offset).map_err(|e| {
format!(
"Could not assemble or atomically replace {}: {e}",
file.path
)
})?;
self.written
.fetch_add(piece.len() as u64, Ordering::Relaxed);
let complete = {
let mut slot = self.slots[f].lock().expect("slot lock");
@@ -231,14 +235,21 @@ impl<'a> Engine<'a> {
let (handle, stage, hash) = {
let mut slot = self.slots[f].lock().expect("slot lock");
slot.done = true;
(slot.file.take(), slot.stage.clone(), slot.hasher.take().map(|h| -> Hash { h.finalize().into() }))
(
slot.file.take(),
slot.stage.clone(),
slot.hasher.take().map(|h| -> Hash { h.finalize().into() }),
)
};
let stage = stage.ok_or_else(|| format!("internal: {} has no stage file", file.path))?;
let result = (|| -> Result<(), String> {
let handle = handle.ok_or_else(|| format!("internal: {} is not open", file.path))?;
handle
.sync_all()
.map_err(|e| format!("Could not assemble or atomically replace {}: {e}", file.path))?;
handle.sync_all().map_err(|e| {
format!(
"Could not assemble or atomically replace {}: {e}",
file.path
)
})?;
let len = handle.metadata().map(|m| m.len()).unwrap_or(u64::MAX);
if len != file.size {
return Err(format!("Size mismatch for {}", file.path));
@@ -266,11 +277,19 @@ impl<'a> Engine<'a> {
}
}
if let Some(parent) = file.dest.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Could not assemble or atomically replace {}: {e}", file.path))?;
fs::create_dir_all(parent).map_err(|e| {
format!(
"Could not assemble or atomically replace {}: {e}",
file.path
)
})?;
}
fs::rename(&stage, &file.dest)
.map_err(|e| format!("Could not assemble or atomically replace {}: {e}", file.path))
fs::rename(&stage, &file.dest).map_err(|e| {
format!(
"Could not assemble or atomically replace {}: {e}",
file.path
)
})
})();
if file.resume.is_some() {
let _ = fs::remove_file(staging::list_path(&stage));
@@ -295,7 +314,10 @@ impl<'a> Engine<'a> {
fn open_stage(&self, f: usize) -> Result<(), String> {
let file = &self.input.files[f];
let (path, opened) = match &file.resume {
Some(r) => (r.stage.clone(), OpenOptions::new().read(true).write(true).open(&r.stage)),
Some(r) => (
r.stage.clone(),
OpenOptions::new().read(true).write(true).open(&r.stage),
),
None => {
let path = self.input.stage_dir.join(staging::unique_name());
let opened = OpenOptions::new().read(true).write(true).create_new(true).open(&path);
@@ -397,8 +419,12 @@ impl<'a> Engine<'a> {
producer.uses.iter().any(|&u| schedule.use_file[u as usize] as usize >= c.next_file)
};
if needs_cache {
cache::store(&self.input.cache_dir, &cache::entry_name(info, producer.copy), &data)
.map_err(|e| SinkError::Fatal(self.fail(e)))?;
cache::store(
&self.input.cache_dir,
&cache::entry_name(info, producer.copy),
&data,
)
.map_err(|e| SinkError::Fatal(self.fail(e)))?;
}
let claims: Vec<usize> = {
let mut c = self.coord.lock().expect("coord lock");
@@ -521,7 +547,12 @@ pub fn run(input: &EngineInput<'_>, stop: &AtomicBool, events: &dyn Events) -> R
stream: false,
};
events.progress(input.progress_base, engine.total_bytes, 0, input.files.len() as u32);
events.progress(
input.progress_base,
engine.total_bytes,
0,
input.files.len() as u32,
);
let log = |line: &str| events.log(line);
let gate = |i: usize| -> bool {
engine.hint.fetch_max(i, Ordering::Relaxed);
@@ -546,7 +577,16 @@ pub fn run(input: &EngineInput<'_>, stop: &AtomicBool, events: &dyn Events) -> R
let outcome = if items.is_empty() {
None
} else {
Some(run_fetch_gated(items, &hosts, &options, &Sink(&engine), stop, &|_: u64, _: u64| {}, &log, &gate))
Some(run_fetch_gated(
items,
&hosts,
&options,
&Sink(&engine),
stop,
&|_: u64, _: u64| {},
&log,
&gate,
))
};
{
let mut c = engine.coord.lock().expect("coord lock");
@@ -597,7 +637,10 @@ fetches={} refetches={} peak_cache_mib={} hosts={}",
return Err(Stop::Failed(message));
}
if complete {
return Ok(Stats { bytes_written: written, files_written: input.files.len() });
return Ok(Stats {
bytes_written: written,
files_written: input.files.len(),
});
}
if let Some(message) = fetch_error {
return Err(Stop::Failed(format!("Download failed: {}", tidy_fetch_error(&message))));
@@ -605,7 +648,9 @@ fetches={} refetches={} peak_cache_mib={} hosts={}",
if stop.load(Ordering::SeqCst) {
return Err(Stop::Cancelled);
}
Err(Stop::Failed("The download ended before every file was written".into()))
Err(Stop::Failed(
"The download ended before every file was written".into(),
))
}
fn hash_whole(handle: &File, file: &EngineFile) -> Result<Hash, String> {
@@ -624,8 +669,18 @@ fn hash_whole(handle: &File, file: &EngineFile) -> Result<Hash, String> {
}
fn keep_partial(file: &EngineFile, written: &[bool], stage: &Path, dir: &Path) -> bool {
let mut kept: Vec<u64> = file.resume.as_ref().map(|r| r.kept.clone()).unwrap_or_default();
kept.extend(file.parts.iter().zip(written).filter(|(_, w)| **w).map(|(p, _)| p.file_offset));
let mut kept: Vec<u64> = file
.resume
.as_ref()
.map(|r| r.kept.clone())
.unwrap_or_default();
kept.extend(
file.parts
.iter()
.zip(written)
.filter(|(_, w)| **w)
.map(|(p, _)| p.file_offset),
);
if kept.is_empty() || file.sha1.is_none() {
return false;
}
@@ -657,7 +712,12 @@ mod tests {
use super::*;
fn part(file_offset: u64, size: u32) -> PlanPart {
PlanPart { chunk: 0, chunk_offset: 0, size, file_offset }
PlanPart {
chunk: 0,
chunk_offset: 0,
size,
file_offset,
}
}
#[test]
@@ -697,7 +757,10 @@ mod tests {
let resumed = EngineFile {
parts: vec![part(0, 10)],
resume: Some(Resume { stage: kept.clone(), kept: vec![20, 10] }),
resume: Some(Resume {
stage: kept.clone(),
kept: vec![20, 10],
}),
..file
};
assert!(keep_partial(&resumed, &[false], &kept, &dir));
+135 -19
View File
@@ -2,7 +2,9 @@ use serde_json::{json, Value};
use super::build_info::{self, BuildInfo};
use super::net::str_of;
use super::{account, achievements, catalog, install, launch, library, locale, manifest, savesync, shop};
use super::{
account, achievements, catalog, install, launch, library, locale, manifest, savesync, shop,
};
fn error(message: impl Into<String>) -> Value {
json!({ "error": message.into() })
@@ -45,10 +47,16 @@ fn dispatch(op: &str, req: &Value) -> Value {
Ok(list) => json!({ "records": list.iter().map(|o| json!({
"appName": o.app_name, "namespace": o.namespace, "catalogItemId": o.catalog_item_id,
})).collect::<Vec<_>>() }),
Err(library::ListError::SignIn) => json!({ "error": "sign-in refused", "signIn": true }),
Err(library::ListError::SignIn) => {
json!({ "error": "sign-in refused", "signIn": true })
}
Err(library::ListError::Failed(e)) => error(e),
},
"catalog.details" => match catalog::details(token, str_of(req, "namespace"), str_of(req, "catalogItemId")) {
"catalog.details" => match catalog::details(
token,
str_of(req, "namespace"),
str_of(req, "catalogItemId"),
) {
Some(d) => json!({ "details": d }),
None => error("no catalog answer"),
},
@@ -70,9 +78,13 @@ fn dispatch(op: &str, req: &Value) -> Value {
Some(info) => json!({ "info": info.to_json() }),
None => error("The manifest could not be fetched"),
},
"build.cdns" => json!({ "cdns": info_of(req).map(|i| build_info::cdns_to_json(&i.cdns)).unwrap_or(json!([])) }),
"build.cdns" => {
json!({ "cdns": info_of(req).map(|i| build_info::cdns_to_json(&i.cdns)).unwrap_or(json!([])) })
}
"build.prereq" => match info_of(req).map(|i| install::fetch_manifest(&i, &|_: &str| {})) {
Some(Ok(m)) => json!({ "name": m.prereq_name, "path": m.prereq_path, "read": m.prereq_read }),
Some(Ok(m)) => {
json!({ "name": m.prereq_name, "path": m.prereq_path, "read": m.prereq_read })
}
Some(Err(e)) => error(e),
None => error("no build information"),
},
@@ -86,17 +98,29 @@ fn dispatch(op: &str, req: &Value) -> Value {
Some(list) => json!({ "list": list }),
None => error("unreachable"),
},
"cloud.list" => match savesync::list(token, str_of(req, "accountId"), str_of(req, "appName")) {
Ok(files) => json!({ "files": files }),
Err(e) => error(e),
},
"cloud.list" => {
match savesync::list(token, str_of(req, "accountId"), str_of(req, "appName")) {
Ok(files) => json!({ "files": files }),
Err(e) => error(e),
}
}
"cloud.writeLinks" => {
let names: Vec<String> = req
.get("names")
.and_then(|n| n.as_array())
.map(|a| a.iter().filter_map(|n| n.as_str()).map(str::to_string).collect())
.map(|a| {
a.iter()
.filter_map(|n| n.as_str())
.map(str::to_string)
.collect()
})
.unwrap_or_default();
match savesync::write_links(token, str_of(req, "accountId"), str_of(req, "appName"), &names) {
match savesync::write_links(
token,
str_of(req, "accountId"),
str_of(req, "appName"),
&names,
) {
Ok(links) => json!({ "links": links }),
Err(e) => error(e),
}
@@ -154,11 +178,42 @@ pub fn inspect(bytes: &[u8], request: &str) -> String {
Err(e) => return error(e).to_string(),
};
let out = match str_of(&req, "op") {
"estimate" => {
let tags: Option<Vec<String>> = req.get("tags").and_then(|t| t.as_array()).map(|a| {
a.iter()
.filter_map(|t| t.as_str())
.map(str::to_string)
.collect()
});
let selected = locale::select(&m.files, tags.as_deref());
let mut chunks = std::collections::HashSet::new();
let mut disk_bytes = 0u64;
for &i in &selected {
let file = &m.files[i];
disk_bytes = disk_bytes.saturating_add(file.size);
for part in &file.parts {
if let Some(&chunk) = m.chunk_index.get(&part.guid) {
chunks.insert(chunk);
}
}
}
let download_bytes: u64 = chunks.iter().fold(0u64, |sum, &i| {
let chunk = &m.chunks[i];
sum.saturating_add(if chunk.cdn_size > 0 {
chunk.cdn_size
} else {
chunk.window as u64
})
});
json!({ "files": selected.len(), "chunks": chunks.len(), "downloadBytes": download_bytes, "diskBytes": disk_bytes })
}
"select" => {
let tags: Option<Vec<String>> = req
.get("tags")
.and_then(|t| t.as_array())
.map(|a| a.iter().filter_map(|t| t.as_str()).map(str::to_string).collect());
let tags: Option<Vec<String>> = req.get("tags").and_then(|t| t.as_array()).map(|a| {
a.iter()
.filter_map(|t| t.as_str())
.map(str::to_string)
.collect()
});
json!({ "indices": locale::select(&m.files, tags.as_deref()) })
}
"launch" => json!({
@@ -187,13 +242,21 @@ mod tests {
let v: Value = serde_json::from_str(&call("locale", r#"{"language":"pt","region":"BR"}"#)).unwrap();
assert_eq!(v["key"], "pt-BR");
assert_eq!(v["launchLocale"], "pt");
let v: Value = serde_json::from_str(&call("auth.codeFromPage", r#"{"text":"{\"authorizationCode\":\"c1\"}"}"#)).unwrap();
let v: Value = serde_json::from_str(&call(
"auth.codeFromPage",
r#"{"text":"{\"authorizationCode\":\"c1\"}"}"#,
))
.unwrap();
assert_eq!(v["code"], "c1");
let v: Value = serde_json::from_str(&call("nope", "{}")).unwrap();
assert!(v["error"].is_string());
let v: Value = serde_json::from_str(&call("launch.args", r#"{"namespace":"ns","appName":"A"}"#)).unwrap();
assert_eq!(v["args"][0], "-EpicPortal");
let v: Value = serde_json::from_str(&call("launch.split", r#"{"command":"-PCStore=EGS \"-log=a b.txt\""}"#)).unwrap();
let v: Value = serde_json::from_str(&call(
"launch.split",
r#"{"command":"-PCStore=EGS \"-log=a b.txt\""}"#,
))
.unwrap();
assert_eq!(v["args"], json!(["-PCStore=EGS", "-log=a b.txt"]));
}
@@ -207,8 +270,18 @@ mod tests {
prereq: None,
chunks: vec![],
files: vec![
TestFile { path: "x.exe".into(), sha1: [0; 20], tags: vec![], parts: vec![] },
TestFile { path: "de.pak".into(), sha1: [0; 20], tags: vec!["German".into()], parts: vec![] },
TestFile {
path: "x.exe".into(),
sha1: [0; 20],
tags: vec![],
parts: vec![],
},
TestFile {
path: "de.pak".into(),
sha1: [0; 20],
tags: vec!["German".into()],
parts: vec![],
},
],
compress: false,
meta_tail: vec![],
@@ -225,4 +298,47 @@ mod tests {
let v: Value = serde_json::from_str(&inspect(b"nonsense", "{}")).unwrap();
assert!(v["error"].is_string());
}
#[test]
fn estimate_counts_unique_chunks_and_selected_disk_bytes() {
use crate::epic::manifest::{
testing::{build, chunk, TestFile, TestManifest},
PartRef,
};
let chunk = chunk(50, 4);
let part = PartRef {
guid: chunk.guid,
offset: 0,
size: 4,
};
let manifest = TestManifest {
version: 21,
build: "1".into(),
exe: "Game.exe".into(),
prereq: None,
chunks: vec![chunk],
files: vec![
TestFile {
path: "a.bin".into(),
sha1: [1; 20],
tags: vec![],
parts: vec![part],
},
TestFile {
path: "b.bin".into(),
sha1: [2; 20],
tags: vec![],
parts: vec![part],
},
],
compress: false,
meta_tail: Vec::new(),
};
let response: Value =
serde_json::from_str(&inspect(&build(&manifest), r#"{"op":"estimate"}"#)).unwrap();
assert_eq!(response["files"], 2);
assert_eq!(response["chunks"], 1);
assert_eq!(response["downloadBytes"], 1000);
assert_eq!(response["diskBytes"], 8);
}
}
@@ -3,11 +3,15 @@ package com.droiddeck.launcher.stores.itch
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
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 java.io.File
@RunWith(RobolectricTestRunner::class)
class ItchStagingTest {
@get:Rule val tmp = TemporaryFolder()
@@ -34,18 +38,16 @@ class ItchStagingTest {
assertTrue(staged.listFiles().orEmpty().all { it.isDirectory && it.listFiles().orEmpty().isEmpty() })
}
@Test fun aFileReplacingADirectoryAndTheOtherWayRound() {
@Test fun aFileDirectoryCollisionFailsWithoutDeletingExistingData() {
val game = tmp.newFolder("game")
val staged = tmp.newFolder("staged")
file(game, "a/inner.txt", "old")
file(game, "b", "old")
file(staged, "a", "new")
file(staged, "b/inner.txt", "new")
ItchStaging.merge(staged, game)
assertTrue(File(game, "a").isFile)
assertEquals("new", File(game, "a").readText())
assertEquals("new", File(game, "b/inner.txt").readText())
assertFalse(File(game, "a/inner.txt").exists())
assertThrows(IllegalArgumentException::class.java) { ItchStaging.merge(staged, game) }
assertEquals("old", File(game, "a/inner.txt").readText())
assertEquals("old", File(game, "b").readText())
}
@Test fun deletingFollowsNoLinkOutOfTheTree() {
@@ -82,4 +84,148 @@ class ItchStagingTest {
assertEquals("record", File(game, ".droiddeck-store.json").readText())
assertEquals("nested", File(game, "sub/.droiddeck-store.json").readText())
}
@Test fun managedCommitRepairsCorruptionRemovesOnlyUntouchedObsoleteFilesAndKeepsSaves() {
val game = tmp.newFolder("managed-game")
val staged = tmp.newFolder("managed-stage")
file(game, "bin/game.exe", "corrupted")
file(game, "data/removed.dat", "publisher-old")
file(game, "saves/slot1.sav", "my-save")
file(game, "data/user.ini", "my-config")
file(staged, "bin/game.exe", "publisher-new")
file(staged, "data/new.dat", "new")
val previous = ItchStaging.inventory(tmp.newFolder("old-inventory")).toMutableMap()
val oldFiles = tmp.newFolder("old-publisher")
file(oldFiles, "bin/game.exe", "publisher-prior")
file(oldFiles, "data/removed.dat", "publisher-old")
previous.putAll(ItchStaging.inventory(oldFiles))
val next = ItchStaging.commit(staged, game, previous)
assertEquals("publisher-new", File(game, "bin/game.exe").readText())
assertFalse(File(game, "data/removed.dat").exists())
assertEquals("my-save", File(game, "saves/slot1.sav").readText())
assertEquals("my-config", File(game, "data/user.ini").readText())
assertEquals("new", File(game, "data/new.dat").readText())
ItchStaging.writeInventory(game, next)
assertEquals(next, ItchStaging.readInventory(game))
}
@Test fun interruptedCommitResumesFromPinnedInventoryAndCompletesObsoleteCleanup() {
val game = tmp.newFolder("resume-game")
val staged = tmp.newFolder("resume-stage")
file(game, "old.txt", "old")
file(staged, "a.txt", "new-a")
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") }
}
assertTrue(ItchStaging.recover(game))
val next = ItchStaging.commit(staged, game, previous)
assertEquals("new-a", File(game, "a.txt").readText())
assertEquals("new-b", File(game, "b.txt").readText())
assertFalse(File(game, "old.txt").exists())
assertEquals(setOf("a.txt", "b.txt"), next.keys)
assertFalse(ItchStaging.recover(game))
}
@Test fun unmanagedCollisionPreservesUserFile() {
val game = tmp.newFolder("collision-game")
val staged = tmp.newFolder("collision-stage")
file(game, "saves/slot1.sav", "my-save")
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())
}
@Test fun publisherUserDataPlaceholdersAndEmptyDirectoriesAreInstalledAndManaged() {
val game = tmp.newFolder("publisher-user-data-game")
val staged = tmp.newFolder("publisher-user-data-stage")
file(staged, "profiles/default/config.ini", "publisher defaults")
file(staged, "saves/default.sav", "")
File(staged, "data/required-empty").mkdirs()
assertThrows(IllegalStateException::class.java) {
ItchStaging.commit(staged, game, emptyMap()) { if (it == 2) error("simulated interruption") }
}
assertTrue(ItchStaging.recover(game))
val next = ItchStaging.commit(staged, game, emptyMap())
assertEquals("publisher defaults", File(game, "profiles/default/config.ini").readText())
assertTrue(File(game, "saves/default.sav").isFile)
assertEquals(0L, File(game, "saves/default.sav").length())
assertTrue(File(game, "data/required-empty").isDirectory)
assertTrue("profiles/default/config.ini" in next)
assertTrue("saves/default.sav" in next)
assertEquals("<directory>", next["data/required-empty/"])
ItchStaging.writeInventory(game, next)
assertEquals(next, ItchStaging.readInventory(game))
assertFalse(ItchStaging.recover(game))
}
@Test fun locallyModifiedManagedProfileIsPreservedByRefusingReplacement() {
val game = tmp.newFolder("modified-save-game")
val oldPublisher = tmp.newFolder("modified-save-old")
val staged = tmp.newFolder("modified-save-stage")
file(oldPublisher, "profiles/settings.ini", "publisher defaults")
file(game, "profiles/settings.ini", "user settings")
file(staged, "profiles/settings.ini", "new publisher defaults")
val previous = ItchStaging.inventory(oldPublisher)
assertThrows(IllegalArgumentException::class.java) { ItchStaging.commit(staged, game, previous) }
assertEquals("user settings", File(game, "profiles/settings.ini").readText())
}
@Test fun aDoneJournalEntryIsRehashedBeforeResumeSkipsIt() {
val game = tmp.newFolder("corrupt-resume-game")
val staged = tmp.newFolder("corrupt-resume-stage")
file(game, "old.txt", "old")
file(staged, "a.txt", "new-a")
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") }
}
file(game, "a.txt", "tampered")
file(staged, "a.txt", "new-a")
ItchStaging.commit(staged, game, previous)
assertEquals("new-a", File(game, "a.txt").readText())
assertEquals("new-b", File(game, "b.txt").readText())
}
@Test fun corruptOrSymlinkedCommitJournalsFailClosed() {
val game = tmp.newFolder("journal-game")
val staged = tmp.newFolder("journal-stage")
val metadata = File(game, ".droiddeck-maintenance").also { it.mkdirs() }
file(game, "user.txt", "keep")
file(staged, "user.txt", "publisher")
val journal = File(metadata, "itch-commit.json")
journal.writeText("{")
assertThrows(IllegalStateException::class.java) { ItchStaging.commit(staged, game, emptyMap()) }
assertThrows(IllegalStateException::class.java) { ItchStaging.recover(game) }
assertEquals("keep", File(game, "user.txt").readText())
journal.delete()
val outside = file(tmp.newFolder("journal-outside"), "outside.json", "{}")
java.nio.file.Files.createSymbolicLink(journal.toPath(), outside.toPath())
assertThrows(IllegalStateException::class.java) { ItchStaging.commit(staged, game, emptyMap()) }
assertThrows(IllegalStateException::class.java) { ItchStaging.recover(game) }
assertEquals("{}", outside.readText())
assertEquals("keep", File(game, "user.txt").readText())
journal.delete()
metadata.delete()
val outsideMetadata = tmp.newFolder("outside-metadata")
file(outsideMetadata, "itch-commit.json", "{}")
java.nio.file.Files.createSymbolicLink(metadata.toPath(), outsideMetadata.toPath())
assertThrows(IllegalArgumentException::class.java) { ItchStaging.recover(game) }
assertEquals("{}", File(outsideMetadata, "itch-commit.json").readText())
}
@Test fun corruptManagedInventoryFailsClosed() {
val game = tmp.newFolder("inventory-game")
val metadata = File(game, ".droiddeck-maintenance").also { it.mkdirs() }
file(game, "publisher.txt", "keep")
File(metadata, "itch-inventory.json").writeText("{\"../outside.txt\":\"bad\"}")
assertThrows(IllegalArgumentException::class.java) { ItchStaging.readInventory(game) }
assertEquals("keep", File(game, "publisher.txt").readText())
}
}
@@ -0,0 +1,122 @@
package com.droiddeck.launcher.stores.maintenance
import android.content.Context
import com.droiddeck.launcher.stores.InstalledStoreGame
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreGameSidecar
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import java.io.File
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class StoreMaintenanceTest {
@get:Rule val temporary = TemporaryFolder()
private val adapter = object : StoreMaintenanceAdapter {
override val store = Store.EPIC
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 resume(context: Context, game: InstalledStoreGame, operation: JSONObject) = Unit
}
@Test fun operationPinsFolderTargetAndFullSidecarBeforeApply() {
val app = RuntimeEnvironment.getApplication()
val folder = temporary.newFolder().canonicalFile
val sidecar = StoreGameSidecar(Store.EPIC, "app", "Example", "bin/game.exe", args = listOf("--user-value"),
env = mapOf("CUSTOM_SETTING" to "kept"), installedAt = 1234L, extra = mapOf("channel" to "test"))
sidecar.write(folder)
val game = InstalledStoreGame(sidecar, folder)
StoreMaintenance.register(adapter)
StoreMaintenance.enqueue(app, game, MaintenanceProposal(folder, sidecar, "version 2", MaintenanceAction.UPDATE,
downloadBytes = 42, diskBytes = 84, target = JSONObject().put("schema", 1).put("build", "2")))
val journal = StoreMaintenance.readOperation(folder)!!
assertEquals(folder.path, journal.getString("folder"))
assertEquals("app", journal.getString("id"))
assertEquals("queued", journal.getString("phase"))
assertEquals("2", journal.getJSONObject("target").getString("build"))
val before = StoreGameSidecar.parse(journal.getJSONObject("before").toString())!!
assertEquals(sidecar.args, before.args)
assertEquals(sidecar.env, before.env)
assertEquals(sidecar.installedAt, before.installedAt)
assertEquals(sidecar.extra, before.extra)
}
@Test fun failedApplyLeavesLaunchExcludedRecoveryStateAndJournal() {
val folder = temporary.newFolder().canonicalFile
val sidecar = StoreGameSidecar(Store.EPIC, "app", "Example", "bin/game.exe")
sidecar.write(folder)
val game = InstalledStoreGame(sidecar, folder)
StoreMaintenance.register(adapter)
StoreMaintenance.enqueue(RuntimeEnvironment.getApplication(), game,
MaintenanceProposal(folder, sidecar, "version 2", MaintenanceAction.UPDATE, target = JSONObject().put("schema", 1)))
runCatching {
StoreMaintenance.withApplyLease(game) {
error("simulated interrupted apply")
}
}
assertEquals(StoreGameSidecar.STATE_UPDATING, StoreGameSidecar.read(folder)!!.state)
assertEquals("applying", StoreMaintenance.readOperation(folder)!!.getString("phase"))
assertEquals(RecoveryState.NEEDS_ATTENTION, StoreMaintenance.recovery(RuntimeEnvironment.getApplication(), game).state)
assertTrue(StoreMaintenance.launchExcluded(game))
assertTrue(StoreMaintenance.operationFile(folder).isFile)
}
@Test fun queuedOperationDoesNotMarkTheInstallAsApplying() {
val folder = temporary.newFolder().canonicalFile
val sidecar = StoreGameSidecar(Store.EPIC, "app", "Example", "bin/game.exe")
sidecar.write(folder)
val game = InstalledStoreGame(sidecar, folder)
StoreMaintenance.register(adapter)
StoreMaintenance.enqueue(RuntimeEnvironment.getApplication(), game,
MaintenanceProposal(folder, sidecar, "version 2", MaintenanceAction.UPDATE, target = JSONObject().put("schema", 1)))
assertFalse(StoreMaintenance.launchExcluded(game))
assertEquals(RecoveryState.RESUMABLE, StoreMaintenance.recovery(RuntimeEnvironment.getApplication(), game).state)
}
@Test fun applyLeaseIsExclusiveAndMarksTheJournalBeforeMutation() {
val folder = temporary.newFolder().canonicalFile
val sidecar = StoreGameSidecar(Store.EPIC, "app", "Example", "bin/game.exe")
sidecar.write(folder)
val game = InstalledStoreGame(sidecar, folder)
StoreMaintenance.register(adapter)
StoreMaintenance.enqueue(RuntimeEnvironment.getApplication(), game,
MaintenanceProposal(folder, sidecar, "version 2", MaintenanceAction.UPDATE, target = JSONObject().put("schema", 1)))
val lease = StoreMaintenance.beginApplyLease(game)
try {
assertEquals("applying", StoreMaintenance.readOperation(folder)!!.getString("phase"))
assertEquals(StoreGameSidecar.STATE_UPDATING, StoreGameSidecar.read(folder)!!.state)
assertTrue(runCatching { StoreMaintenance.beginApplyLease(game) }.isFailure)
} finally {
lease.close()
}
}
@Test fun unsupportedOrMismatchedJournalBlocksLaunch() {
val folder = temporary.newFolder().canonicalFile
val game = InstalledStoreGame(StoreGameSidecar(Store.EPIC, "app", "Example", "bin/game.exe"), folder)
File(folder, ".droiddeck-maintenance").mkdirs()
StoreMaintenance.operationFile(folder).writeText(JSONObject().put("schema", 9).put("folder", folder.path).put("phase", "download").toString())
assertTrue(StoreMaintenance.launchExcluded(game))
assertTrue(runCatching { StoreMaintenance.readOperation(folder) }.isFailure)
assertEquals(RecoveryState.NEEDS_ATTENTION, StoreMaintenance.recovery(RuntimeEnvironment.getApplication(), game).state)
}
}
@@ -0,0 +1,28 @@
package com.droiddeck.launcher.stores.xbox
import com.droiddeck.launcher.stores.maintenance.StoreMaintenance
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Test
class XboxMaintenancePersistenceTest {
@Test fun completedMaintenanceDoesNotPersistQueueMarkers() {
val persisted = XboxInstaller.maintenanceSidecarExtra(
mapOf(
"contentId" to "old-content",
"market" to "US",
"preferredLanguage" to "fr-FR",
StoreMaintenance.OPERATION_KEY to "UPDATE",
StoreMaintenance.FOLDER_KEY to "/games/example",
),
"new-content",
"GB",
)
assertEquals("new-content", persisted["contentId"])
assertEquals("GB", persisted["market"])
assertEquals("fr-FR", persisted["preferredLanguage"])
assertFalse(persisted.containsKey(StoreMaintenance.OPERATION_KEY))
assertFalse(persisted.containsKey(StoreMaintenance.FOLDER_KEY))
}
}
+34
View File
@@ -0,0 +1,34 @@
# Store maintenance
The shared maintenance coordinator checks installed games, throttles successful
metadata checks, pins update targets, and persists operations through the
existing download queue. Install settings are restored from the original
sidecar after a successful apply. Interrupted or uncertain applications retain
their journal and block launch until recovery. A provider exposes only actions
its existing engine or client can perform.
## Shipped actions and limits
- **Epic Games:** metadata checks, pinned manifest updates, and repair from the
retained installed manifest. Existing Epic install settings and locale tags
are preserved.
- **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.
- **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.
- **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.
- **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.
- **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.
Unknown transfer or disk estimates use a negative sentinel and appear as
“Unknown (client-determined)”; zero remains a real zero-byte estimate.
@@ -67,6 +67,7 @@ def generation(pid):
def lease(folder, owner):
folder = Path(folder).resolve(strict=True)
root = control(folder)
with lock_file(root) as stream:
fcntl.lockf(stream, fcntl.LOCK_SH | fcntl.LOCK_NB)
@@ -76,6 +77,36 @@ def lease(folder, owner):
journal = root / "operation.json"
if journal.exists() and read(journal).get("phase") != "download":
raise ValueError("GOG maintenance is incomplete")
common_journal = folder / ".droiddeck-maintenance" / "operation.json"
if common_journal.exists() and read(common_journal).get("phase") not in ("queued", "download"):
raise ValueError("Store maintenance is incomplete")
start = generation(owner)
if start is None:
raise ValueError("Game launcher has exited")
print("ready", flush=True)
sys.stdout.close()
while generation(owner) == start:
time.sleep(0.25)
return 0
def store_lease(folder, owner):
folder = Path(folder).resolve(strict=True)
root = folder / ".droiddeck-maintenance"
if root.is_symlink():
raise ValueError("maintenance control directory is a symbolic link")
root.mkdir(exist_ok=True)
with lock_file(root) as stream:
fcntl.lockf(stream, fcntl.LOCK_SH | fcntl.LOCK_NB)
sidecar = read(folder / ".droiddeck-store.json")
if sidecar.get("state", "installed") != "installed":
raise ValueError("Resume or recover the store update before playing")
journal = root / "operation.json"
if journal.exists() and read(journal).get("phase") not in ("queued", "download"):
raise ValueError("Store maintenance is incomplete")
gog_journal = folder / ".droiddeck-gog" / "operation.json"
if gog_journal.exists() and read(gog_journal).get("phase") != "download":
raise ValueError("GOG maintenance is incomplete")
start = generation(owner)
if start is None:
raise ValueError("Game launcher has exited")
@@ -325,6 +356,8 @@ def prepare_setup(folder, command, env):
def main(argv):
if len(argv) == 3 and argv[0] == "lease":
return lease(argv[1], int(argv[2]))
if len(argv) == 3 and argv[0] == "store-lease":
return store_lease(argv[1], int(argv[2]))
if len(argv) == 4 and argv[0] == "idle":
return idle(*argv[1:])
if len(argv) == 2 and argv[0] == "status":
@@ -566,6 +566,9 @@ def main(argv):
return 0
folder, sidecar = found
store, game = str(sidecar.get("store")), str(sidecar.get("id"))
if sidecar.get("state", "installed") != "installed":
log("%s: resume or recover maintenance before playing" % store)
return RAN_FAILED
if store == "gog":
helper = Path(__file__).resolve().with_name("droiddeck-gog-maintenance")
if helper.is_file():
@@ -578,6 +581,18 @@ def main(argv):
if ready != "ready":
log("gog: close or recover the maintenance operation before playing")
return RAN_FAILED
else:
helper = Path(__file__).resolve().with_name("droiddeck-gog-maintenance")
if helper.is_file():
child = subprocess.Popen([sys.executable, str(helper), "store-lease", str(folder), str(os.getppid())],
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL,
start_new_session=True)
ready = child.stdout.readline().decode().strip()
child.stdout.close()
threading.Thread(target=child.wait, daemon=True).start()
if ready != "ready":
log("%s: close or recover the maintenance operation before playing" % store)
return RAN_FAILED
compat_data = os.environ.get("STEAM_COMPAT_DATA_PATH", "").rstrip("/")
prefix_id = os.path.basename(compat_data) if compat_data else ""
launch_dir = os.environ.get("BL_LAUNCH_DIR", "")
+29
View File
@@ -109,6 +109,35 @@ class GogMaintenance(unittest.TestCase):
with self.assertRaises(ValueError):
G["lease"](self.folder, os.getpid())
def test_shared_store_lease_excludes_updates_until_the_game_exits(self):
import fcntl
(self.folder / ".droiddeck-store.json").write_text(json.dumps({"store": "epic", "id": "1"}))
control = self.folder / ".droiddeck-maintenance"
control.mkdir()
owner = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"])
self.addCleanup(lambda: owner.poll() is None and owner.kill())
lease = subprocess.Popen([sys.executable, str(BIN / "droiddeck-gog-maintenance"), "store-lease", str(self.folder), str(owner.pid)],
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
self.addCleanup(lease.stdout.close)
self.addCleanup(lease.stderr.close)
self.addCleanup(lambda: lease.poll() is None and lease.kill())
self.assertEqual(b"ready\n", lease.stdout.readline())
with G["lock_file"](control) as stream:
with self.assertRaises(BlockingIOError):
fcntl.lockf(stream, fcntl.LOCK_EX | fcntl.LOCK_NB)
owner.terminate(); owner.wait()
lease.wait(timeout=5)
with G["lock_file"](control) as stream:
fcntl.lockf(stream, fcntl.LOCK_EX | fcntl.LOCK_NB)
def test_shared_store_lease_rejects_applying_or_incomplete_journals(self):
(self.folder / ".droiddeck-store.json").write_text(json.dumps({"store": "amazon", "id": "1"}))
control = self.folder / ".droiddeck-maintenance"
control.mkdir()
(control / "operation.json").write_text(json.dumps({"schema": 1, "store": "amazon", "id": "1", "phase": "applying"}))
with self.assertRaises(ValueError):
G["store_lease"](self.folder, os.getpid())
def test_running_game_and_wrong_prefix_are_distinguished(self):
proc = self.folder / "proc"
for pid, prefix, name in [(10, "/prefix/pfx", "game.exe"), (11, "/other/pfx", "other.exe"), (12, "/prefix/pfx", "services.exe")]: