mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-12 11:31:08 +00:00
feat: add store maintenance
This commit is contained in:
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)
|
||||
Generated
+20
@@ -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"
|
||||
|
||||
@@ -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(),
|
||||
))
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+28
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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", "")
|
||||
|
||||
@@ -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")]:
|
||||
|
||||
Reference in new issue
Block a user