From cd09deddba8197d68e2c7c13ef9166e472435a80 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:19:45 -0400 Subject: [PATCH] feat: add store maintenance --- .../droiddeck/launcher/stores/StoreLaunch.kt | 10 +- .../droiddeck/launcher/stores/StoresState.kt | 12 +- .../launcher/stores/amazon/AmazonBackend.kt | 2 + .../launcher/stores/amazon/AmazonInstaller.kt | 27 +- .../stores/amazon/AmazonMaintenance.kt | 141 ++++++++ .../stores/battlenet/BattleNetInstall.kt | 46 ++- .../stores/battlenet/BattleNetMaintenance.kt | 87 +++++ .../stores/battlenet/BattleNetStore.kt | 2 + .../launcher/stores/download/SavedQueue.kt | 9 +- .../launcher/stores/epic/EpicBackend.kt | 2 + .../launcher/stores/epic/EpicInstaller.kt | 35 +- .../launcher/stores/epic/EpicMaintenance.kt | 169 ++++++++++ .../launcher/stores/itch/ItchBackend.kt | 3 + .../launcher/stores/itch/ItchInstaller.kt | 59 +++- .../launcher/stores/itch/ItchMaintenance.kt | 134 ++++++++ .../launcher/stores/itch/ItchStaging.kt | 262 ++++++++++++++- .../stores/maintenance/StoreMaintenance.kt | 305 +++++++++++++++++ .../launcher/stores/xbox/XboxBackend.kt | 56 ++++ .../launcher/stores/xbox/XboxInstaller.kt | 44 ++- .../launcher/stores/xbox/XboxMaintenance.kt | 53 +++ .../com/droiddeck/launcher/ui/GameDetail.kt | 19 +- .../launcher/ui/StoreMaintenanceUi.kt | 180 ++++++++++ app/src/main/rust/stores/Cargo.lock | 20 ++ app/src/main/rust/stores/Cargo.toml | 1 + .../rust/stores/src/amazon/install/check.rs | 166 ++++++++- .../rust/stores/src/amazon/install/mod.rs | 234 ++++++++++++- .../rust/stores/src/amazon/install/tests.rs | 152 ++++++++- .../rust/stores/src/amazon/install/writer.rs | 130 +++++-- .../main/rust/stores/src/amazon/manifest.rs | 121 ++++++- .../main/rust/stores/src/amazon/service.rs | 64 +++- .../rust/stores/src/epic/install/cache.rs | 238 ++++++++++++- .../rust/stores/src/epic/install/delta.rs | 34 +- .../main/rust/stores/src/epic/install/mod.rs | 317 +++++++++++++++++- .../rust/stores/src/epic/install/schedule.rs | 45 ++- .../rust/stores/src/epic/install/space.rs | 60 +++- .../rust/stores/src/epic/install/staging.rs | 19 +- .../rust/stores/src/epic/install/tests.rs | 141 +++++++- .../rust/stores/src/epic/install/writer.rs | 109 ++++-- app/src/main/rust/stores/src/epic/service.rs | 154 +++++++-- .../launcher/stores/itch/ItchStagingTest.kt | 158 ++++++++- .../maintenance/StoreMaintenanceTest.kt | 122 +++++++ .../xbox/XboxMaintenancePersistenceTest.kt | 28 ++ docs/development/store-maintenance.md | 34 ++ .../usr/local/bin/droiddeck-gog-maintenance | 33 ++ .../usr/local/bin/droiddeck-store-launch | 15 + tools/tests/test_gog_maintenance.py | 29 ++ 46 files changed, 3830 insertions(+), 251 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/amazon/AmazonMaintenance.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/battlenet/BattleNetMaintenance.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/epic/EpicMaintenance.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/itch/ItchMaintenance.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/maintenance/StoreMaintenance.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxMaintenance.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/StoreMaintenanceUi.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/stores/maintenance/StoreMaintenanceTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxMaintenancePersistenceTest.kt create mode 100644 docs/development/store-maintenance.md diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunch.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunch.kt index e17aac578..75c00b62c 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunch.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunch.kt @@ -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) diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt index ff44ca763..4509b4ccd 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt @@ -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 = 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 } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/amazon/AmazonBackend.kt b/app/src/main/java/com/droiddeck/launcher/stores/amazon/AmazonBackend.kt index 21f08e754..10a3a2c19 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/amazon/AmazonBackend.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/amazon/AmazonBackend.kt @@ -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() } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/amazon/AmazonInstaller.kt b/app/src/main/java/com/droiddeck/launcher/stores/amazon/AmazonInstaller.kt index 401f621ed..435b66cd4 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/amazon/AmazonInstaller.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/amazon/AmazonInstaller.kt @@ -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) diff --git a/app/src/main/java/com/droiddeck/launcher/stores/amazon/AmazonMaintenance.kt b/app/src/main/java/com/droiddeck/launcher/stores/amazon/AmazonMaintenance.kt new file mode 100644 index 000000000..3194a8b92 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/amazon/AmazonMaintenance.kt @@ -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 = 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) + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/battlenet/BattleNetInstall.kt b/app/src/main/java/com/droiddeck/launcher/stores/battlenet/BattleNetInstall.kt index 0fe38c49d..5decaec58 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/battlenet/BattleNetInstall.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/battlenet/BattleNetInstall.kt @@ -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) diff --git a/app/src/main/java/com/droiddeck/launcher/stores/battlenet/BattleNetMaintenance.kt b/app/src/main/java/com/droiddeck/launcher/stores/battlenet/BattleNetMaintenance.kt new file mode 100644 index 000000000..d17bdc824 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/battlenet/BattleNetMaintenance.kt @@ -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 = 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) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/battlenet/BattleNetStore.kt b/app/src/main/java/com/droiddeck/launcher/stores/battlenet/BattleNetStore.kt index 737ba3b4f..726233ebe 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/battlenet/BattleNetStore.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/battlenet/BattleNetStore.kt @@ -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) diff --git a/app/src/main/java/com/droiddeck/launcher/stores/download/SavedQueue.kt b/app/src/main/java/com/droiddeck/launcher/stores/download/SavedQueue.kt index 99d08d6b8..6274f2538 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/download/SavedQueue.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/download/SavedQueue.kt @@ -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) } } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicBackend.kt b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicBackend.kt index ce5afa91b..d278da0ab 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicBackend.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicBackend.kt @@ -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() } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicInstaller.kt b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicInstaller.kt index 3be966b96..a884471f1 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicInstaller.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicInstaller.kt @@ -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" diff --git a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicMaintenance.kt b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicMaintenance.kt new file mode 100644 index 000000000..7df885830 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicMaintenance.kt @@ -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 = 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): 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 = 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) + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchBackend.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchBackend.kt index 66ca72602..241e71b01 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchBackend.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchBackend.kt @@ -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)) } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchInstaller.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchInstaller.kt index f084e503d..d52e1e101 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchInstaller.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchInstaller.kt @@ -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) diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchMaintenance.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchMaintenance.kt new file mode 100644 index 000000000..ef0a2fca9 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchMaintenance.kt @@ -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() + private val exactCheckedAt = ConcurrentHashMap() + + override fun capabilities(game: InstalledStoreGame): Set { + 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) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchStaging.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchStaging.kt index 73e8f8596..f0ae90ca6 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchStaging.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchStaging.kt @@ -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 = "" + + fun inventory(root: File): Map = linkedMapOf().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 { + 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().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) { + 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, afterEntry: ((Int) -> Unit)? = null): Map { + 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().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 { it.count { ch -> ch == '/' } }.thenBy { it }) + + next.keys.sortedWith(compareBy { 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() } } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/maintenance/StoreMaintenance.kt b/app/src/main/java/com/droiddeck/launcher/stores/maintenance/StoreMaintenance.kt new file mode 100644 index 000000000..58f13ba37 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/maintenance/StoreMaintenance.kt @@ -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 + 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() + private val workers = Executors.newFixedThreadPool(2) + private val checkedAt = ConcurrentHashMap() + private val inFlight = ConcurrentHashMap() + private val revisions = ConcurrentHashMap() + private val accounts = ConcurrentHashMap() + val checks = mutableStateMapOf() + + 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) { + 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 = + 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 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? = 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 + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxBackend.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxBackend.kt index d678a2f41..e5fdf74fe 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxBackend.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxBackend.kt @@ -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 = 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) diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxInstaller.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxInstaller.kt index 3d02e4db5..6da990b1b 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxInstaller.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxInstaller.kt @@ -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, contentId: String, market: String): Map = + LinkedHashMap(before).apply { + remove(StoreMaintenance.OPERATION_KEY) + remove(StoreMaintenance.FOLDER_KEY) + put("contentId", contentId) + put("market", market) + } } } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxMaintenance.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxMaintenance.kt new file mode 100644 index 000000000..5b690db87 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxMaintenance.kt @@ -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 = + 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") + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/GameDetail.kt b/app/src/main/java/com/droiddeck/launcher/ui/GameDetail.kt index 03d63cf47..f6f0e0a0b 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/GameDetail.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/GameDetail.kt @@ -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() } } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoreMaintenanceUi.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoreMaintenanceUi.kt new file mode 100644 index 000000000..da561d4c8 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoreMaintenanceUi.kt @@ -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(null) } + var proposal by remember { mutableStateOf(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) diff --git a/app/src/main/rust/stores/Cargo.lock b/app/src/main/rust/stores/Cargo.lock index 2fac37c5a..2ab6a4d8c 100644 --- a/app/src/main/rust/stores/Cargo.lock +++ b/app/src/main/rust/stores/Cargo.lock @@ -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" diff --git a/app/src/main/rust/stores/Cargo.toml b/app/src/main/rust/stores/Cargo.toml index 61d121a22..1b992fd3d 100644 --- a/app/src/main/rust/stores/Cargo.toml +++ b/app/src/main/rust/stores/Cargo.toml @@ -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" diff --git a/app/src/main/rust/stores/src/amazon/install/check.rs b/app/src/main/rust/stores/src/amazon/install/check.rs index e53a082bf..74954b960 100644 --- a/app/src/main/rust/stores/src/amazon/install/check.rs +++ b/app/src/main/rust/stores/src/amazon/install/check.rs @@ -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, ()> { - 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, ( } 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, ()> { + 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) -> 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()); diff --git a/app/src/main/rust/stores/src/amazon/install/mod.rs b/app/src/main/rust/stores/src/amazon/install/mod.rs index f9e85122c..113814145 100644 --- a/app/src/main/rust/stores/src/amazon/install/mod.rs +++ b/app/src/main/rust/stores/src/amazon/install/mod.rs @@ -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, + pub target_version_id: Option, 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 { - 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 { + 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, 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>, 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 { + 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 { + let normalized = manifest::check_path(path).ok()?; + Some(normalized.to_lowercase()) + }; + let inventory = |files: &[GameFile]| -> Option> { + 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 { 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 { +pub fn run_download( + req: &Request, + download: &Download, + cancel: &AtomicBool, + ev: &dyn Events, +) -> Result { 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)] diff --git a/app/src/main/rust/stores/src/amazon/install/tests.rs b/app/src/main/rust/stores/src/amazon/install/tests.rs index 066718644..2e40ce79b 100644 --- a/app/src/main/rust/stores/src/amazon/install/tests.rs +++ b/app/src/main/rust/stores/src/amazon/install/tests.rs @@ -39,7 +39,11 @@ fn serve(files: HashMap>) -> 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>>, seen: &Mutex) { @@ -136,15 +140,26 @@ fn fixture_with(paths: &[(&'static str, Vec)]) -> 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); diff --git a/app/src/main/rust/stores/src/amazon/install/writer.rs b/app/src/main/rust/stores/src/amazon/install/writer.rs index 09aa5e0a6..88a5f85c4 100644 --- a/app/src/main/rust/stores/src/amazon/install/writer.rs +++ b/app/src/main/rust/stores/src/amazon/install/writer.rs @@ -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, 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 { +pub fn run( + jobs: &[Job], + settings: &Settings, + base: Baseline, + gate: &ReplaceGate, + stop: &AtomicBool, + events: &dyn Events, +) -> Result { 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 = 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(), + )) } diff --git a/app/src/main/rust/stores/src/amazon/manifest.rs b/app/src/main/rust/stores/src/amazon/manifest.rs index 8bffbbead..fb9441337 100644 --- a/app/src/main/rust/stores/src/amazon/manifest.rs +++ b/app/src/main/rust/stores/src/amazon/manifest.rs @@ -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 { _ => {} } } + 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, 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 { @@ -138,7 +160,9 @@ pub fn parse_message(data: &[u8]) -> Result { (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 = 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()); diff --git a/app/src/main/rust/stores/src/amazon/service.rs b/app/src/main/rust/stores/src/amazon/service.rs index b009e0aec..2d4521e9e 100644 --- a/app/src/main/rust/stores/src/amazon/service.rs +++ b/app/src/main/rust/stores/src/amazon/service.rs @@ -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::>() }) } - 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::>() }) } _ => error(format!("unknown operation {op}")), @@ -105,7 +113,11 @@ pub fn fetch(op: &str, request: &str) -> Option> { 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); + } } diff --git a/app/src/main/rust/stores/src/epic/install/cache.rs b/app/src/main/rust/stores/src/epic/install/cache.rs index bcd8c7ccc..4ca950d38 100644 --- a/app/src/main/rust/stores/src/epic/install/cache.rs +++ b/app/src/main/rust/stores/src/epic/install/cache.rs @@ -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, + covered: Vec, + valid: bool, +} + +pub fn seed_from_verified_installed( + dir: &Path, + game_dir: &Path, + installed: &Manifest, + target: &Manifest, + needed: &HashSet, + cached: &[u32], + budget: u64, + cancel: &AtomicBool, +) -> (Vec, u64) { + const MAX_WINDOW: u32 = 64 * 1024 * 1024; + const MAX_REBUILD_MEMORY: u64 = 32 * 1024 * 1024; + let cached: HashSet = cached.iter().copied().collect(); + let mut rebuilt: HashMap = 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); + } } diff --git a/app/src/main/rust/stores/src/epic/install/delta.rs b/app/src/main/rust/stores/src/epic/install/delta.rs index d860e72fc..9f88581bb 100644 --- a/app/src/main/rust/stores/src/epic/install/delta.rs +++ b/app/src/main/rust/stores/src/epic/install/delta.rs @@ -14,7 +14,9 @@ pub enum Check { } fn hash_file(path: &Path, cancel: &AtomicBool) -> Option> { - 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> { } pub fn check(dest: &Path, size: u64, sha1: Option, 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); diff --git a/app/src/main/rust/stores/src/epic/install/mod.rs b/app/src/main/rust/stores/src/epic/install/mod.rs index df20412ac..56633d488 100644 --- a/app/src/main/rust/stores/src/epic/install/mod.rs +++ b/app/src/main/rust/stores/src/epic/install/mod.rs @@ -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, pub scratch_dir: Option, pub storage_label: String, pub tags: Vec, + pub previous_tags: Vec, 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 = 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 PathBuf { + game_dir.join(".droiddeck-maintenance/epic/installed.manifest") +} + +fn read_installed_manifest(game_dir: &Path, log: &dyn Fn(&str)) -> Option { + 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>, 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 { + if !safe_relative(path) { + return None; + } + Some(path.replace('\\', "/").to_lowercase()) + }; + let inventory = |files: &[FileEntry], indices: &[usize]| -> Option> { + 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> { 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 = list.lines().map(|l| l.parse().ok()).collect::>()?; - let fits = |o: &u64| parts.iter().any(|p| p.file_offset == *o && p.file_offset + p.size as u64 <= meta.len()); + let kept: HashSet = list + .lines() + .map(|l| l.parse().ok()) + .collect::>()?; + 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 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 Result Result 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 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 = 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 = 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 = 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 = 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) } diff --git a/app/src/main/rust/stores/src/epic/install/schedule.rs b/app/src/main/rust/stores/src/epic/install/schedule.rs index 005a31dbd..5bc7dc381 100644 --- a/app/src/main/rust/stores/src/epic/install/schedule.rs +++ b/app/src/main/rust/stores/src/epic/install/schedule.rs @@ -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 = s.producers.iter().filter(|p| p.preloaded).map(|p| p.chunk).collect(); assert_eq!(preloaded, vec![0, 1]); diff --git a/app/src/main/rust/stores/src/epic/install/space.rs b/app/src/main/rust/stores/src/epic/install/space.rs index 2302f3551..76d25ed3a 100644 --- a/app/src/main/rust/stores/src/epic/install/space.rs +++ b/app/src/main/rust/stores/src/epic/install/space.rs @@ -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, label: &str) -> Result { +pub fn plan( + pending: &[PendingSize], + free_game: i64, + scratch: Option, + label: &str, +) -> Result { 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, 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, 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 { - 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); diff --git a/app/src/main/rust/stores/src/epic/install/staging.rs b/app/src/main/rust/stores/src/epic/install/staging.rs index 53be6cceb..9ed005f4e 100644 --- a/app/src/main/rust/stores/src/epic/install/staging.rs +++ b/app/src/main/rust/stores/src/epic/install/staging.rs @@ -44,7 +44,9 @@ pub fn remove_partial(partial: &Path) { } pub fn partials(dir: &Path) -> HashMap { - 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 { 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 { } pub fn remove_if_idle(dir: &Path) { - let Ok(entries) = fs::read_dir(dir) else { return }; - let names: Vec = 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 = 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) { diff --git a/app/src/main/rust/stores/src/epic/install/tests.rs b/app/src/main/rust/stores/src/epic/install/tests.rs index 127d772de..1f7987e95 100644 --- a/app/src/main/rust/stores/src/epic/install/tests.rs +++ b/app/src/main/rust/stores/src/epic/install/tests.rs @@ -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(); diff --git a/app/src/main/rust/stores/src/epic/install/writer.rs b/app/src/main/rust/stores/src/epic/install/writer.rs index 66cab176b..82943b389 100644 --- a/app/src/main/rust/stores/src/epic/install/writer.rs +++ b/app/src/main/rust/stores/src/epic/install/writer.rs @@ -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 = { 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 { @@ -624,8 +669,18 @@ fn hash_whole(handle: &File, file: &EngineFile) -> Result { } fn keep_partial(file: &EngineFile, written: &[bool], stage: &Path, dir: &Path) -> bool { - let mut kept: Vec = 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 = 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)); diff --git a/app/src/main/rust/stores/src/epic/service.rs b/app/src/main/rust/stores/src/epic/service.rs index 2264f6c77..b6cfcecc3 100644 --- a/app/src/main/rust/stores/src/epic/service.rs +++ b/app/src/main/rust/stores/src/epic/service.rs @@ -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) -> 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::>() }), - 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 = 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> = 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> = 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> = 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); + } } diff --git a/app/src/test/java/com/droiddeck/launcher/stores/itch/ItchStagingTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/itch/ItchStagingTest.kt index 226ef3b9b..09a26405b 100644 --- a/app/src/test/java/com/droiddeck/launcher/stores/itch/ItchStagingTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/stores/itch/ItchStagingTest.kt @@ -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("", 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()) + } } diff --git a/app/src/test/java/com/droiddeck/launcher/stores/maintenance/StoreMaintenanceTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/maintenance/StoreMaintenanceTest.kt new file mode 100644 index 000000000..e026f8e4d --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/maintenance/StoreMaintenanceTest.kt @@ -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) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxMaintenancePersistenceTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxMaintenancePersistenceTest.kt new file mode 100644 index 000000000..ba443e68d --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxMaintenancePersistenceTest.kt @@ -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)) + } +} diff --git a/docs/development/store-maintenance.md b/docs/development/store-maintenance.md new file mode 100644 index 000000000..24121b403 --- /dev/null +++ b/docs/development/store-maintenance.md @@ -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. diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-gog-maintenance b/tools/linuxfs/overlay/usr/local/bin/droiddeck-gog-maintenance index 4a56fd122..179428533 100644 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-gog-maintenance +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-gog-maintenance @@ -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": diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-store-launch b/tools/linuxfs/overlay/usr/local/bin/droiddeck-store-launch index 682cfc0ed..4ac835815 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-store-launch +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-store-launch @@ -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", "") diff --git a/tools/tests/test_gog_maintenance.py b/tools/tests/test_gog_maintenance.py index 0465e91b4..eca147578 100644 --- a/tools/tests/test_gog_maintenance.py +++ b/tools/tests/test_gog_maintenance.py @@ -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")]: