mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-09 10:09:45 -10:00
Compare commits
32
Commits
main
...
feat/ea-store
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f20fea179e | ||
|
|
d4feae4b88 | ||
|
|
065d904fa9 | ||
|
|
b2f2978b71 | ||
|
|
d27144c13d | ||
|
|
c3ddc3f69c | ||
|
|
7ecfbf7478 | ||
|
|
8bdff5763b | ||
|
|
ab0b1ad371 | ||
|
|
68d9fbcefb | ||
|
|
7ed7a46b8f | ||
|
|
eac510d1d7 | ||
|
|
bcf33685ec | ||
|
|
c3bcfc8593 | ||
|
|
c27c6a336a | ||
|
|
db81d233b0 | ||
|
|
e7d11a5feb | ||
|
|
e7eed182b4 | ||
|
|
4f6a1fcd17 | ||
|
|
a68429fcc4 | ||
|
|
4605b4a756 | ||
|
|
942dc1d587 | ||
|
|
56911e9f25 | ||
|
|
a4aa19d2ac | ||
|
|
94a7330491 | ||
|
|
35b77fdbf5 | ||
|
|
fc0e0b04cf | ||
|
|
afb23680f7 | ||
|
|
95511dc5c0 | ||
|
|
228a1311ae | ||
|
|
b67bdcd0b3 | ||
|
|
df265ea3a1 |
No files matched your search
@@ -10,3 +10,5 @@ tools/mangoapp/mangoapp text eol=lf
|
||||
app/src/main/assets/desktop-removal/*.json text eol=lf
|
||||
app/src/main/assets/desktop-removal/*.txt text eol=lf
|
||||
*.pdf binary
|
||||
|
||||
app/src/main/resources/ea/graphql/*.gql -text
|
||||
@@ -93,6 +93,7 @@ DroidDeck is [GPL-3.0](LICENSE). Steam and Proton belong to Valve Corporation. V
|
||||
- Epic Games and GOG build on [Phobos](https://github.com/phobos665)'s adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl). GOG install script registry entries are read the way [Heroic Games Launcher](https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher) (GPL-3.0) reads them.
|
||||
- Epic's official Linux launcher preview runs through FEX. That setup is outside Epic's listed Linux support.
|
||||
- Battle.net is by [@kayakyakr](https://github.com/kayakyakr). It runs Blizzard's own client in Proton (downloaded from Blizzard, not included) and shows key art from the [Battle.net Shop](https://shop.battle.net). Without Steam, its games see the pad as an Xbox 360 controller via `PROTON_SPOOF_STEAMINPUT_VIDPID` in [Proton](https://github.com/ValveSoftware/wine)'s `dlls/winebus.sys`.
|
||||
- The EA store follows the EA app's protocols as documented by [ArmchairDevelopers](https://github.com/ArmchairDevelopers)' [Maxima](https://github.com/ArmchairDevelopers/Maxima) (GPL-3.0): sign-in, the GraphQL queries (included verbatim, since EA matches them by hash), build archives, online activation licences and the LSX game endpoint. The code is DroidDeck's own.
|
||||
- Battle.net installs drive Blizzard's Agent the way [@barncastle](https://github.com/barncastle)'s [Battle.Net-Installer](https://github.com/barncastle/Battle.Net-Installer) does, with requests from [wowdev.wiki](https://wowdev.wiki/Agent). Read without taking code: [@mubaraknumann](https://github.com/mubaraknumann)'s [unifideck](https://github.com/mubaraknumann/unifideck), [@djmittens](https://github.com/djmittens)'s [neo-mittens](https://github.com/djmittens/neo-mittens) `bnetswitch`, [@BCD1210](https://github.com/BCD1210)'s [soju](https://github.com/BCD1210/soju) and [Lutris](https://github.com/lutris/lutris)'s `product.db` reader.
|
||||
- World of Warcraft addon imports replace an addon's folder from a temporary copy, as [WowUp](https://github.com/WowUp/WowUp) does, and read flavour folders the way [WoWDeck](https://github.com/sigboe/wowdeck) does. No code from either is used.
|
||||
- The World of Warcraft addon Download window searches [WoWInterface](https://www.wowinterface.com) through its MMOUI API. [CurseForge](https://www.curseforge.com/wow) opens in the user's browser, with no API and no key.
|
||||
|
||||
@@ -61,6 +61,8 @@ class App : Application() {
|
||||
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.xbox.XboxBackend)
|
||||
com.droiddeck.launcher.sync.Syncthing.init(this)
|
||||
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.battlenet.BattleNetBackend)
|
||||
com.droiddeck.launcher.stores.ea.EaBackend.app = this
|
||||
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.ea.EaBackend)
|
||||
}
|
||||
|
||||
private fun prefetchAddonPacks() {
|
||||
|
||||
@@ -87,7 +87,7 @@ object CloudSaves {
|
||||
val item = sidecar.extra["catalogItemId"].orEmpty()
|
||||
if (ns.isEmpty() || item.isEmpty()) "" else EpicApiClient.getCustomAttribute(token, ns, item, "CloudSaveFolder")
|
||||
}
|
||||
Store.AMAZON, Store.ITCH, Store.GAMEJOLT, Store.XBOX, Store.BATTLENET -> ""
|
||||
Store.AMAZON, Store.ITCH, Store.GAMEJOLT, Store.XBOX, Store.BATTLENET, Store.EA -> ""
|
||||
} ?: return null
|
||||
p.edit()
|
||||
.putString("cloud_template_$id", fetched.ifEmpty { TEMPLATE_NONE })
|
||||
@@ -148,7 +148,7 @@ object CloudSaves {
|
||||
val dir = when (store) {
|
||||
Store.GOG -> CloudSavePaths.gog(template, pfx, folder)
|
||||
Store.EPIC -> CloudSavePaths.epic(template, pfx, folder, EpicCredentialStore.load(context)?.accountId.orEmpty(), id)
|
||||
Store.AMAZON, Store.ITCH, Store.GAMEJOLT, Store.XBOX, Store.BATTLENET -> null
|
||||
Store.AMAZON, Store.ITCH, Store.GAMEJOLT, Store.XBOX, Store.BATTLENET, Store.EA -> null
|
||||
}
|
||||
return dir to (if (dir == null) "unresolved" else "ok")
|
||||
}
|
||||
@@ -173,7 +173,7 @@ object CloudSaves {
|
||||
val account = EpicCredentialStore.load(context)?.accountId.orEmpty()
|
||||
if (token == null || account.isEmpty()) null else EpicCloud(account, id, token)
|
||||
}
|
||||
Store.AMAZON, Store.ITCH, Store.GAMEJOLT, Store.XBOX, Store.BATTLENET -> null
|
||||
Store.AMAZON, Store.ITCH, Store.GAMEJOLT, Store.XBOX, Store.BATTLENET, Store.EA -> null
|
||||
}
|
||||
|
||||
private fun gogScopedToken(context: Context, id: String, clientId: String): String? {
|
||||
|
||||
@@ -13,11 +13,13 @@ internal object LoginFlows {
|
||||
Store.EPIC -> epicFlow()
|
||||
Store.AMAZON -> amazonFlow()
|
||||
Store.GAMEJOLT -> gameJoltFlow(browserUserAgent)
|
||||
Store.EA -> eaFlow(browserUserAgent)
|
||||
Store.ITCH, Store.XBOX, Store.BATTLENET -> null
|
||||
}
|
||||
|
||||
@Volatile var epicFlow: () -> StoreLoginActivity.Flow? = { null }
|
||||
@Volatile var amazonFlow: () -> StoreLoginActivity.Flow? = { null }
|
||||
@Volatile var eaFlow: (String) -> StoreLoginActivity.Flow? = { null }
|
||||
@Volatile var gameJoltFlow: (String) -> StoreLoginActivity.Flow? = { null }
|
||||
|
||||
private class GogFlow(private val state: String, override val userAgent: String) : StoreLoginActivity.Flow {
|
||||
|
||||
@@ -7,7 +7,8 @@ enum class Store(val id: String, val label: String, val shortLabel: String) {
|
||||
XBOX("xbox", "Xbox", "Xbox"),
|
||||
ITCH("itch", "Itch.io", "Itch.io"),
|
||||
GAMEJOLT("gamejolt", "Game Jolt", "Game Jolt"),
|
||||
BATTLENET("battlenet", "Battle.net", "Battle.net");
|
||||
BATTLENET("battlenet", "Battle.net", "Battle.net"),
|
||||
EA("ea", "EA", "EA");
|
||||
|
||||
val folder: String get() = if (this == ITCH) "itch" else shortLabel
|
||||
|
||||
|
||||
@@ -11,9 +11,12 @@ object StoreLaunch {
|
||||
const val LAUNCHER = ".droiddeck-launch.bat"
|
||||
const val EPIC_CODE = ".droiddeck-epic-code"
|
||||
const val EPIC_ARGS = ".droiddeck-epic-args"
|
||||
const val EA_ENV = ".droiddeck-ea-env"
|
||||
/** A sleeping stand-in that the launch hook installs; older EA SDKs require a running Origin process. */
|
||||
const val EA_ORIGIN = "C:\\Program Files (x86)\\Origin\\Origin.exe"
|
||||
|
||||
fun writeLauncher(folder: File, sidecar: StoreGameSidecar): StoreGameSidecar {
|
||||
val needs = sidecar.args.isNotEmpty() || sidecar.env.isNotEmpty() || sidecar.store == Store.EPIC
|
||||
val needs = sidecar.args.isNotEmpty() || sidecar.env.isNotEmpty() || sidecar.store == Store.EPIC || sidecar.store == Store.EA
|
||||
val file = File(folder, LAUNCHER)
|
||||
if (!needs) { file.delete(); return sidecar.copy(launcher = null) }
|
||||
file.writeText(launcherText(sidecar))
|
||||
@@ -49,6 +52,14 @@ object StoreLaunch {
|
||||
lines += "del \"%~dp0$EPIC_ARGS\""
|
||||
lines += "if exist \"%~dp0$EPIC_CODE\" del \"%~dp0$EPIC_CODE\""
|
||||
lines += "set \"$CLIENT_VAR=\" & \"%~dp0$exe\" %$CLIENT_VAR%"
|
||||
} else if (sidecar.store == Store.EA) {
|
||||
lines += "if not exist \"%~dp0$EA_ENV\" goto run"
|
||||
lines += "for /f \"usebackq tokens=1,* delims==\" %%a in (\"%~dp0$EA_ENV\") do set \"%%a=%%b\""
|
||||
lines += "del \"%~dp0$EA_ENV\""
|
||||
lines += ":run"
|
||||
lines += "if exist \"$EA_ORIGIN\" start \"\" \"$EA_ORIGIN\""
|
||||
lines += run
|
||||
lines += "taskkill /f /im Origin.exe >nul 2>&1"
|
||||
} else {
|
||||
lines += run
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ object StoreLaunchRequests {
|
||||
|
||||
fun stop() {
|
||||
com.droiddeck.launcher.stores.xbox.XboxRuntime.stop()
|
||||
com.droiddeck.launcher.stores.ea.EaLsx.stop()
|
||||
synchronized(lock) {
|
||||
observer?.stopWatching()
|
||||
observer = null
|
||||
@@ -58,6 +59,12 @@ object StoreLaunchRequests {
|
||||
"xbox-renew" -> JSONObject().put("ok", com.droiddeck.launcher.stores.xbox.XboxRuntime.renew(app, request.optString("id"), request.optString("lease")))
|
||||
"xbox-prepare" -> runCatching { com.droiddeck.launcher.stores.xbox.XboxRuntime.prepare(app, request.getString("id")) }
|
||||
.getOrElse { JSONObject().put("ok", false).put("reason", "Xbox account service is unavailable. Sign in again and retry.") }
|
||||
"ea-prepare" -> runCatching { lastLaunched = Store.EA to request.optString("id"); com.droiddeck.launcher.stores.ea.EaLaunch.prepare(app, request) }
|
||||
.getOrElse { JSONObject().put("ok", false).put("reason", "error-" + it.javaClass.simpleName) }
|
||||
"ea-exited" -> {
|
||||
com.droiddeck.launcher.stores.ea.EaLsx.stop(request.optString("id"))
|
||||
JSONObject().put("ok", true)
|
||||
}
|
||||
"gog-achievements" -> runCatching { com.droiddeck.launcher.stores.gog.GogComet.prepare(app, request.optString("id")) }
|
||||
.getOrElse { JSONObject().put("ok", false).put("ready", false).put("reason", "service-unavailable") }
|
||||
"epic-code" -> {
|
||||
|
||||
@@ -55,6 +55,7 @@ object StoreSizes {
|
||||
Store.EPIC -> EpicPrefs.get(app).edit().putLong("size_${item.id}", it).apply()
|
||||
Store.AMAZON -> AmazonPrefs.get(app).edit().putLong("size_${item.id}", it).apply()
|
||||
Store.ITCH, Store.GAMEJOLT -> {}
|
||||
Store.EA -> com.droiddeck.launcher.stores.ea.EaPrefs.get(app).edit().putLong("size_${item.id}", it).apply()
|
||||
Store.GOG, Store.XBOX, Store.BATTLENET -> {}
|
||||
}
|
||||
}
|
||||
@@ -111,6 +112,7 @@ object StoreSizes {
|
||||
val bytes = AmazonApiClient.getBytes(AmazonApiClient.appendPath(spec.downloadUrl, "manifest.proto"), token) ?: return 0L
|
||||
AmazonManifest.parse(bytes).totalInstallSize
|
||||
}
|
||||
Store.EA -> com.droiddeck.launcher.stores.ea.EaSizes.size(context, item)
|
||||
Store.GOG, Store.XBOX, Store.BATTLENET -> 0L
|
||||
Store.ITCH -> com.droiddeck.launcher.stores.itch.ItchLibrary.size(context, item)
|
||||
Store.GAMEJOLT -> com.droiddeck.launcher.stores.gamejolt.GameJoltBuilds.size(context, item)
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
package com.droiddeck.launcher.stores.ea
|
||||
|
||||
import android.net.Uri
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
class EaApiException(val status: Int, message: String) : Exception(message)
|
||||
|
||||
object EaApi {
|
||||
private const val GRAPHQL = "https://service-aggregation-layer.juno.ea.com/graphql"
|
||||
private const val LOCALE = "DEFAULT"
|
||||
|
||||
class Player(val userId: String, val personaId: String, val displayName: String, val eaId: String)
|
||||
|
||||
class OwnedProduct(
|
||||
val offerId: String,
|
||||
val productId: String,
|
||||
val name: String,
|
||||
val slug: String,
|
||||
val baseSlug: String,
|
||||
val gameType: String,
|
||||
val downloadable: Boolean,
|
||||
val trial: Boolean,
|
||||
val ownership: List<String>,
|
||||
)
|
||||
|
||||
class Offer(
|
||||
val offerId: String,
|
||||
val contentId: String,
|
||||
val displayName: String,
|
||||
val displayType: String,
|
||||
val downloadable: Boolean,
|
||||
val executePathOverride: String,
|
||||
val installCheckOverride: String,
|
||||
val executeParameters: String,
|
||||
val multiplayerId: String,
|
||||
val masterTitleId: String,
|
||||
val gameVersion: String,
|
||||
val cloudSaves: String,
|
||||
val locales: List<String>,
|
||||
)
|
||||
|
||||
class Images(val tall: String?, val wide: String?, val logo: String?)
|
||||
|
||||
class Build(val buildId: String, val gameVersion: String, val liveDate: String)
|
||||
|
||||
private val queries = HashMap<String, String>()
|
||||
|
||||
internal fun query(name: String): String = synchronized(queries) {
|
||||
queries.getOrPut(name) {
|
||||
val stream = EaApi::class.java.getResourceAsStream("/ea/graphql/$name.gql") ?: throw IllegalStateException("missing query $name")
|
||||
stream.use { it.readBytes().toString(Charsets.UTF_8) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun persisted(name: String): String =
|
||||
JSONObject().put("persistedQuery", JSONObject().put("version", 1).put("sha256Hash", EaCrypto.hex(EaCrypto.sha256(query(name).toByteArray())))).toString()
|
||||
|
||||
internal fun call(token: String, name: String, variables: JSONObject): JSONObject {
|
||||
val headers = mapOf("Authorization" to "Bearer $token", "Accept" to "application/json")
|
||||
val get = Uri.parse(GRAPHQL).buildUpon()
|
||||
.appendQueryParameter("extensions", persisted(name))
|
||||
.appendQueryParameter("operationName", name)
|
||||
.appendQueryParameter("variables", variables.toString())
|
||||
.build().toString()
|
||||
var reply = EaHttp.request(get, headers = headers)
|
||||
var json = runCatching { JSONObject(reply.text) }.getOrNull()
|
||||
if (!reply.ok || json == null || json.has("errors")) {
|
||||
val body = JSONObject()
|
||||
.put("extensions", JSONObject(persisted(name)))
|
||||
.put("variables", variables)
|
||||
.put("operationName", name)
|
||||
.put("query", query(name))
|
||||
reply = EaHttp.request(GRAPHQL, "POST", headers + ("Content-Type" to "application/json"), body.toString().toByteArray())
|
||||
json = runCatching { JSONObject(reply.text) }.getOrNull()
|
||||
}
|
||||
if (reply.status == 401) throw EaApiException(401, "EA refused the session")
|
||||
if (json == null) throw EaApiException(reply.status, "EA gave no answer to $name (${reply.status})")
|
||||
json.optJSONArray("errors")?.optJSONObject(0)?.let { throw EaApiException(reply.status, "$name: ${it.optString("message")}") }
|
||||
return json.optJSONObject("data") ?: throw EaApiException(reply.status, "$name returned no data")
|
||||
}
|
||||
|
||||
fun player(token: String): Player {
|
||||
val me = call(token, "GetUserPlayer", JSONObject()).optJSONObject("me") ?: throw EaApiException(0, "no player")
|
||||
val p = me.optJSONObject("player")
|
||||
return Player(
|
||||
userId = me.optString("pd"),
|
||||
personaId = p?.optString("psd").orEmpty(),
|
||||
displayName = p?.optString("displayName").orEmpty(),
|
||||
eaId = p?.optString("uniqueName").orEmpty().ifEmpty { p?.optString("displayName").orEmpty() },
|
||||
)
|
||||
}
|
||||
|
||||
internal fun parseOwned(page: JSONObject): Pair<List<OwnedProduct>, String?> {
|
||||
val owned = page.optJSONObject("me")?.optJSONObject("ownedGameProducts") ?: return emptyList<OwnedProduct>() to null
|
||||
val items = owned.optJSONArray("items") ?: JSONArray()
|
||||
val list = (0 until items.length()).mapNotNull { items.optJSONObject(it) }.mapNotNull { o ->
|
||||
val product = o.optJSONObject("product") ?: return@mapNotNull null
|
||||
val base = product.optJSONObject("baseItem")
|
||||
val user = product.optJSONObject("gameProductUser")
|
||||
val methods = user?.optJSONArray("ownershipMethods")
|
||||
OwnedProduct(
|
||||
offerId = o.optString("originOfferId").ifEmpty { return@mapNotNull null },
|
||||
productId = product.optString("id"),
|
||||
name = (base?.optString("title")?.takeIf { it.isNotBlank() } ?: product.optString("name")).replace("\n", " ").trim(),
|
||||
slug = product.optString("gameSlug"),
|
||||
baseSlug = base?.optString("baseGameSlug").orEmpty().takeIf { it != "null" }.orEmpty(),
|
||||
gameType = base?.optString("gameType").orEmpty(),
|
||||
downloadable = product.optBoolean("downloadable", true),
|
||||
trial = product.optJSONObject("trialDetails")?.optString("trialType").orEmpty().let { it.isNotEmpty() && it != "null" },
|
||||
ownership = (0 until (methods?.length() ?: 0)).map { methods!!.optString(it) },
|
||||
)
|
||||
}
|
||||
val next = owned.optString("next").takeIf { it.isNotBlank() && it != "null" }
|
||||
return list to next
|
||||
}
|
||||
|
||||
fun owned(token: String): List<OwnedProduct> {
|
||||
val out = ArrayList<OwnedProduct>()
|
||||
var next: String? = "0"
|
||||
var guard = 0
|
||||
while (next != null && guard++ < 50) {
|
||||
val variables = JSONObject()
|
||||
.put("isMac", false)
|
||||
.put("limit", 100)
|
||||
.put("next", next)
|
||||
.put("type", JSONArray().put("DIGITAL_FULL_GAME").put("PACKAGED_FULL_GAME"))
|
||||
.put("storefronts", JSONArray().put("EA"))
|
||||
.put("platforms", JSONArray().put("PC"))
|
||||
val (items, after) = parseOwned(call(token, "getPreloadedOwnedGames", variables))
|
||||
out += items
|
||||
next = after?.takeIf { it != next && items.isNotEmpty() }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
internal fun parseOffers(data: JSONObject): List<Offer> {
|
||||
val arr = data.optJSONArray("legacyOffers") ?: return emptyList()
|
||||
return (0 until arr.length()).mapNotNull { arr.optJSONObject(it) }.mapNotNull { o ->
|
||||
val downloads = o.optJSONArray("downloads")
|
||||
val version = (0 until (downloads?.length() ?: 0)).mapNotNull { downloads!!.optJSONObject(it) }
|
||||
.firstOrNull { it.optString("downloadType").equals("LIVE", true) }?.optString("gameVersion")
|
||||
?: downloads?.optJSONObject(0)?.optString("gameVersion")
|
||||
val locales = o.optJSONArray("softwareLocales")
|
||||
fun s(k: String) = o.optString(k).takeIf { it != "null" }.orEmpty()
|
||||
Offer(
|
||||
offerId = s("offerId").ifEmpty { return@mapNotNull null },
|
||||
contentId = s("contentId"),
|
||||
displayName = s("displayName"),
|
||||
displayType = s("displayType"),
|
||||
downloadable = o.optBoolean("isDownloadable", true),
|
||||
executePathOverride = s("executePathOverride"),
|
||||
installCheckOverride = s("installCheckOverride"),
|
||||
executeParameters = s("executeParameters"),
|
||||
multiplayerId = s("multiplayerId"),
|
||||
masterTitleId = s("primaryMasterTitleId"),
|
||||
gameVersion = version.orEmpty().takeIf { it != "null" }.orEmpty(),
|
||||
cloudSaves = s("cloudSaveConfigurationOverride"),
|
||||
locales = (0 until (locales?.length() ?: 0)).map { locales!!.optString(it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun offers(token: String, ids: List<String>): List<Offer> = ids.distinct().chunked(50).flatMap { chunk ->
|
||||
parseOffers(call(token, "getLegacyCatalogDefs", JSONObject().put("offerIds", JSONArray(chunk))))
|
||||
}
|
||||
|
||||
private fun path(o: JSONObject?, vararg keys: String): String? {
|
||||
var cur = o ?: return null
|
||||
for (k in keys.dropLast(1)) cur = cur.optJSONObject(k) ?: return null
|
||||
return cur.optString(keys.last()).takeIf { it.startsWith("http") }
|
||||
}
|
||||
|
||||
internal fun parseImages(data: JSONObject): Images {
|
||||
val game = data.optJSONObject("game")
|
||||
val key = game?.optJSONObject("keyArt")
|
||||
val pack = game?.optJSONObject("packArt")
|
||||
return Images(
|
||||
tall = path(pack, "aspect9x16Image", "path") ?: path(key, "aspect9x16Image", "path"),
|
||||
wide = path(key, "aspect16x9Image", "path") ?: path(key, "largestImage", "path") ?: path(pack, "aspect2x1Image", "path"),
|
||||
logo = path(game, "primaryLogo", "largestImage", "path") ?: path(game, "primaryLogo", "path"),
|
||||
)
|
||||
}
|
||||
|
||||
fun images(token: String, slug: String): Images =
|
||||
parseImages(call(token, "GameImages", JSONObject().put("gameSlug", slug).put("locale", LOCALE)))
|
||||
|
||||
fun downloadUrl(token: String, offerId: String, buildId: String? = null): String {
|
||||
val data = call(token, "downloadUrl", JSONObject().put("offerId", offerId).put("buildId", buildId.orEmpty()))
|
||||
return data.optJSONObject("downloadUrl")?.optString("url").orEmpty().ifEmpty { throw EaApiException(0, "EA gave no download link") }
|
||||
}
|
||||
|
||||
fun builds(token: String, offerId: String): List<Build> {
|
||||
val arr = call(token, "availableBuilds", JSONObject().put("offerId", offerId)).optJSONArray("availableBuilds") ?: return emptyList()
|
||||
return (0 until arr.length()).mapNotNull { arr.optJSONObject(it) }.map {
|
||||
Build(it.optString("buildId"), it.optString("gameVersion").takeIf { v -> v != "null" }.orEmpty(), it.optString("buildLiveDate"))
|
||||
}
|
||||
}
|
||||
|
||||
fun legacyEntitlements(token: String): List<JSONObject> {
|
||||
val out = ArrayList<JSONObject>()
|
||||
var next: String? = "0"
|
||||
var guard = 0
|
||||
while (next != null && guard++ < 50) {
|
||||
val variables = JSONObject().put("limit", 100).put("next", next).put("storeFronts", JSONArray().put("EA"))
|
||||
val page = call(token, "getLegacyEntitlements", variables).optJSONObject("me")?.optJSONObject("legacyEntitlements") ?: break
|
||||
val items = page.optJSONArray("items") ?: JSONArray()
|
||||
for (i in 0 until items.length()) items.optJSONObject(i)?.let(out::add)
|
||||
next = page.optString("next").takeIf { it.isNotBlank() && it != "null" && it != next && items.length() > 0 }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
fun entitlements(token: String, group: String): JSONArray {
|
||||
val variables = JSONObject()
|
||||
.put("productIds", JSONArray())
|
||||
.put("includeChildGroups", false)
|
||||
.put("groupNames", JSONArray().put(group))
|
||||
.put("entitlementTag", "")
|
||||
.put("pageSize", 100)
|
||||
.put("pageNumber", 1)
|
||||
return call(token, "sdkEntitlements", variables).optJSONObject("me")?.optJSONObject("sdkEntitlements")?.optJSONArray("entitlements") ?: JSONArray()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package com.droiddeck.launcher.stores.ea
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreAccounts
|
||||
import org.json.JSONObject
|
||||
import java.security.SecureRandom
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
object EaAuth {
|
||||
private const val TAG = "EaAuth"
|
||||
const val CLIENT_ID = "JUNO_PC_CLIENT"
|
||||
private const val CLIENT_SECRET = "4mRLtYMb6vq9qglomWEaT4ChxsXWcyqbQpuBNfMPOYOiDmYYQmjuaBsF2Zp0RyVeWkfqhE9TuGgAw7te"
|
||||
const val AUTH_URL = "https://accounts.ea.com/connect/auth"
|
||||
private const val TOKEN_URL = "https://accounts.ea.com/connect/token"
|
||||
const val REDIRECT = "qrc:///html/login_successful.html"
|
||||
private const val REFRESH_MARGIN_MS = 5 * 60 * 1000L
|
||||
private val refreshLock = Any()
|
||||
private val random = SecureRandom()
|
||||
|
||||
private val PC_SIGN_KEYS = mapOf(
|
||||
"v1" to "ISa3dpGOc8wW7Adn4auACSQmaccrOyR2".toByteArray(),
|
||||
"v2" to "nt5FfJbdPzNcl2pkC3zgjO43Knvscxft".toByteArray(),
|
||||
)
|
||||
|
||||
val OPAQUE_SCOPES = listOf(
|
||||
"basic.commerce.cartv2", "service.atom", "dp.client.default", "signin", "social_recommendation_user",
|
||||
"basic.optin.write", "basic.commerce.cartv2.write", "basic.billing", "external.social_information_ups_admin",
|
||||
)
|
||||
|
||||
class Account(
|
||||
val accessToken: String,
|
||||
val refreshToken: String,
|
||||
val expiresAt: Long,
|
||||
val userId: String,
|
||||
val personaId: String,
|
||||
val displayName: String,
|
||||
val eaId: String,
|
||||
)
|
||||
|
||||
class Pending(val url: String, val verifier: String)
|
||||
|
||||
internal fun fromJson(o: JSONObject): Account? {
|
||||
val access = o.optString("access_token").ifEmpty { return null }
|
||||
return Account(
|
||||
accessToken = access,
|
||||
refreshToken = o.optString("refresh_token"),
|
||||
expiresAt = o.optLong("expires_at"),
|
||||
userId = o.optString("user_id"),
|
||||
personaId = o.optString("persona_id"),
|
||||
displayName = o.optString("display_name"),
|
||||
eaId = o.optString("ea_id"),
|
||||
)
|
||||
}
|
||||
|
||||
fun load(context: Context): Account? = StoreAccounts.read(context.applicationContext, Store.EA)?.let(::fromJson)
|
||||
|
||||
private fun deviceId(context: Context): String {
|
||||
val prefs = EaPrefs.get(context)
|
||||
prefs.getString("device", null)?.let { return it }
|
||||
val made = EaCrypto.hex(ByteArray(16).also(random::nextBytes))
|
||||
prefs.edit().putString("device", made).apply()
|
||||
return made
|
||||
}
|
||||
|
||||
internal fun pcSign(device: String, now: Date = Date(), version: String = if (random.nextBoolean()) "v1" else "v2"): String {
|
||||
val stamp = SimpleDateFormat("yyyy-MM-dd HH:mm:ss:SSS", Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC") }.format(now)
|
||||
val mid = EaCrypto.fnv1a(("DroidDeck" + device.uppercase() + "DroidDeck").toByteArray()).toString()
|
||||
val json = "{\"av\":\"v1\",\"bsn\":\"None\",\"gid\":0,\"hsn\":\"None\",\"mid\":\"$mid\",\"msn\":\"${device.uppercase()}\",\"sv\":\"$version\",\"ts\":\"$stamp\"}"
|
||||
val payload = EaCrypto.base64Url(json.toByteArray())
|
||||
val signature = EaCrypto.base64Url(EaCrypto.hmacSha256(PC_SIGN_KEYS.getValue(version), payload.toByteArray()))
|
||||
return "$payload.$signature"
|
||||
}
|
||||
|
||||
internal fun authUrl(
|
||||
device: String,
|
||||
responseType: String,
|
||||
challenge: String? = null,
|
||||
accessToken: String? = null,
|
||||
clientId: String = CLIENT_ID,
|
||||
scopes: List<String> = emptyList(),
|
||||
tokenFormat: String? = null,
|
||||
expiresIn: Int? = null,
|
||||
): String {
|
||||
val b = Uri.parse(AUTH_URL).buildUpon()
|
||||
.appendQueryParameter("client_id", clientId)
|
||||
.appendQueryParameter("sbiod_enabled", "false")
|
||||
.appendQueryParameter("response_type", responseType)
|
||||
.appendQueryParameter("locale", "en_US")
|
||||
.appendQueryParameter("pc_sign", pcSign(device))
|
||||
.appendQueryParameter("nonce", random.nextInt().toString())
|
||||
if (scopes.isNotEmpty()) b.appendQueryParameter("scope", scopes.joinToString(" "))
|
||||
if (challenge != null) {
|
||||
b.appendQueryParameter("code_challenge_method", "S256")
|
||||
b.appendQueryParameter("code_challenge", challenge)
|
||||
}
|
||||
if (accessToken != null) b.appendQueryParameter("access_token", accessToken)
|
||||
if (tokenFormat != null) b.appendQueryParameter("token_format", tokenFormat)
|
||||
if (expiresIn != null) b.appendQueryParameter("expires_in", expiresIn.toString())
|
||||
return b.build().toString()
|
||||
}
|
||||
|
||||
fun begin(context: Context): Pending {
|
||||
val verifier = EaCrypto.base64Url(ByteArray(32).also(random::nextBytes))
|
||||
val challenge = EaCrypto.base64Url(EaCrypto.sha256(verifier.toByteArray()))
|
||||
return Pending(authUrl(deviceId(context), "code", challenge = challenge), verifier)
|
||||
}
|
||||
|
||||
/** Pulls one parameter out of the redirect EA sends to its desktop client's local page. */
|
||||
internal fun redirectValue(location: String, name: String): String? {
|
||||
val normal = location.replace("qrc:///html", "http://127.0.0.1").replace("qrc:/html", "http://127.0.0.1")
|
||||
val uri = Uri.parse(normal)
|
||||
uri.getQueryParameter(name)?.let { return it }
|
||||
val fragment = uri.encodedFragment ?: return null
|
||||
return Uri.parse("http://x/?$fragment").getQueryParameter(name)
|
||||
}
|
||||
|
||||
private fun tokenCall(values: List<Pair<String, String>>): JSONObject {
|
||||
val reply = EaHttp.request(
|
||||
TOKEN_URL, "POST",
|
||||
mapOf("Content-Type" to "application/x-www-form-urlencoded"),
|
||||
EaHttp.form(values + listOf("client_id" to CLIENT_ID, "client_secret" to CLIENT_SECRET)).toByteArray(),
|
||||
follow = false,
|
||||
)
|
||||
val json = runCatching { JSONObject(reply.text) }.getOrNull()
|
||||
if (!reply.ok || json == null || json.optString("access_token").isEmpty()) {
|
||||
throw IllegalStateException("EA token request failed (${reply.status}): ${json?.optString("error_description")?.ifEmpty { json.optString("error") } ?: ""}")
|
||||
}
|
||||
return json
|
||||
}
|
||||
|
||||
fun exchange(context: Context, code: String, verifier: String) {
|
||||
val token = tokenCall(
|
||||
listOf(
|
||||
"grant_type" to "authorization_code", "code" to code, "code_verifier" to verifier,
|
||||
"redirect_uri" to REDIRECT, "token_format" to "JWS",
|
||||
),
|
||||
)
|
||||
val access = token.getString("access_token")
|
||||
val player = runCatching { EaApi.player(access) }.onFailure { Log.w(TAG, "player: ${it.message}") }.getOrNull()
|
||||
val record = JSONObject()
|
||||
.put("access_token", access)
|
||||
.put("refresh_token", token.optString("refresh_token"))
|
||||
.put("expires_at", System.currentTimeMillis() + token.optLong("expires_in", 3600) * 1000)
|
||||
.put("user_id", player?.userId.orEmpty())
|
||||
.put("persona_id", player?.personaId.orEmpty())
|
||||
.put("display_name", player?.displayName.orEmpty())
|
||||
.put("ea_id", player?.eaId.orEmpty())
|
||||
StoreAccounts.write(context.applicationContext, Store.EA, record)
|
||||
}
|
||||
|
||||
private fun fresh(a: Account) = a.expiresAt - System.currentTimeMillis() > REFRESH_MARGIN_MS || a.refreshToken.isEmpty()
|
||||
|
||||
private fun refreshLocked(context: Context, a: Account): String? {
|
||||
if (a.refreshToken.isEmpty()) return null
|
||||
val token = try {
|
||||
tokenCall(listOf("grant_type" to "refresh_token", "refresh_token" to a.refreshToken))
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "refresh: ${e.message}")
|
||||
return null
|
||||
}
|
||||
val access = token.getString("access_token")
|
||||
val player = if (load(context)?.userId.isNullOrEmpty()) runCatching { EaApi.player(access) }.getOrNull() else null
|
||||
return access.takeIf { storeRefreshed(context, access, token.optLong("expires_in", 3600), token.optString("refresh_token"), player) }
|
||||
}
|
||||
|
||||
/** Writes a refreshed token into the saved sign-in, unless sign-out removed it meanwhile. */
|
||||
internal fun storeRefreshed(context: Context, access: String, expiresIn: Long, refresh: String, player: EaApi.Player? = null): Boolean =
|
||||
synchronized(refreshLock) {
|
||||
val app = context.applicationContext
|
||||
val record = StoreAccounts.read(app, Store.EA) ?: return false
|
||||
record.put("access_token", access).put("expires_at", System.currentTimeMillis() + expiresIn * 1000)
|
||||
refresh.takeIf { it.isNotEmpty() }?.let { record.put("refresh_token", it) }
|
||||
player?.let { record.put("user_id", it.userId).put("persona_id", it.personaId).put("display_name", it.displayName).put("ea_id", it.eaId) }
|
||||
StoreAccounts.write(app, Store.EA, record)
|
||||
true
|
||||
}
|
||||
|
||||
fun accessToken(context: Context): String? {
|
||||
val current = load(context) ?: return null
|
||||
if (fresh(current)) return current.accessToken
|
||||
synchronized(refreshLock) {
|
||||
val again = load(context) ?: return null
|
||||
if (fresh(again)) return again.accessToken
|
||||
return refreshLocked(context, again) ?: again.accessToken.takeIf { again.expiresAt > System.currentTimeMillis() }
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshNow(context: Context, stale: String?): String? = synchronized(refreshLock) {
|
||||
val current = load(context) ?: return null
|
||||
if (stale != null && current.accessToken != stale) return current.accessToken
|
||||
refreshLocked(context, current)
|
||||
}
|
||||
|
||||
/** Asks EA to mint a code or token for another client from the signed-in session, as the EA app does for games. */
|
||||
fun mint(
|
||||
context: Context,
|
||||
clientId: String,
|
||||
responseType: String,
|
||||
scopes: List<String> = emptyList(),
|
||||
tokenFormat: String? = null,
|
||||
expiresIn: Int? = null,
|
||||
): String? {
|
||||
val access = accessToken(context) ?: return null
|
||||
val url = authUrl(deviceId(context), responseType, accessToken = access, clientId = clientId, scopes = scopes, tokenFormat = tokenFormat, expiresIn = expiresIn)
|
||||
val reply = runCatching { EaHttp.request(url, follow = false) }.getOrElse {
|
||||
Log.w(TAG, "mint $clientId: ${it.javaClass.simpleName}")
|
||||
return null
|
||||
}
|
||||
val location = reply.header("Location") ?: run {
|
||||
Log.w(TAG, "mint $clientId: no redirect (${reply.status})")
|
||||
return null
|
||||
}
|
||||
if (location.startsWith("https://signin.ea.com")) {
|
||||
Log.w(TAG, "mint $clientId: the session needs a new sign-in")
|
||||
return null
|
||||
}
|
||||
return redirectValue(location, if (responseType == "token") "access_token" else responseType)
|
||||
}
|
||||
|
||||
/** Waits for any refresh in progress, so it cannot write the sign-in back afterwards. */
|
||||
fun signOut(context: Context) {
|
||||
synchronized(refreshLock) { StoreAccounts.clear(context.applicationContext, Store.EA) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.droiddeck.launcher.stores.ea
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import android.webkit.WebView
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.stores.CatalogItem
|
||||
import com.droiddeck.launcher.stores.InstalledStoreGame
|
||||
import com.droiddeck.launcher.stores.LoginFlows
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreBackend
|
||||
import com.droiddeck.launcher.stores.StoreInstallRoot
|
||||
import com.droiddeck.launcher.stores.StoreLoginActivity
|
||||
import com.droiddeck.launcher.stores.StoresState
|
||||
import com.droiddeck.launcher.stores.WebCookies
|
||||
import com.droiddeck.launcher.stores.download.DownloadEntry
|
||||
import com.droiddeck.launcher.stores.download.DownloadQueue
|
||||
|
||||
object EaBackend : StoreBackend {
|
||||
override val store: Store = Store.EA
|
||||
|
||||
@Volatile internal var app: Context? = null
|
||||
|
||||
init {
|
||||
LoginFlows.eaFlow = { ua -> app?.let { EaLogin(EaAuth.begin(it), ua) } }
|
||||
}
|
||||
|
||||
override fun signIn(context: Context) {
|
||||
app = context.applicationContext
|
||||
context.startActivity(
|
||||
Intent(context, StoreLoginActivity::class.java)
|
||||
.putExtra(StoreLoginActivity.EXTRA_STORE, store.id)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
)
|
||||
}
|
||||
|
||||
override fun signOut(context: Context) {
|
||||
val app = context.applicationContext
|
||||
EaAuth.signOut(app)
|
||||
EaLibrary.reset(app)
|
||||
EaUpdates.available.clear()
|
||||
runCatching { WebCookies.expire(EaLogin.SIGN_IN_URLS, ".ea.com", listOf("/")) {} }
|
||||
}
|
||||
|
||||
override fun syncLibrary(context: Context, force: Boolean) {
|
||||
val app = context.applicationContext
|
||||
this.app = app
|
||||
Thread({ EaLibrary.sync(app, force) }, "ea-sync").start()
|
||||
}
|
||||
|
||||
override fun loadShelves(context: Context, force: Boolean) {}
|
||||
|
||||
override fun install(context: Context, item: CatalogItem, root: java.io.File) {
|
||||
val app = context.applicationContext
|
||||
if (!EaNative.ready()) {
|
||||
StoresState.logLine(EaNative.ENGINE_MISSING)
|
||||
StoresState.post { StoresState.problems[store] = EaNative.ENGINE_MISSING }
|
||||
return
|
||||
}
|
||||
val folder = StoreInstallRoot.folderFor(app, store, item.id, item.title, root)
|
||||
val entry = DownloadEntry(store, item.id, item.title, cover = item.tallImageUrl ?: item.imageUrl, bytesTotal = item.sizeBytes, location = StoreInstallRoot.labelFor(app, folder))
|
||||
DownloadQueue.enqueue(app, entry) { EaInstaller(app, item, folder) }
|
||||
}
|
||||
|
||||
override fun uninstall(context: Context, game: InstalledStoreGame) {}
|
||||
}
|
||||
|
||||
internal class EaLogin(private val pending: EaAuth.Pending, override val userAgent: String) : StoreLoginActivity.Flow {
|
||||
override val startUrl: String get() = pending.url
|
||||
|
||||
override fun isRedirect(url: String): Boolean = url.startsWith("qrc:", ignoreCase = true)
|
||||
|
||||
override fun forgetSignIn(done: () -> Unit) {
|
||||
runCatching { WebCookies.expire(SIGN_IN_URLS, ".ea.com", listOf("/")) { done() } }.onFailure { done() }
|
||||
}
|
||||
|
||||
override fun finish(activity: StoreLoginActivity, view: WebView, url: String): String? {
|
||||
val code = EaAuth.redirectValue(url, "code") ?: return activity.getString(R.string.stores_login_error_verification)
|
||||
return try {
|
||||
EaAuth.exchange(activity, code, pending.verifier)
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
Log.w("EaLogin", "sign-in failed: ${e.message}")
|
||||
activity.getString(R.string.stores_login_error_generic)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val SIGN_IN_URLS = listOf("https://accounts.ea.com", "https://signin.ea.com", "https://www.ea.com")
|
||||
}
|
||||
}
|
||||
|
||||
object EaUpdates {
|
||||
val available = mutableStateMapOf<String, String>()
|
||||
private val checked = java.util.concurrent.ConcurrentHashMap<String, Long>()
|
||||
private const val RECHECK_MS = 30 * 60 * 1000L
|
||||
|
||||
fun check(context: Context, game: InstalledStoreGame, force: Boolean = false) {
|
||||
val app = context.applicationContext
|
||||
val id = game.sidecar.id
|
||||
val now = System.currentTimeMillis()
|
||||
if (!force && now - (checked[id] ?: 0L) < RECHECK_MS) return
|
||||
checked[id] = now
|
||||
Thread({
|
||||
val token = EaAuth.accessToken(app)
|
||||
val offer = token?.let { runCatching { EaApi.offers(it, listOf(id)) }.getOrNull() }?.firstOrNull()
|
||||
if (offer == null) {
|
||||
checked.remove(id)
|
||||
return@Thread
|
||||
}
|
||||
val live = offer.gameVersion
|
||||
val installed = game.sidecar.installVersion
|
||||
StoresState.post {
|
||||
if (live.isNotEmpty() && installed.isNotEmpty() && live != installed) available[game.key] = live else available.remove(game.key)
|
||||
}
|
||||
}, "ea-update-check").start()
|
||||
}
|
||||
|
||||
fun start(context: Context, game: InstalledStoreGame) {
|
||||
val app = context.applicationContext
|
||||
val root = game.folder.parentFile?.parentFile ?: return
|
||||
val known = EaLibrary.game(app, game.sidecar.id)
|
||||
val item = known?.let { EaLibrary.toCatalogItem(app, it) } ?: CatalogItem(
|
||||
Store.EA, game.sidecar.id, game.sidecar.title, game.sidecar.hero, tallImageUrl = game.sidecar.cover, owned = true, extra = game.sidecar.extra,
|
||||
)
|
||||
available.remove(game.key)
|
||||
StoresState.install(app, item, root)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.droiddeck.launcher.stores.ea
|
||||
|
||||
import java.security.MessageDigest
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneOffset
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
internal object EaCrypto {
|
||||
val LSX_DEFAULT_KEY = ByteArray(16) { it.toByte() }
|
||||
|
||||
private val OOA_KEY = intArrayOf(65, 50, 114, 45, 208, 130, 239, 176, 220, 100, 87, 197, 118, 104, 202, 9)
|
||||
.map { it.toByte() }.toByteArray()
|
||||
|
||||
private const val PRIME_10K = 104729L
|
||||
private const val PRIME_20K = 224737L
|
||||
private const val PRIME_30K = 350377L
|
||||
|
||||
fun ecbEncrypt(data: ByteArray, key: ByteArray): ByteArray =
|
||||
Cipher.getInstance("AES/ECB/PKCS5Padding").run { init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES")); doFinal(data) }
|
||||
|
||||
fun ecbDecrypt(data: ByteArray, key: ByteArray): ByteArray =
|
||||
Cipher.getInstance("AES/ECB/PKCS5Padding").run { init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES")); doFinal(data) }
|
||||
|
||||
fun licenseEncrypt(data: ByteArray): ByteArray =
|
||||
Cipher.getInstance("AES/CBC/PKCS5Padding").run { init(Cipher.ENCRYPT_MODE, SecretKeySpec(OOA_KEY, "AES"), IvParameterSpec(ByteArray(16))); doFinal(data) }
|
||||
|
||||
fun licenseDecrypt(data: ByteArray): ByteArray =
|
||||
Cipher.getInstance("AES/CBC/PKCS5Padding").run { init(Cipher.DECRYPT_MODE, SecretKeySpec(OOA_KEY, "AES"), IvParameterSpec(ByteArray(16))); doFinal(data) }
|
||||
|
||||
fun hex(bytes: ByteArray): String = bytes.joinToString("") { "%02x".format(it.toInt() and 0xFF) }
|
||||
|
||||
fun unhex(text: String): ByteArray {
|
||||
val digits = text.lowercase().filter { it in '0'..'9' || it in 'a'..'f' }
|
||||
if (digits.length % 2 != 0) return ByteArray(0)
|
||||
return ByteArray(digits.length / 2) { i -> digits.substring(i * 2, i * 2 + 2).toInt(16).toByte() }
|
||||
}
|
||||
|
||||
/** The session key both sides derive after the LSX challenge, from a seed the game picks. */
|
||||
fun lsxKey(seed: Int): ByteArray {
|
||||
if (seed == 0) return LSX_DEFAULT_KEY.copyOf()
|
||||
var state = 7L
|
||||
fun next(): Int {
|
||||
state = (state * 214013L + 2531011L) and 0xFFFFFFFFL
|
||||
return ((state ushr 16) and 0xFFFF).toInt()
|
||||
}
|
||||
val start = (next().toLong() + seed) and 0xFFFFFFFFL
|
||||
state = start
|
||||
return ByteArray(16) { next().toByte() }
|
||||
}
|
||||
|
||||
fun challengeAnswer(key: String): String = hex(ecbEncrypt(key.toByteArray(Charsets.US_ASCII), LSX_DEFAULT_KEY))
|
||||
|
||||
fun challengeSeed(version: String, answer: String): Int? = when (version) {
|
||||
"2" -> 0
|
||||
"3" -> (answer[0].code shl 8) or answer[1].code
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** EA's launch handshake code, which changes every UTC day. */
|
||||
fun rtpCode(day: LocalDate = LocalDate.now(ZoneOffset.UTC)): Long {
|
||||
val time = ((PRIME_10K * day.year) xor (day.monthValue * PRIME_20K) xor (day.dayOfMonth * PRIME_30K)) and 0xFFFFFFFFL
|
||||
return (time xor ((time shl 16) and 0xFFFFFFFFL) xor (time ushr 16)) and 0xFFFFFFFFL
|
||||
}
|
||||
|
||||
fun sha256(data: ByteArray): ByteArray = MessageDigest.getInstance("SHA-256").digest(data)
|
||||
|
||||
fun sha1(data: ByteArray): ByteArray = MessageDigest.getInstance("SHA-1").digest(data)
|
||||
|
||||
fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray =
|
||||
Mac.getInstance("HmacSHA256").run { init(SecretKeySpec(key, "HmacSHA256")); doFinal(data) }
|
||||
|
||||
fun fnv1a(data: ByteArray): ULong {
|
||||
var hash = 0xcbf29ce484222325UL
|
||||
for (b in data) hash = (hash xor (b.toUByte().toULong())) * 0x100000001b3UL
|
||||
return hash
|
||||
}
|
||||
|
||||
fun base64Url(data: ByteArray): String =
|
||||
java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(data)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.droiddeck.launcher.stores.ea
|
||||
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.net.URLEncoder
|
||||
|
||||
internal class EaReply(val status: Int, val body: ByteArray, val headers: Map<String, List<String>>) {
|
||||
val ok: Boolean get() = status in 200..299
|
||||
val text: String get() = body.toString(Charsets.UTF_8)
|
||||
fun header(name: String): String? = headers.entries.firstOrNull { it.key.equals(name, ignoreCase = true) }?.value?.firstOrNull()
|
||||
}
|
||||
|
||||
internal object EaHttp {
|
||||
const val USER_AGENT = "Mozilla/5.0 EA Desktop/13.735.2.6250"
|
||||
private const val TIMEOUT = 30_000
|
||||
|
||||
fun form(values: List<Pair<String, String>>): String =
|
||||
values.joinToString("&") { (k, v) -> URLEncoder.encode(k, "UTF-8") + "=" + URLEncoder.encode(v, "UTF-8") }
|
||||
|
||||
fun request(
|
||||
url: String,
|
||||
method: String = "GET",
|
||||
headers: Map<String, String> = emptyMap(),
|
||||
body: ByteArray? = null,
|
||||
follow: Boolean = true,
|
||||
): EaReply {
|
||||
val c = URL(url).openConnection() as HttpURLConnection
|
||||
try {
|
||||
c.requestMethod = method
|
||||
c.connectTimeout = TIMEOUT
|
||||
c.readTimeout = TIMEOUT
|
||||
c.instanceFollowRedirects = follow
|
||||
c.setRequestProperty("User-Agent", USER_AGENT)
|
||||
for ((k, v) in headers) c.setRequestProperty(k, v)
|
||||
if (body != null) {
|
||||
c.doOutput = true
|
||||
c.outputStream.use { it.write(body) }
|
||||
}
|
||||
val status = c.responseCode
|
||||
val stream = if (status >= 400) c.errorStream else c.inputStream
|
||||
val bytes = stream?.use { it.readBytes() } ?: ByteArray(0)
|
||||
return EaReply(status, bytes, c.headerFields.filterKeys { it != null })
|
||||
} finally {
|
||||
c.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package com.droiddeck.launcher.stores.ea
|
||||
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.stores.CatalogItem
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreExe
|
||||
import com.droiddeck.launcher.stores.StoreGameSidecar
|
||||
import com.droiddeck.launcher.stores.StoreInstalls
|
||||
import com.droiddeck.launcher.stores.StoreLanguage
|
||||
import com.droiddeck.launcher.stores.amazon.AmazonNative
|
||||
import com.droiddeck.launcher.stores.download.DownloadQueue
|
||||
import com.droiddeck.launcher.stores.download.DownloadStage
|
||||
import com.droiddeck.launcher.stores.download.StoreDownloadTier
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
internal class EaInstaller(
|
||||
private val app: Context,
|
||||
private val item: CatalogItem,
|
||||
private val folder: File,
|
||||
) : DownloadQueue.DownloadJob {
|
||||
private val cancelled = AtomicBoolean(false)
|
||||
|
||||
override fun run(handle: DownloadQueue.JobHandle): String? {
|
||||
handle.stage(DownloadStage.MANIFEST, app.getString(R.string.stores_sync_sign_in))
|
||||
var token = EaAuth.accessToken(app) ?: throw IllegalStateException("Not signed in to EA")
|
||||
val url = try {
|
||||
EaApi.downloadUrl(token, item.id)
|
||||
} catch (e: EaApiException) {
|
||||
if (e.status != 401) throw IllegalStateException(e.message)
|
||||
token = EaAuth.refreshNow(app, token) ?: throw IllegalStateException("The EA sign-in has expired")
|
||||
EaApi.downloadUrl(token, item.id)
|
||||
}
|
||||
val game = EaLibrary.game(app, item.id)
|
||||
val version = runCatching { EaApi.offers(token, listOf(item.id)).firstOrNull()?.gameVersion }.getOrNull().orEmpty()
|
||||
.ifEmpty { game?.version.orEmpty() }
|
||||
val cover = item.tallImageUrl ?: item.imageUrl
|
||||
StoreInstalls.begin(folder, Store.EA, item.id, item.title, cover, item.imageUrl)
|
||||
if (cancelled.get()) return null
|
||||
|
||||
val tier = StoreDownloadTier.current(app)
|
||||
handle.log("ea: \"${item.title}\" tier=${tier.id} window=${tier.networkWindow}")
|
||||
val request = JSONObject()
|
||||
.put("url", url)
|
||||
.put("gameDir", folder.absolutePath)
|
||||
.put("maxWorkers", tier.networkWindow.coerceIn(1, 64))
|
||||
.put("processWorkers", tier.processWorkers)
|
||||
.put("caBundle", "")
|
||||
val shown = AtomicLong(-1)
|
||||
val downloading = AtomicBoolean(false)
|
||||
val listener = object : AmazonNative.InstallListener {
|
||||
override fun onStatus(text: String) { handle.stage(DownloadStage.MANIFEST, text) }
|
||||
override fun onCheck(doneBytes: Long, totalBytes: Long, files: Int, filesTotal: Int) {
|
||||
handle.stage(DownloadStage.MANIFEST, "Checking files…")
|
||||
handle.stageProgress(doneBytes, totalBytes, files, filesTotal, bytes = true)
|
||||
}
|
||||
override fun onSizes(pendingBytes: Long, totalBytes: Long) { handle.diskSize(totalBytes) }
|
||||
override fun onProgress(done: Long, total: Long, files: Int, filesTotal: Int) {
|
||||
if (downloading.compareAndSet(false, true)) handle.stage(DownloadStage.DOWNLOAD)
|
||||
while (true) {
|
||||
val before = shown.get()
|
||||
if (done < before) return
|
||||
if (shown.compareAndSet(before, done)) break
|
||||
}
|
||||
handle.progress(done, total, "Downloading ($files/$filesTotal files)")
|
||||
}
|
||||
override fun onLog(line: String) { handle.log("ea: $line") }
|
||||
override fun beforeReplace(): Boolean = try {
|
||||
StoreInstalls.markUpdating(folder)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
handle.log("ea: the install record could not be switched to updating (${e.javaClass.simpleName})")
|
||||
false
|
||||
}
|
||||
override fun onComplete(success: Boolean, cancelled: Boolean, error: String, result: String) = Unit
|
||||
}
|
||||
val outcome = EaNative.install(request, listener, cancelled)
|
||||
if (outcome.cancelled || cancelled.get() && !outcome.success) return null
|
||||
if (!outcome.success) throw IllegalStateException(outcome.error.ifEmpty { "The EA install failed" })
|
||||
val result = outcome.result ?: JSONObject()
|
||||
result.optLong("totalBytes").takeIf { it > 0 }?.let { EaPrefs.get(app).edit().putLong("size_${item.id}", it).apply() }
|
||||
|
||||
handle.stage(DownloadStage.INSTALL, app.getString(R.string.stores_registering))
|
||||
val sidecar = sidecarFor(app, item, folder, game, version, cover) { handle.log("ea: $it") }
|
||||
StoreInstalls.complete(app, folder, sidecar, onStep = { done, steps -> handle.stageProgress(done.toLong(), steps.toLong()) })
|
||||
return folder.path
|
||||
}
|
||||
|
||||
override fun cancel(deleteFiles: Boolean) {
|
||||
cancelled.set(true)
|
||||
if (deleteFiles) StoreInstalls.discard(app, folder, emptyList())
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun locale(context: Context, available: List<String>): String {
|
||||
val wanted = StoreLanguage.localeFor(null, context)
|
||||
val exact = "${wanted.language}_${wanted.country}"
|
||||
return available.firstOrNull { it.equals(exact, true) }
|
||||
?: available.firstOrNull { it.substringBefore('_').equals(wanted.language, true) }
|
||||
?: available.firstOrNull { it.equals("en_US", true) }
|
||||
?: "en_US"
|
||||
}
|
||||
|
||||
internal fun sidecarFor(app: Context, item: CatalogItem, folder: File, game: EaGame?, version: String, cover: String?, log: (String) -> Unit): StoreGameSidecar {
|
||||
val manifest = EaManifest.read(folder)
|
||||
if (manifest == null) log("no installerdata.xml in ${folder.name}")
|
||||
val launcher = manifest?.launcher()
|
||||
val candidates = listOfNotNull(launcher?.path, game?.executePath?.takeIf { it.isNotEmpty() })
|
||||
var exe = candidates.firstNotNullOfOrNull { EaPaths.relativeOnDisk(folder, EaPaths.relative(it)) }.orEmpty()
|
||||
if (exe.isEmpty()) exe = StoreExe.pick(folder, null, item.title)
|
||||
if (exe.isEmpty()) { log("no exe found; pick one in Steam settings › Added games"); exe = "game.exe" } else log("exe=$exe")
|
||||
val args = EaPaths.splitArgs(listOf(game?.parameters.orEmpty(), launcher?.parameters.orEmpty()).filter { it.isNotBlank() }.joinToString(" "))
|
||||
val keys = JSONArray()
|
||||
listOfNotNull(game?.installCheck, game?.executePath, launcher?.path).mapNotNull { EaPaths.registered(it) }
|
||||
.distinctBy { it.key.lowercase() + "|" + it.value.lowercase() }
|
||||
.forEach { keys.put(JSONArray().put(it.key).put(it.value)) }
|
||||
val extra = linkedMapOf(
|
||||
"contentId" to (game?.contentId ?: item.extra["contentId"].orEmpty()),
|
||||
"slug" to (game?.slug ?: item.extra["slug"].orEmpty()),
|
||||
"multiplayerId" to game?.multiplayerId.orEmpty(),
|
||||
"masterTitleId" to game?.masterTitleId.orEmpty(),
|
||||
"locale" to locale(app, game?.locales.orEmpty()),
|
||||
"registry" to keys.toString(),
|
||||
"touchup" to manifest?.touchupPath.orEmpty().replace('\\', '/').trimStart('/'),
|
||||
"touchupArgs" to manifest?.touchupParameters.orEmpty(),
|
||||
"contentIds" to manifest?.contentIds.orEmpty().joinToString(","),
|
||||
"displayName" to manifest?.title(locale(app, game?.locales.orEmpty())).orEmpty(),
|
||||
).filterValues { it.isNotEmpty() }
|
||||
return StoreGameSidecar(
|
||||
Store.EA, item.id, item.title, exe = exe, args = args,
|
||||
installVersion = version.ifEmpty { manifest?.version.orEmpty() }, installedAt = System.currentTimeMillis(),
|
||||
cover = cover, hero = item.imageUrl, extra = extra, cloud = false, logo = item.logoUrl,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.droiddeck.launcher.stores.ea
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreGameSidecar
|
||||
import com.droiddeck.launcher.stores.StoreInstallRoot
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
object EaLaunch {
|
||||
private const val TAG = "EaLaunch"
|
||||
const val ENV_FILE = ".droiddeck-ea-env"
|
||||
|
||||
private fun installed(context: Context, id: String): Pair<File, StoreGameSidecar>? =
|
||||
StoreInstallRoot.gameFolders(context).firstNotNullOfOrNull { f ->
|
||||
StoreGameSidecar.read(f)?.takeIf { it.store == Store.EA && it.id == id && it.isInstalled }?.let { f to it }
|
||||
}
|
||||
|
||||
internal fun environment(
|
||||
offerId: String,
|
||||
contentId: String,
|
||||
locale: String,
|
||||
generic: String,
|
||||
access: String,
|
||||
opaque: String,
|
||||
account: EaAuth.Account,
|
||||
session: String = UUID.randomUUID().toString(),
|
||||
): LinkedHashMap<String, String> = linkedMapOf(
|
||||
"EAAuthCode" to "unavailable",
|
||||
"EAEgsProxyIpcPort" to "0",
|
||||
"EAEntitlementSource" to "EA",
|
||||
"EAExternalSource" to "EA",
|
||||
"EAFreeTrialGame" to "false",
|
||||
"EAGameLocale" to locale,
|
||||
"EAGenericAuthToken" to generic,
|
||||
"EALaunchCode" to "unavailable",
|
||||
"EALaunchOwner" to "EA",
|
||||
"EALaunchEAID" to account.eaId.ifEmpty { account.displayName },
|
||||
"EALaunchEnv" to "production",
|
||||
"EALaunchOfflineMode" to "false",
|
||||
"EALsxPort" to EaLsx.PORT.toString(),
|
||||
"EARtPLaunchCode" to EaCrypto.rtpCode().toString(),
|
||||
"EASecureLaunchTokenTemp" to account.userId,
|
||||
"EASteamProxyIpcPort" to "0",
|
||||
"OriginSessionKey" to session,
|
||||
"ContentId" to contentId,
|
||||
"EAOnErrorExitRetCode" to "1",
|
||||
"EAConnectionId" to offerId,
|
||||
"EALicenseToken" to offerId,
|
||||
"EALaunchUserAuthToken" to opaque,
|
||||
"EAAccessTokenJWS" to access,
|
||||
)
|
||||
|
||||
/** Adds what installs made before the manifest's content ids and title were recorded are missing. */
|
||||
internal fun backfill(folder: File, sidecar: StoreGameSidecar): StoreGameSidecar {
|
||||
if (sidecar.extra.containsKey("contentIds") || sidecar.extra.containsKey("displayName")) return sidecar
|
||||
val manifest = EaManifest.read(folder) ?: return sidecar
|
||||
val locale = sidecar.extra["locale"].orEmpty().ifEmpty { "en_US" }
|
||||
val added = mapOf(
|
||||
"contentIds" to manifest.contentIds.joinToString(","),
|
||||
"displayName" to manifest.title(locale).orEmpty(),
|
||||
).filterValues { it.isNotEmpty() }
|
||||
if (added.isEmpty()) return sidecar
|
||||
val updated = sidecar.copy(extra = sidecar.extra + added)
|
||||
runCatching { updated.write(folder) }.onFailure { Log.w(TAG, "sidecar: ${it.javaClass.simpleName}") }
|
||||
return updated
|
||||
}
|
||||
|
||||
/** Answers the guest's request right before an EA game starts: session, licence and the LSX endpoint. */
|
||||
fun prepare(context: Context, request: JSONObject): JSONObject {
|
||||
val app = context.applicationContext
|
||||
val id = request.optString("id")
|
||||
val reply = JSONObject().put("ok", false)
|
||||
val (folder, recorded) = installed(app, id) ?: return reply.put("reason", "not-installed")
|
||||
val sidecar = backfill(folder, recorded)
|
||||
runCatching { com.droiddeck.launcher.stores.StoreLaunch.refreshLauncher(folder, sidecar) }
|
||||
File(folder, ENV_FILE).delete()
|
||||
val account = EaAuth.load(app) ?: return reply.put("reason", "signed-out")
|
||||
val access = EaAuth.accessToken(app) ?: return reply.put("reason", "sign-in-expired")
|
||||
val contentId = sidecar.extra["contentId"].orEmpty()
|
||||
val locale = sidecar.extra["locale"].orEmpty().ifEmpty { "en_US" }
|
||||
val core = EaOoa.coreDir(folder, sidecar.exe)
|
||||
val hashVersion = core?.let(EaOoa::activationVersion) ?: 1
|
||||
val hardware = request.optJSONObject("hardware")?.let(EaOoa.Hardware::fromJson)?.takeIf { it.complete(hashVersion) }
|
||||
val legacy = EaOoa.legacyCore(core)
|
||||
|
||||
val lifetime = if (legacy) 14400 else 550
|
||||
val opaque = EaAuth.mint(app, EaAuth.CLIENT_ID, "token", EaAuth.OPAQUE_SCOPES, "OPAQUE", lifetime).orEmpty()
|
||||
val known = (sidecar.extra["contentIds"].orEmpty().split(',') + sidecar.extra["multiplayerId"].orEmpty()).map { it.trim() }.filter { it.isNotEmpty() }.toSet()
|
||||
val lsx = EaLsx.start(app, EaLsx.Game(id, contentId, sidecar.title, sidecar.installVersion, locale, hardware, hashVersion, opaque,
|
||||
System.currentTimeMillis() + (lifetime - 30) * 1000L, known))
|
||||
val env = environment(id, contentId, locale, if (legacy && opaque.isNotEmpty()) opaque else access, access, opaque, EaAuth.load(app) ?: account)
|
||||
val tmp = File(folder, "$ENV_FILE.tmp")
|
||||
tmp.writeText(env.entries.joinToString("\r\n", postfix = "\r\n") { "${it.key}=${it.value}" })
|
||||
tmp.renameTo(File(folder, ENV_FILE))
|
||||
|
||||
val licenses = JSONArray()
|
||||
val notes = ArrayList<String>()
|
||||
if (core != null) {
|
||||
val ids = (listOf(contentId) + sidecar.extra["contentIds"].orEmpty().split(',')).map { it.trim() }.filter { it.isNotEmpty() }.distinct()
|
||||
if (hardware == null) notes += "no-hardware" else {
|
||||
val hash = EaOoa.machineHash(hardware, hashVersion)
|
||||
for (content in ids) {
|
||||
notes += try {
|
||||
for ((path, data) in EaOoa.files(core, EaOoa.request(access, content, hash))) {
|
||||
licenses.put(JSONObject().put("path", path).put("data", Base64.encodeToString(data, Base64.NO_WRAP)))
|
||||
}
|
||||
"$content:ok"
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "licence $content for $id: ${e.message}")
|
||||
com.droiddeck.launcher.stores.StoresState.logLine("ea licence $content for $id v$hashVersion legacy=$legacy: ${e.message}")
|
||||
"$content:refused"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (core != null && notes.isNotEmpty() && notes.none { it.endsWith(":ok") }) runCatching {
|
||||
val rows = EaApi.legacyEntitlements(access).filter { it.optString("productId") == id }
|
||||
val types = rows.joinToString(",") { it.optString("entitlementType") + "/" + it.optString("entitlementTag") + "/" + it.optString("status") }
|
||||
com.droiddeck.launcher.stores.StoresState.logLine("ea $id has no licence; entitlements: ${types.ifEmpty { "none" }}")
|
||||
}
|
||||
val licenseNote = if (core == null) "none-needed" else "v$hashVersion " + notes.joinToString(",")
|
||||
Log.i(TAG, "prepared $id lsx=$lsx legacy=$legacy opaque=${opaque.isNotEmpty()} licence=$licenseNote")
|
||||
return reply.put("ok", true).put("lsx", lsx).put("license", licenseNote).put("licenses", licenses)
|
||||
}
|
||||
}
|
||||
|
||||
object EaSizes {
|
||||
fun size(context: Context, item: com.droiddeck.launcher.stores.CatalogItem): Long {
|
||||
val token = EaAuth.accessToken(context) ?: return 0L
|
||||
val url = runCatching { EaApi.downloadUrl(token, item.id) }.getOrNull() ?: return 0L
|
||||
return EaNative.call("zip.size", JSONObject().put("url", url))?.optLong("totalBytes") ?: 0L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
package com.droiddeck.launcher.stores.ea
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.stores.CatalogItem
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreNet
|
||||
import com.droiddeck.launcher.stores.StoreShelves
|
||||
import com.droiddeck.launcher.stores.StoresState
|
||||
import com.droiddeck.launcher.stores.mergeItems
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
object EaPrefs {
|
||||
const val FILE = "stores.ea"
|
||||
fun get(context: Context): SharedPreferences = context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
data class EaGame(
|
||||
val offerId: String,
|
||||
val contentId: String,
|
||||
val title: String,
|
||||
val slug: String,
|
||||
val tall: String = "",
|
||||
val wide: String = "",
|
||||
val logo: String = "",
|
||||
val version: String = "",
|
||||
val executePath: String = "",
|
||||
val installCheck: String = "",
|
||||
val parameters: String = "",
|
||||
val multiplayerId: String = "",
|
||||
val masterTitleId: String = "",
|
||||
val cloudSaves: String = "",
|
||||
val locales: List<String> = emptyList(),
|
||||
) {
|
||||
fun toJson(): JSONObject = JSONObject()
|
||||
.put("offerId", offerId).put("contentId", contentId).put("title", title).put("slug", slug)
|
||||
.put("tall", tall).put("wide", wide).put("logo", logo).put("version", version)
|
||||
.put("executePath", executePath).put("installCheck", installCheck).put("parameters", parameters)
|
||||
.put("multiplayerId", multiplayerId).put("masterTitleId", masterTitleId).put("cloudSaves", cloudSaves)
|
||||
.put("locales", JSONArray(locales))
|
||||
|
||||
companion object {
|
||||
fun fromJson(o: JSONObject): EaGame? {
|
||||
val locales = o.optJSONArray("locales")
|
||||
return EaGame(
|
||||
offerId = o.optString("offerId").ifEmpty { return null },
|
||||
contentId = o.optString("contentId"),
|
||||
title = o.optString("title"),
|
||||
slug = o.optString("slug"),
|
||||
tall = o.optString("tall"),
|
||||
wide = o.optString("wide"),
|
||||
logo = o.optString("logo"),
|
||||
version = o.optString("version"),
|
||||
executePath = o.optString("executePath"),
|
||||
installCheck = o.optString("installCheck"),
|
||||
parameters = o.optString("parameters"),
|
||||
multiplayerId = o.optString("multiplayerId"),
|
||||
masterTitleId = o.optString("masterTitleId"),
|
||||
cloudSaves = o.optString("cloudSaves"),
|
||||
locales = (0 until (locales?.length() ?: 0)).map { locales!!.optString(it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object EaLibrary {
|
||||
private const val KEY_CACHE = "library_cache"
|
||||
private const val KEY_SYNCED_AT = "library_synced_at"
|
||||
private const val THROTTLE_MS = 6 * 60 * 60 * 1000L
|
||||
private const val ART_TTL_MS = 7 * 24 * 60 * 60 * 1000L
|
||||
private const val ART_THREADS = 4
|
||||
const val STORE_URL = "https://www.ea.com/ea-app/library"
|
||||
private val gate = SyncGate()
|
||||
private val generation = java.util.concurrent.atomic.AtomicInteger()
|
||||
|
||||
/** Drops the cache and makes any sync already running drop its results, in one step. */
|
||||
fun reset(context: Context) {
|
||||
synchronized(cacheLock) {
|
||||
generation.incrementAndGet()
|
||||
EaPrefs.get(context).edit().remove(KEY_CACHE).remove(KEY_SYNCED_AT).commit()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun snapshot(app: Context): Pair<Int, List<EaGame>> = synchronized(cacheLock) { generation.get() to cached(app) }
|
||||
|
||||
internal fun storeCache(app: Context, games: List<EaGame>, gen: Int): Boolean = synchronized(cacheLock) {
|
||||
gen == generation.get() &&
|
||||
EaPrefs.get(app).edit().putString(KEY_CACHE, JSONArray(games.map { it.toJson() }).toString()).putLong(KEY_SYNCED_AT, System.currentTimeMillis()).commit()
|
||||
}
|
||||
|
||||
private val cacheLock = Any()
|
||||
private val store = Store.EA
|
||||
|
||||
fun cached(context: Context): List<EaGame> {
|
||||
val text = EaPrefs.get(context).getString(KEY_CACHE, null) ?: return emptyList()
|
||||
val arr = runCatching { JSONArray(text) }.getOrNull() ?: return emptyList()
|
||||
return (0 until arr.length()).mapNotNull { arr.optJSONObject(it)?.let(EaGame::fromJson) }
|
||||
}
|
||||
|
||||
fun game(context: Context, offerId: String): EaGame? = cached(context).firstOrNull { it.offerId == offerId }
|
||||
|
||||
|
||||
fun toCatalogItem(context: Context, g: EaGame): CatalogItem {
|
||||
val prefs = EaPrefs.get(context)
|
||||
val tall = g.tall.ifEmpty { prefs.getString("poster_${g.offerId}", null).orEmpty() }
|
||||
return CatalogItem(
|
||||
store = store,
|
||||
id = g.offerId,
|
||||
title = g.title.ifBlank { g.slug },
|
||||
imageUrl = g.wide.ifEmpty { tall }.ifEmpty { null },
|
||||
tallImageUrl = tall.ifEmpty { g.wide }.ifEmpty { null },
|
||||
publisher = "Electronic Arts",
|
||||
owned = true,
|
||||
sizeBytes = prefs.getLong("size_${g.offerId}", 0L),
|
||||
storeUrl = STORE_URL,
|
||||
extra = mapOf("contentId" to g.contentId, "slug" to g.slug, "version" to g.version),
|
||||
logoUrl = g.logo.ifEmpty { null },
|
||||
)
|
||||
}
|
||||
|
||||
/** One entry per game: owned editions of the same base game collapse onto the base offer. */
|
||||
internal fun pick(owned: List<EaApi.OwnedProduct>, offers: Map<String, EaApi.Offer>): List<Pair<EaApi.OwnedProduct, EaApi.Offer>> {
|
||||
val usable = owned.filter { o ->
|
||||
val offer = offers[o.offerId] ?: return@filter false
|
||||
!o.trial && o.downloadable && offer.downloadable &&
|
||||
(offer.displayType.isEmpty() || offer.displayType.equals("FullGame", true)) &&
|
||||
(o.gameType.isEmpty() || o.gameType.equals("BASE_GAME", true))
|
||||
}
|
||||
return usable.groupBy { it.baseSlug.ifEmpty { it.slug }.ifEmpty { it.offerId } }.values.map { group ->
|
||||
val chosen = group.firstOrNull { it.baseSlug.isEmpty() } ?: group.first()
|
||||
chosen to offers.getValue(chosen.offerId)
|
||||
}.sortedBy { it.first.name.lowercase() }
|
||||
}
|
||||
|
||||
private fun art(app: Context, token: String, slugs: Collection<String>, gen: Int): Map<String, EaApi.Images> {
|
||||
val prefs = EaPrefs.get(app)
|
||||
val now = System.currentTimeMillis()
|
||||
val out = java.util.concurrent.ConcurrentHashMap<String, EaApi.Images>()
|
||||
val pool = Executors.newFixedThreadPool(ART_THREADS)
|
||||
try {
|
||||
for (slug in slugs.filter { it.isNotEmpty() }) {
|
||||
if (gen != generation.get()) break
|
||||
val cached = prefs.getString("art_$slug", null)?.let { runCatching { JSONObject(it) }.getOrNull() }
|
||||
if (cached != null && now - cached.optLong("at") < ART_TTL_MS) {
|
||||
out[slug] = EaApi.Images(cached.optString("tall").ifEmpty { null }, cached.optString("wide").ifEmpty { null }, cached.optString("logo").ifEmpty { null })
|
||||
continue
|
||||
}
|
||||
pool.execute {
|
||||
if (gen != generation.get()) return@execute
|
||||
val images = runCatching { EaApi.images(token, slug) }.getOrNull() ?: return@execute
|
||||
out[slug] = images
|
||||
prefs.edit().putString(
|
||||
"art_$slug",
|
||||
JSONObject().put("at", now).put("tall", images.tall.orEmpty()).put("wide", images.wide.orEmpty()).put("logo", images.logo.orEmpty()).toString(),
|
||||
).apply()
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
pool.shutdown()
|
||||
pool.awaitTermination(2, TimeUnit.MINUTES)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun posters(app: Context, games: List<EaGame>, gen: Int) {
|
||||
val prefs = EaPrefs.get(app)
|
||||
val todo = games.filter { it.tall.isEmpty() && !prefs.contains("poster_${it.offerId}") }.take(40)
|
||||
if (todo.isEmpty()) return
|
||||
val pool = Executors.newFixedThreadPool(ART_THREADS)
|
||||
try {
|
||||
for (g in todo) pool.execute {
|
||||
if (gen != generation.get()) return@execute
|
||||
prefs.edit().putString("poster_${g.offerId}", runCatching { StoreNet.sgdbPoster(g.title) }.getOrDefault("")).apply()
|
||||
}
|
||||
} finally {
|
||||
pool.shutdown()
|
||||
pool.awaitTermination(5, TimeUnit.MINUTES)
|
||||
}
|
||||
}
|
||||
|
||||
private fun publish(app: Context, games: List<EaGame>, gen: Int = generation.get()) {
|
||||
if (gen != generation.get()) return
|
||||
val items = games.map { toCatalogItem(app, it) }
|
||||
StoresState.post {
|
||||
if (gen != generation.get()) return@post
|
||||
StoresState.library[store] = mergeItems(StoresState.library[store], items)
|
||||
val installed = StoresState.installed.filter { it.sidecar.store == store }.map { it.sidecar.id }.toSet()
|
||||
StoresState.shelves[store] = StoreShelves(trending = items.filterNot { it.id in installed }, art = StoresState.shelves[store]?.art.orEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun <T> withToken(app: Context, token: String, call: (String) -> T): T = try {
|
||||
call(token)
|
||||
} catch (e: EaApiException) {
|
||||
if (e.status != 401) throw e
|
||||
call(EaAuth.refreshNow(app, token) ?: throw e)
|
||||
}
|
||||
|
||||
fun sync(context: Context, force: Boolean) {
|
||||
if (!gate.enter()) return
|
||||
var forced = force
|
||||
do {
|
||||
runCatching { syncOnce(context.applicationContext, forced) }
|
||||
forced = true
|
||||
} while (gate.leave())
|
||||
}
|
||||
|
||||
private fun syncOnce(app: Context, force: Boolean) {
|
||||
val (gen, cache) = snapshot(app)
|
||||
val prefs = EaPrefs.get(app)
|
||||
val quiet = cache.isNotEmpty() && !force
|
||||
fun status(id: Int) { if (!quiet) StoresState.post { StoresState.status[store] = app.getString(id) } }
|
||||
fun problem(text: String?) {
|
||||
StoresState.post {
|
||||
StoresState.status.remove(store)
|
||||
if (text == null) StoresState.problems.remove(store) else StoresState.problems[store] = text
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (cache.isNotEmpty()) publish(app, cache, gen)
|
||||
if (!force && cache.isNotEmpty() && System.currentTimeMillis() - prefs.getLong(KEY_SYNCED_AT, 0L) in 0 until THROTTLE_MS) {
|
||||
return problem(null)
|
||||
}
|
||||
status(R.string.stores_sync_sign_in)
|
||||
val token = EaAuth.accessToken(app) ?: return problem(StoresState.notSignedInLine(store))
|
||||
status(R.string.stores_sync_game_list)
|
||||
val owned = withToken(app, token) { EaApi.owned(it) }
|
||||
val offers = withToken(app, token) { EaApi.offers(it, owned.map { o -> o.offerId }) }.associateBy { it.offerId }
|
||||
val picked = pick(owned, offers)
|
||||
StoresState.logLine("ea: ${owned.size} owned products, ${picked.size} games")
|
||||
for ((o, offer) in picked) StoresState.logLine(
|
||||
"ea: owned ${o.offerId} content=${offer.contentId} master=${offer.masterTitleId} mp=${offer.multiplayerId} via=${o.ownership.joinToString("+")} type=${o.gameType}")
|
||||
if (picked.isEmpty()) return problem(if (cache.isEmpty()) app.getString(R.string.stores_sync_no_games, store.label) else null)
|
||||
if (gen != generation.get()) return
|
||||
status(R.string.stores_sync_cover_art)
|
||||
val images = art(app, token, picked.map { it.first.slug }, gen)
|
||||
val games = picked.map { (o, offer) ->
|
||||
val img = images[o.slug]
|
||||
EaGame(
|
||||
offerId = o.offerId, contentId = offer.contentId, title = o.name.ifBlank { offer.displayName }, slug = o.slug,
|
||||
tall = img?.tall.orEmpty(), wide = img?.wide.orEmpty(), logo = img?.logo.orEmpty(), version = offer.gameVersion,
|
||||
executePath = offer.executePathOverride, installCheck = offer.installCheckOverride, parameters = offer.executeParameters,
|
||||
multiplayerId = offer.multiplayerId, masterTitleId = offer.masterTitleId, cloudSaves = offer.cloudSaves, locales = offer.locales,
|
||||
)
|
||||
}
|
||||
if (!storeCache(app, games, gen)) return
|
||||
posters(app, games, gen)
|
||||
if (gen != generation.get()) return
|
||||
publish(app, games, gen)
|
||||
problem(null)
|
||||
} catch (e: EaApiException) {
|
||||
StoresState.logLine("ea: library sync failed: ${e.message}")
|
||||
if (e.status == 401) StoresState.markSignInExpired(store)
|
||||
problem(if (cache.isEmpty()) e.message else null)
|
||||
} catch (e: Exception) {
|
||||
StoresState.logLine("ea: library sync failed: ${e.javaClass.simpleName} ${e.message.orEmpty()}")
|
||||
problem(if (cache.isEmpty()) e.message ?: app.getString(R.string.stores_sync_failed) else null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One sync at a time; a request made meanwhile makes the running one go round again rather than being lost. */
|
||||
internal class SyncGate {
|
||||
private var busy = false
|
||||
private var again = false
|
||||
|
||||
/** True when the caller should run; false when a running sync will pick the request up. */
|
||||
@Synchronized
|
||||
fun enter(): Boolean {
|
||||
if (busy) {
|
||||
again = true
|
||||
return false
|
||||
}
|
||||
busy = true
|
||||
return true
|
||||
}
|
||||
|
||||
/** True when another round was requested while running; the caller stays the runner. */
|
||||
@Synchronized
|
||||
fun leave(): Boolean {
|
||||
if (again) {
|
||||
again = false
|
||||
return true
|
||||
}
|
||||
busy = false
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
package com.droiddeck.launcher.stores.ea
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import org.xmlpull.v1.XmlPullParserFactory
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.OutputStream
|
||||
import java.io.StringReader
|
||||
import java.net.InetAddress
|
||||
import java.net.ServerSocket
|
||||
import java.net.Socket
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
/**
|
||||
* The local SDK endpoint EA games talk to in place of the EA app (LSX: XML over TCP, AES after a
|
||||
* challenge). It answers for the one game DroidDeck last prepared.
|
||||
*/
|
||||
object EaLsx {
|
||||
private const val TAG = "EaLsx"
|
||||
const val PORT = 3216
|
||||
private const val SENDER = "EALS"
|
||||
private const val CHALLENGE_KEY = "cacf897a20b6d612ad0c05e011df52bb"
|
||||
private const val CHALLENGE_VERSION = "10,5,30,15625"
|
||||
private const val LANGUAGES = "ar_SA,de_DE,en_US,es_ES,es_MX,fr_FR,it_IT,ja_JP,ko_KR,pl_PL,pt_BR,ru_RU,zh_CN,zh_TW"
|
||||
|
||||
private val SERVICES = listOf(
|
||||
"EbisuSDK" to "SDK", "EbisuSDK" to "PROFILE", "XMPP" to "PRESENCE", "XMPP" to "FRIENDS", "Commerce" to "COMMERCE",
|
||||
"EbisuSDK" to "RECENTPLAYER", "EbisuSDK" to "IGO", "EbisuSDK" to "MISC", "EALS" to "LOGIN", "EbisuSDK" to "UTILITY",
|
||||
"XMPP" to "XMPP", "XMPP" to "CHAT", "EbisuSDK" to "IGO_EVENT", "EALS" to "EALS_EVENTS", "EbisuSDK" to "LOGIN_EVENT",
|
||||
"XMPP" to "INVITE_EVENT", "EbisuSDK" to "PROFILE_EVENT", "XMPP" to "PRESENCE_EVENT", "XMPP" to "FRIENDS_EVENT",
|
||||
"Commerce" to "COMMERCE_EVENT", "XMPP" to "CHAT_EVENT", "EbisuSDK" to "DOWNLOAD_EVENT", "EbisuSDK" to "PERMISSION",
|
||||
"EbisuSDK" to "RESOURCES", "EbisuSDK" to "BLOCKED_USERS", "EbisuSDK" to "BLOCKED_USER_EVENT", "EbisuSDK" to "GET_USERID",
|
||||
"EbisuSDK" to "ONLINE_STATUS_EVENT", "EbisuSDK" to "ACHIEVEMENT", "EbisuSDK" to "ACHIEVEMENT_EVENT",
|
||||
"EbisuSDK" to "BROADCAST_EVENT", "PI" to "PROGRESSIVE_INSTALLATION", "PI" to "PROGRESSIVE_INSTALLATION_EVENT",
|
||||
"EbisuSDK" to "CONTENT",
|
||||
)
|
||||
|
||||
class Game(
|
||||
val offerId: String,
|
||||
val contentId: String,
|
||||
val title: String,
|
||||
val version: String,
|
||||
val locale: String,
|
||||
val hardware: EaOoa.Hardware?,
|
||||
val hashVersion: Int,
|
||||
val token: String = "",
|
||||
val tokenExpiresAt: Long = 0L,
|
||||
val contentIds: Set<String> = emptySet(),
|
||||
) {
|
||||
val validToken: String? get() = token.takeIf { it.isNotEmpty() && System.currentTimeMillis() < tokenExpiresAt }
|
||||
}
|
||||
|
||||
/** The parts of a request a reply depends on. */
|
||||
class Message(val recipient: String, val id: String, val name: String, val attrs: Map<String, String>, val children: List<Pair<String, String>>)
|
||||
|
||||
interface Account {
|
||||
val userId: String
|
||||
val personaId: String
|
||||
val persona: String
|
||||
fun authCode(clientId: String, scope: String?): String?
|
||||
fun license(contentId: String, requestTicket: String, ticketEngine: String): String?
|
||||
fun entitlements(group: String): List<Map<String, String>>
|
||||
fun token(): String?
|
||||
}
|
||||
|
||||
@Volatile var game: Game? = null
|
||||
@Volatile private var account: Account? = null
|
||||
/** The one session of the current launch that may act for the account. */
|
||||
/** Bumped whenever the launch changes; a connection accepted under an older one is not admitted. */
|
||||
@Volatile internal var generation = 0
|
||||
private val holder = java.util.concurrent.atomic.AtomicReference<Any?>(null)
|
||||
private var server: ServerSocket? = null
|
||||
private val lock = Any()
|
||||
private const val MAX_CLIENTS = 8
|
||||
private val WHITESPACE = Regex("[ \t]+")
|
||||
|
||||
fun start(context: Context, game: Game): Boolean {
|
||||
synchronized(lock) {
|
||||
closeSessions()
|
||||
this.game = game
|
||||
account = AppAccount(context.applicationContext)
|
||||
runCatching { server?.close() }
|
||||
return try {
|
||||
val socket = ServerSocket().apply {
|
||||
reuseAddress = true
|
||||
bind(java.net.InetSocketAddress(InetAddress.getByName("127.0.0.1"), PORT), 8)
|
||||
}
|
||||
server = socket
|
||||
Thread({ accept(socket) }, "ea-lsx").apply { isDaemon = true }.start()
|
||||
Log.i(TAG, "listening on $PORT")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "could not listen on $PORT: ${e.javaClass.simpleName}")
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Ends the endpoint; with [offerId], only if it still serves that game, so a late exit cannot stop a newer launch. */
|
||||
fun stop(offerId: String? = null) {
|
||||
synchronized(lock) {
|
||||
if (offerId != null && game?.offerId != offerId) return
|
||||
runCatching { server?.close() }
|
||||
server = null
|
||||
game = null
|
||||
account = null
|
||||
closeSessions()
|
||||
}
|
||||
}
|
||||
|
||||
private fun closeSessions() {
|
||||
generation++
|
||||
holder.set(null)
|
||||
synchronized(sessions) { sessions.forEach { it.trusted = false } }
|
||||
for (socket in open) runCatching { socket.close() }
|
||||
open.clear()
|
||||
}
|
||||
|
||||
private val open = java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap<Socket, Boolean>())
|
||||
private const val HANDSHAKE_TIMEOUT_MS = 15_000
|
||||
private const val UNTRUSTED_IDLE_MS = 10 * 60 * 1000
|
||||
|
||||
private fun accept(socket: ServerSocket) {
|
||||
while (!socket.isClosed) {
|
||||
val client = runCatching { socket.accept() }.getOrNull() ?: continue
|
||||
val (owner, unreadable) = ownerUid(client)
|
||||
val verified = owner?.let { it == android.os.Process.myUid() }
|
||||
if (verified == false) {
|
||||
Log.w(TAG, "refused a connection from another app")
|
||||
runCatching { client.close() }
|
||||
continue
|
||||
}
|
||||
if (unreadable != null) note("peer cannot be identified: $unreadable")
|
||||
val session = runCatching { Session(client, client.getOutputStream(), verified == true, unreadable != null) }.getOrNull()
|
||||
val live = session != null && synchronized(lock) { server === socket && admit(session).also { if (it) open += client } }
|
||||
if (!live) {
|
||||
runCatching { client.close() }
|
||||
continue
|
||||
}
|
||||
Thread({ try { serve(client, session!!) } finally { synchronized(sessions) { sessions -= session!! }; open -= client } }, "ea-lsx-client").apply { isDaemon = true }.start()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The uid owning the connecting socket, or null where SELinux hides /proc/net. The guest runs
|
||||
* under this app's uid, so any other uid is another app; an unknown owner never gets credentials.
|
||||
*/
|
||||
/** The connecting socket's uid, or why Android hid socket ownership from this app (some builds deny /proc/net). */
|
||||
private fun ownerUid(client: Socket): Pair<Int?, String?> {
|
||||
val port = client.port
|
||||
var denied: String? = null
|
||||
var read = false
|
||||
for (path in listOf("/proc/net/tcp6", "/proc/net/tcp")) {
|
||||
val lines = runCatching { java.io.File(path).readLines() }.onFailure { denied = "$path ${it.javaClass.simpleName}" }.getOrNull() ?: continue
|
||||
read = true
|
||||
owner(lines, port)?.let { return it to null }
|
||||
}
|
||||
return null to if (read) null else denied ?: "no socket table"
|
||||
}
|
||||
|
||||
internal fun owner(lines: List<String>, port: Int): Int? = lines.drop(1).firstNotNullOfOrNull { line ->
|
||||
val f = line.trim().split(WHITESPACE)
|
||||
val local = f.getOrNull(1)?.substringAfterLast(':')?.toIntOrNull(16)
|
||||
val remote = f.getOrNull(2)?.substringAfterLast(':')?.toIntOrNull(16)
|
||||
if (local == port && remote == PORT) f.getOrNull(7)?.toIntOrNull() else null
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds [session] unless the cap is reached by sessions that cannot be replaced; otherwise the
|
||||
* oldest session that never claimed the prepared game (then the oldest untrusted one) makes room.
|
||||
* Counting and eviction share one lock, so the live total never passes the cap.
|
||||
*/
|
||||
internal fun admit(session: Session): Boolean = synchronized(sessions) {
|
||||
if (sessions.count { !it.evicted } >= MAX_CLIENTS) {
|
||||
val candidates = sessions.filter { !it.trusted && !it.evicted }
|
||||
val victim = candidates.firstOrNull { !it.claimed } ?: candidates.firstOrNull() ?: return false
|
||||
victim.evicted = true
|
||||
runCatching { victim.socket.close() }
|
||||
}
|
||||
sessions += session
|
||||
true
|
||||
}
|
||||
|
||||
internal val sessions = LinkedHashSet<Session>()
|
||||
|
||||
/** Trust and eviction exclude each other: an evicted session never becomes trusted, a trusted one is never evicted. */
|
||||
internal fun promote(session: Session, matches: Boolean) {
|
||||
synchronized(sessions) { session.trusted = matches && !session.evicted && session.born == generation && holder.compareAndSet(null, session) }
|
||||
}
|
||||
|
||||
internal class Session(val socket: Socket, val output: OutputStream, val verified: Boolean, val unverifiable: Boolean = false) {
|
||||
val born = generation
|
||||
val connectedAt = System.currentTimeMillis()
|
||||
@Volatile var evicted = false
|
||||
@Volatile var claimed = false
|
||||
var key: ByteArray? = null
|
||||
@Volatile var trusted = false
|
||||
var pending: ByteArray? = null
|
||||
|
||||
/** A peer Android names as another app never gets account data; one Android cannot name is judged by its claim alone. */
|
||||
val mayTrust get() = (verified || unverifiable) && claimed
|
||||
|
||||
@Synchronized
|
||||
fun send(xml: String) {
|
||||
val key = this.key
|
||||
val text = if (key == null) xml else EaCrypto.hex(EaCrypto.ecbEncrypt(xml.toByteArray(), key))
|
||||
output.write(text.toByteArray())
|
||||
output.write(0)
|
||||
output.flush()
|
||||
}
|
||||
}
|
||||
|
||||
private fun note(line: String) = com.droiddeck.launcher.stores.StoresState.logLine("ea lsx: $line")
|
||||
|
||||
private fun serve(client: Socket, session: Session) {
|
||||
client.tcpNoDelay = true
|
||||
client.soTimeout = HANDSHAKE_TIMEOUT_MS
|
||||
val seen = LinkedHashSet<String>()
|
||||
var why = "closed by the game"
|
||||
note("game connected")
|
||||
try {
|
||||
session.send("<LSX><Event sender=\"$SENDER\"><Challenge build=\"release\" key=\"$CHALLENGE_KEY\" version=\"$CHALLENGE_VERSION\"/></Event></LSX>")
|
||||
val input = client.getInputStream()
|
||||
val pending = ByteArrayOutputStream()
|
||||
val chunk = ByteArray(16 * 1024)
|
||||
while (true) {
|
||||
if (session.key == null) {
|
||||
val left = session.connectedAt + HANDSHAKE_TIMEOUT_MS - System.currentTimeMillis()
|
||||
if (left <= 0) throw java.net.SocketTimeoutException()
|
||||
client.soTimeout = left.toInt()
|
||||
}
|
||||
val n = input.read(chunk)
|
||||
if (n < 0) break
|
||||
pending.write(chunk, 0, n)
|
||||
for (frame in frames(pending, session.key)) {
|
||||
for (one in Regex("<LSX>.*?</LSX>", RegexOption.DOT_MATCHES_ALL).findAll(frame)) {
|
||||
val message = parse(one.value) ?: continue
|
||||
if (seen.add(message.name)) note("request ${message.name}")
|
||||
val reply = runCatching { answer(session, message) }
|
||||
.onFailure { note("${message.name} failed: ${it.javaClass.simpleName}") }.getOrNull()
|
||||
if (reply != null) session.send("<LSX><Response sender=\"${esc(message.recipient)}\" id=\"${esc(message.id)}\">$reply</Response></LSX>")
|
||||
session.pending?.let { session.key = it; session.pending = null }
|
||||
if (session.key != null) {
|
||||
val idle = if (session.trusted) 0 else UNTRUSTED_IDLE_MS
|
||||
if (client.soTimeout != idle) client.soTimeout = idle
|
||||
}
|
||||
}
|
||||
}
|
||||
if (pending.size() > 4 * 1024 * 1024) { why = "message too large"; break }
|
||||
}
|
||||
} catch (e: java.net.SocketTimeoutException) {
|
||||
why = "no trusted handshake in time"
|
||||
} catch (e: Exception) {
|
||||
why = e.javaClass.simpleName
|
||||
} finally {
|
||||
holder.compareAndSet(session, null)
|
||||
note("game disconnected ($why)")
|
||||
runCatching { client.close() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete messages in what has arrived so far. Messages normally end in a NUL; one that does
|
||||
* not counts once it holds a whole `<LSX>` element, plain or decrypted.
|
||||
*/
|
||||
private fun frames(pending: ByteArrayOutputStream, key: ByteArray?): List<String> {
|
||||
val bytes = pending.toByteArray()
|
||||
val out = ArrayList<String>()
|
||||
var start = 0
|
||||
for (i in bytes.indices) {
|
||||
if (bytes[i].toInt() != 0) continue
|
||||
if (i > start) decode(String(bytes, start, i - start, Charsets.UTF_8), key)?.let(out::add)
|
||||
start = i + 1
|
||||
}
|
||||
var rest = String(bytes, start, bytes.size - start, Charsets.UTF_8)
|
||||
if (rest.isNotBlank()) decode(rest, key)?.takeIf { it.contains("</LSX>") }?.let { out += it; rest = "" }
|
||||
pending.reset()
|
||||
pending.write(rest.toByteArray(Charsets.UTF_8))
|
||||
return out
|
||||
}
|
||||
|
||||
private fun decode(frame: String, key: ByteArray?): String? {
|
||||
val text = frame.trim()
|
||||
if (text.isEmpty()) return null
|
||||
if (text.startsWith("<")) return text
|
||||
val k = key ?: return null
|
||||
return runCatching { String(EaCrypto.ecbDecrypt(EaCrypto.unhex(text), k)) }.getOrNull()
|
||||
}
|
||||
|
||||
internal fun parse(xml: String): Message? = runCatching {
|
||||
val parser = XmlPullParserFactory.newInstance().newPullParser()
|
||||
parser.setInput(StringReader(xml))
|
||||
var recipient = ""
|
||||
var id = ""
|
||||
var name = ""
|
||||
val attrs = HashMap<String, String>()
|
||||
val children = ArrayList<Pair<String, String>>()
|
||||
var depth = 0
|
||||
var child = ""
|
||||
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
|
||||
when (parser.eventType) {
|
||||
XmlPullParser.START_TAG -> {
|
||||
depth++
|
||||
when (depth) {
|
||||
2 -> if (parser.name == "Request") {
|
||||
recipient = parser.getAttributeValue(null, "recipient").orEmpty()
|
||||
id = parser.getAttributeValue(null, "id").orEmpty()
|
||||
} else return@runCatching null
|
||||
3 -> {
|
||||
name = parser.name
|
||||
for (i in 0 until parser.attributeCount) attrs[parser.getAttributeName(i)] = parser.getAttributeValue(i)
|
||||
}
|
||||
4 -> child = parser.name
|
||||
}
|
||||
}
|
||||
XmlPullParser.TEXT -> if (depth == 4) children += child to parser.text.trim()
|
||||
XmlPullParser.END_TAG -> depth--
|
||||
}
|
||||
parser.next()
|
||||
}
|
||||
if (name.isEmpty()) null else Message(recipient, id, name, attrs, children)
|
||||
}.getOrNull()
|
||||
|
||||
private fun esc(s: String) = s.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """)
|
||||
|
||||
private fun tag(name: String, vararg attrs: Pair<String, Any?>, body: String? = null): String {
|
||||
val a = attrs.filter { it.second != null }.joinToString("") { " ${it.first}=\"${esc(it.second.toString())}\"" }
|
||||
return if (body == null) "<$name$a/>" else "<$name$a>$body</$name>"
|
||||
}
|
||||
|
||||
private fun now() = LocalDateTime.now(ZoneOffset.UTC).format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"))
|
||||
|
||||
private fun answer(session: Session, m: Message): String? {
|
||||
val g = game
|
||||
val who = account
|
||||
val person = who?.takeIf { session.trusted }
|
||||
val locale = g?.locale ?: "en_US"
|
||||
return when (m.name) {
|
||||
"ChallengeResponse" -> {
|
||||
val answer = EaCrypto.challengeAnswer(m.attrs["key"].orEmpty())
|
||||
val version = m.attrs["version"].orEmpty()
|
||||
val seed = EaCrypto.challengeSeed(version, answer) ?: 0
|
||||
val content = m.children.firstOrNull { it.first == "ContentId" }?.second.orEmpty()
|
||||
session.claimed = g != null && content.isNotEmpty() && (content == g.contentId || content == g.offerId || content in g.contentIds)
|
||||
val matches = session.mayTrust
|
||||
promote(session, matches)
|
||||
note("challenge version '$version' content '$content' ${when { session.trusted -> "matches the prepared game"; matches -> "matches, but another session holds this launch"; !session.verified && !session.unverifiable -> "comes from an unverified peer"; else -> "does not match the prepared game" }}")
|
||||
session.pending = EaCrypto.lsxKey(seed)
|
||||
Log.i(TAG, "game connected: ${m.children.firstOrNull { it.first == "Title" }?.second.orEmpty()}")
|
||||
tag("ChallengeAccepted", "response" to answer)
|
||||
}
|
||||
"GetConfig" -> tag("GetConfigResponse", body = SERVICES.joinToString("") { tag("Service", "Name" to it.first, "Facility" to it.second) })
|
||||
"GetProfile" -> tag(
|
||||
"GetProfileResponse",
|
||||
"Persona" to person?.persona.orEmpty(), "SubscriberLevel" to 0, "CommerceCurrency" to "USD", "IsTrialSubscriber" to false,
|
||||
"Country" to "US", "UserId" to person?.userId.orEmpty(), "GeoCountry" to "US", "AvatarId" to "", "IsSubscriber" to false,
|
||||
"IsSteamSubscriber" to false, "PersonaId" to person?.personaId.orEmpty(), "IsUnderAge" to false, "UserIndex" to 0,
|
||||
)
|
||||
"GetSetting" -> tag("GetSettingResponse", "Setting" to when (m.attrs["SettingId"]) {
|
||||
"ENVIRONMENT" -> "production"
|
||||
"LANGUAGE" -> locale
|
||||
else -> "false"
|
||||
})
|
||||
"GetGameInfo" -> tag("GetGameInfoResponse", "GameInfo" to when (m.attrs["GameInfoId"]) {
|
||||
"FREETRIAL" -> "false"
|
||||
"LANGUAGES" -> LANGUAGES
|
||||
"INSTALLED_LANGUAGE" -> locale
|
||||
"INSTALLED_VERSION", "AVAILABLE_VERSION" -> g?.version.orEmpty()
|
||||
else -> ""
|
||||
})
|
||||
"GetAllGameInfo" -> tag(
|
||||
"GetAllGameInfoResponse",
|
||||
"FullGamePurchased" to true, "FullGameReleased" to true, "InstalledVersion" to (g?.version ?: "0"), "MaxGroupSize" to 16,
|
||||
"Languages" to LANGUAGES, "Expiration" to "0000-00-00T00:00:00", "UpToDate" to true, "HasExpiration" to false,
|
||||
"EntitlementSource" to "EA", "AvailableVersion" to (g?.version ?: "0"), "DisplayName" to g?.title.orEmpty(),
|
||||
"FreeTrial" to false, "InstalledLanguage" to locale, "FullGameReleaseDate" to "2000-01-01T00:00:00", "SystemTime" to now(),
|
||||
)
|
||||
"GetInternetConnectedState" -> tag("InternetConnectedState", "connected" to 1)
|
||||
"IsProgressiveInstallationAvailable" -> tag("IsProgressiveInstallationAvailableResponse", "ItemId" to m.attrs["ItemId"].orEmpty(), "Available" to false)
|
||||
"AreChunksInstalled" -> tag(
|
||||
"AreChunksInstalledResponse", "ItemId" to m.attrs["ItemId"].orEmpty(), "Installed" to true,
|
||||
body = m.children.joinToString("") { tag(it.first, body = esc(it.second)) },
|
||||
)
|
||||
"GetAuthCode" -> tag("AuthCode", "value" to (who?.takeIf { session.trusted }?.authCode(m.attrs["ClientId"].orEmpty(), m.attrs["Scope"])?.takeIf { session.trusted } ?: "invalid"))
|
||||
"GetAuthToken" -> tag("AuthToken", "value" to if (!session.trusted) "" else (g?.validToken ?: who?.token().orEmpty()))
|
||||
"RequestLicense" -> {
|
||||
val token = who?.takeIf { session.trusted }?.license(g?.contentId.orEmpty(), m.attrs["RequestTicket"].orEmpty(), m.attrs["TicketEngine"].orEmpty())?.takeIf { session.trusted }.orEmpty()
|
||||
tag("RequestLicenseResponse", "License" to token)
|
||||
}
|
||||
"QueryEntitlements" -> {
|
||||
val group = m.attrs["Group"].orEmpty()
|
||||
val rows = who?.takeIf { session.trusted }?.entitlements(group).orEmpty()
|
||||
tag("QueryEntitlementsResponse", body = rows.joinToString("") { e ->
|
||||
tag(
|
||||
"Entitlement",
|
||||
"LastModifiedDate" to "0000-00-00T00:00:00", "EntitlementId" to e["id"], "UseCount" to (e["useCount"] ?: "0"),
|
||||
"Version" to (e["version"] ?: "0"), "ItemId" to e["productId"], "ResourceId" to "", "GrantDate" to e["grantDate"],
|
||||
"Group" to group, "EntitlementTag" to e["entitlementTag"], "Type" to e["entitlementType"],
|
||||
"Expiration" to (e["terminationDate"]?.takeIf { it.isNotEmpty() && it != "null" } ?: "0000-00-00T00:00:00"), "Source" to "",
|
||||
)
|
||||
})
|
||||
}
|
||||
"GetPresence" -> tag("GetPresenceResponse", "UserId" to person?.userId.orEmpty(), "Presence" to "INGAME")
|
||||
"QueryPresence" -> tag("QueryPresenceResponse", body = "")
|
||||
"QueryFriends" -> tag("QueryFriendsResponse", body = "")
|
||||
"GetBlockList" -> tag("GetBlockListResponse", "Return" to "Success", body = "")
|
||||
"QueryOffers" -> tag("QueryOffersResponse", body = "")
|
||||
"QueryImage" -> tag("QueryImageResponse", "Result" to 0, body = "")
|
||||
"GetVoipStatus" -> tag("GetVoipStatusResponse", "Available" to false, "Active" to false)
|
||||
"ShowIGOWindow", "SetDownloaderUtilization" -> null
|
||||
else -> tag("ErrorSuccess", "Code" to 0, "Description" to "")
|
||||
}
|
||||
}
|
||||
|
||||
private class AppAccount(private val app: Context) : Account {
|
||||
private val saved get() = EaAuth.load(app)
|
||||
override val userId: String get() = saved?.userId.orEmpty()
|
||||
override val personaId: String get() = saved?.personaId.orEmpty()
|
||||
override val persona: String get() = saved?.eaId?.ifEmpty { null } ?: saved?.displayName.orEmpty()
|
||||
|
||||
override fun authCode(clientId: String, scope: String?): String? =
|
||||
EaAuth.mint(app, clientId, "code", scopes = scope?.split(' ')?.filter { it.isNotBlank() }.orEmpty())
|
||||
|
||||
override fun license(contentId: String, requestTicket: String, ticketEngine: String): String? {
|
||||
val g = game ?: return null
|
||||
val hw = g.hardware ?: return null
|
||||
val token = EaAuth.accessToken(app) ?: return null
|
||||
return runCatching {
|
||||
EaOoa.request(token, contentId, EaOoa.machineHash(hw, g.hashVersion), requestTicket.ifEmpty { null }, ticketEngine.ifEmpty { null }).gameToken
|
||||
}.onFailure { Log.w(TAG, "licence: ${it.message}") }.getOrNull()
|
||||
}
|
||||
|
||||
override fun token(): String? = EaAuth.mint(app, EaAuth.CLIENT_ID, "token", EaAuth.OPAQUE_SCOPES, "OPAQUE", 14400)
|
||||
|
||||
override fun entitlements(group: String): List<Map<String, String>> {
|
||||
val token = EaAuth.accessToken(app) ?: return emptyList()
|
||||
val arr = runCatching { EaApi.entitlements(token, group) }.getOrNull() ?: return emptyList()
|
||||
return (0 until arr.length()).mapNotNull { arr.optJSONObject(it) }.map { o ->
|
||||
o.keys().asSequence().associateWith { k -> o.optString(k) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package com.droiddeck.launcher.stores.ea
|
||||
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import org.xmlpull.v1.XmlPullParserFactory
|
||||
import java.io.File
|
||||
import java.io.StringReader
|
||||
|
||||
/** What `__Installer/installerdata.xml` in an EA build says about running and finishing the install. */
|
||||
class EaManifest(
|
||||
val version: String,
|
||||
val launchers: List<Launcher>,
|
||||
val touchupPath: String,
|
||||
val touchupParameters: String,
|
||||
val contentIds: List<String> = emptyList(),
|
||||
val titles: Map<String, String> = emptyMap(),
|
||||
) {
|
||||
fun title(locale: String): String? = titles[locale] ?: titles["en_US"] ?: titles.values.firstOrNull()
|
||||
|
||||
class Launcher(val path: String, val parameters: String, val trial: Boolean)
|
||||
|
||||
fun launcher(): Launcher? = launchers.firstOrNull { !it.trial } ?: launchers.firstOrNull()
|
||||
|
||||
companion object {
|
||||
const val RELATIVE = "__Installer/installerdata.xml"
|
||||
|
||||
fun decode(bytes: ByteArray): String {
|
||||
val utf16 = bytes.size >= 2 && ((bytes[0] == 0xFF.toByte() && bytes[1] == 0xFE.toByte()) || (bytes[0] == 0xFE.toByte() && bytes[1] == 0xFF.toByte()))
|
||||
val text = if (utf16) String(bytes, Charsets.UTF_16) else String(bytes, Charsets.UTF_8)
|
||||
return text.trimStart('')
|
||||
}
|
||||
|
||||
fun read(folder: File): EaManifest? {
|
||||
val file = File(folder, RELATIVE).takeIf { it.isFile } ?: EaPaths.find(folder, RELATIVE) ?: return null
|
||||
return runCatching { parse(decode(file.readBytes())) }.getOrNull()
|
||||
}
|
||||
|
||||
fun parse(xml: String): EaManifest {
|
||||
val parser = XmlPullParserFactory.newInstance().newPullParser()
|
||||
parser.setInput(StringReader(xml.replace(Regex("^<\\?xml[^>]*\\?>"), "")))
|
||||
val stack = ArrayList<String>()
|
||||
var version = ""
|
||||
val launchers = ArrayList<Launcher>()
|
||||
var touchupPath = ""
|
||||
var touchupParams = ""
|
||||
var path = ""
|
||||
var params = ""
|
||||
var trial = false
|
||||
val contentIds = ArrayList<String>()
|
||||
val titles = LinkedHashMap<String, String>()
|
||||
var locale = ""
|
||||
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
|
||||
when (parser.eventType) {
|
||||
XmlPullParser.START_TAG -> {
|
||||
val name = parser.name
|
||||
stack += name
|
||||
if (name.equals("gameVersion", true)) {
|
||||
version = parser.getAttributeValue(null, "version") ?: version
|
||||
}
|
||||
if (name.equals("launcher", true)) { path = ""; params = ""; trial = false }
|
||||
if (name.equals("localeInfo", true)) locale = parser.getAttributeValue(null, "locale").orEmpty()
|
||||
}
|
||||
XmlPullParser.TEXT -> {
|
||||
val text = parser.text.trim()
|
||||
val tag = stack.lastOrNull().orEmpty()
|
||||
val parent = stack.getOrNull(stack.size - 2).orEmpty()
|
||||
if (text.isNotEmpty()) when {
|
||||
parent.equals("launcher", true) && tag.equals("filePath", true) -> path = text
|
||||
parent.equals("launcher", true) && tag.equals("parameters", true) -> params = text
|
||||
parent.equals("launcher", true) && tag.equals("trial", true) -> trial = text.equals("true", true) || text == "1"
|
||||
(parent.equals("touchup", true) || parent.equals("executable", true)) && tag.equals("filePath", true) -> touchupPath = text
|
||||
(parent.equals("touchup", true) || parent.equals("executable", true)) && tag.equals("parameters", true) -> touchupParams = text
|
||||
parent.equals("contentIDs", true) && tag.equals("contentID", true) -> contentIds += text
|
||||
parent.equals("localeInfo", true) && tag.equals("title", true) && locale.isNotEmpty() -> titles[locale] = text
|
||||
}
|
||||
}
|
||||
XmlPullParser.END_TAG -> {
|
||||
if (parser.name.equals("launcher", true) && path.isNotEmpty()) launchers += Launcher(path, params, trial)
|
||||
stack.removeLastOrNull()
|
||||
}
|
||||
}
|
||||
parser.next()
|
||||
}
|
||||
return EaManifest(version, launchers, touchupPath, touchupParams, contentIds, titles)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object EaPaths {
|
||||
private val KEY = Regex("^\\[([^\\]]+)\\](.*)$")
|
||||
|
||||
/** `[HKEY_LOCAL_MACHINE\SOFTWARE\Publisher\Game\Install Dir]bin\game.exe` splits into the registry key, value and relative path. */
|
||||
class Registered(val key: String, val value: String, val rest: String)
|
||||
|
||||
fun registered(path: String): Registered? {
|
||||
val m = KEY.find(path.trim()) ?: return null
|
||||
val full = m.groupValues[1]
|
||||
val cut = full.lastIndexOf('\\').takeIf { it > 0 } ?: return null
|
||||
return Registered(full.substring(0, cut), full.substring(cut + 1), m.groupValues[2])
|
||||
}
|
||||
|
||||
fun relative(path: String): String {
|
||||
val rest = registered(path)?.rest ?: path.replace("{installLocation}", "", ignoreCase = true)
|
||||
return rest.replace('\\', '/').trimStart('/').split('/').filter { it.isNotEmpty() && it != "." }.joinToString("/")
|
||||
}
|
||||
|
||||
/** The real file for a path the manifest may spell in a different case. */
|
||||
fun find(folder: File, relative: String): File? {
|
||||
var cur = folder
|
||||
for (part in relative.replace('\\', '/').split('/').filter { it.isNotEmpty() }) {
|
||||
val names = cur.listFiles() ?: return null
|
||||
cur = names.firstOrNull { it.name == part } ?: names.firstOrNull { it.name.equals(part, true) } ?: return null
|
||||
}
|
||||
return cur.takeIf { it.exists() && it != folder }
|
||||
}
|
||||
|
||||
fun relativeOnDisk(folder: File, relative: String): String? =
|
||||
find(folder, relative)?.relativeTo(folder)?.path?.replace(File.separatorChar, '/')
|
||||
|
||||
fun splitArgs(text: String): List<String> {
|
||||
val out = ArrayList<String>()
|
||||
val cur = StringBuilder()
|
||||
var quoted = false
|
||||
var any = false
|
||||
for (c in text) {
|
||||
when {
|
||||
c == '"' -> { quoted = !quoted; any = true }
|
||||
c.isWhitespace() && !quoted -> { if (any || cur.isNotEmpty()) out += cur.toString(); cur.clear(); any = false }
|
||||
else -> cur.append(c)
|
||||
}
|
||||
}
|
||||
if (any || cur.isNotEmpty()) out += cur.toString()
|
||||
return out
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.droiddeck.launcher.stores.ea
|
||||
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.stores.StoresNative
|
||||
import com.droiddeck.launcher.stores.amazon.AmazonNative
|
||||
import org.json.JSONObject
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
object EaNative {
|
||||
private const val TAG = "EaNative"
|
||||
const val ENGINE_MISSING = "EA: the store engine is not available"
|
||||
|
||||
fun ready(): Boolean = StoresNative.ensureLoaded()
|
||||
|
||||
fun call(op: String, request: JSONObject): JSONObject? {
|
||||
if (!ready()) return null
|
||||
val text = try { nativeCall(op, request.toString()) } catch (t: Throwable) {
|
||||
Log.w(TAG, "$op: ${t.javaClass.simpleName}")
|
||||
null
|
||||
} ?: return null
|
||||
return runCatching { JSONObject(text) }.getOrNull()
|
||||
}
|
||||
|
||||
fun install(request: JSONObject, events: AmazonNative.InstallListener, cancelled: AtomicBoolean): AmazonNative.InstallOutcome {
|
||||
if (!ready()) return AmazonNative.InstallOutcome(false, false, ENGINE_MISSING, null)
|
||||
val done = CountDownLatch(1)
|
||||
val holder = arrayOfNulls<AmazonNative.InstallOutcome>(1)
|
||||
val bridge = object : AmazonNative.InstallListener by events {
|
||||
override fun onComplete(success: Boolean, cancelled: Boolean, error: String, result: String) {
|
||||
holder[0] = AmazonNative.InstallOutcome(success, cancelled, error, result.takeIf { it.isNotEmpty() }?.let { runCatching { JSONObject(it) }.getOrNull() })
|
||||
done.countDown()
|
||||
}
|
||||
}
|
||||
val handle = try { nativeInstall(request.toString(), bridge) } catch (t: Throwable) {
|
||||
return AmazonNative.InstallOutcome(false, false, "The EA install could not start: ${t.javaClass.simpleName}", null)
|
||||
}
|
||||
if (handle == 0L) return holder[0] ?: AmazonNative.InstallOutcome(false, false, "The EA install could not start", null)
|
||||
try {
|
||||
var forwarded = false
|
||||
while (true) {
|
||||
val finished = try { done.await(250, TimeUnit.MILLISECONDS) } catch (e: InterruptedException) { cancelled.set(true); false }
|
||||
if (finished) break
|
||||
if (!forwarded && cancelled.get()) { nativeCancel(handle); forwarded = true }
|
||||
}
|
||||
} finally {
|
||||
nativeRelease(handle)
|
||||
}
|
||||
return holder[0] ?: AmazonNative.InstallOutcome(false, false, "The EA install ended without a result", null)
|
||||
}
|
||||
|
||||
@JvmStatic private external fun nativeCall(op: String, request: String): String?
|
||||
@JvmStatic private external fun nativeInstall(request: String, listener: AmazonNative.InstallListener): Long
|
||||
@JvmStatic private external fun nativeCancel(handle: Long)
|
||||
@JvmStatic private external fun nativeRelease(handle: Long)
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package com.droiddeck.launcher.stores.ea
|
||||
|
||||
import org.json.JSONObject
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import org.xmlpull.v1.XmlPullParserFactory
|
||||
import java.io.File
|
||||
import java.io.StringReader
|
||||
import java.net.URLEncoder
|
||||
import java.security.SecureRandom
|
||||
|
||||
/** Origin Online Activation: the per-machine licence an EA game's activation layer checks before it runs. */
|
||||
object EaOoa {
|
||||
private const val LICENSES = "https://proxy.novafusion.ea.com/licenses"
|
||||
const val LICENSE_DIR = "ProgramData/Electronic Arts/EA Services/License"
|
||||
private const val SIGNATURE_SLOT = 65
|
||||
|
||||
/**
|
||||
* What the game sees of the machine inside its Wine prefix: firmware and OS values from Wine's
|
||||
* WMI, CPU values from CPUID executed as x86 code, since an emulated game sees the emulator's
|
||||
* CPUID rather than the host CPU WMI describes.
|
||||
*/
|
||||
class Hardware(
|
||||
val boardManufacturer: String,
|
||||
val boardSerial: String,
|
||||
val biosManufacturer: String,
|
||||
val biosSerial: String,
|
||||
val osInstallDate: String,
|
||||
val osSerial: String,
|
||||
val volumeSerial: String,
|
||||
val gpuPnpId: String,
|
||||
val cpuVendor: String,
|
||||
val cpuEdx: String,
|
||||
val cpuEax: String,
|
||||
val cpuEcx: String,
|
||||
val brand: String,
|
||||
val hostname: String,
|
||||
) {
|
||||
/** Whether the probe reported everything the hash of this activation version is built from. */
|
||||
fun complete(version: Int): Boolean {
|
||||
val needed = arrayListOf(boardManufacturer, biosManufacturer)
|
||||
when {
|
||||
version <= 1 -> needed += listOf(hostname, osInstallDate, osSerial)
|
||||
version == 2 -> needed += listOf(osInstallDate, osSerial, volumeSerial, cpuVendor, cpuEdx, cpuEcx)
|
||||
version == 3 -> needed += listOf(volumeSerial, cpuVendor, cpuEdx, cpuEcx)
|
||||
else -> needed += listOf(volumeSerial, cpuVendor, cpuEdx, cpuEax)
|
||||
}
|
||||
return needed.all { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromJson(o: JSONObject): Hardware {
|
||||
fun s(k: String) = o.optString(k).trim()
|
||||
return Hardware(
|
||||
boardManufacturer = s("boardManufacturer"),
|
||||
boardSerial = s("boardSerial").ifEmpty { "None" },
|
||||
biosManufacturer = s("biosManufacturer"),
|
||||
biosSerial = s("biosSerial").ifEmpty { "None" },
|
||||
osInstallDate = s("osInstallDate"),
|
||||
osSerial = s("osSerial"),
|
||||
volumeSerial = s("volumeSerial").replace("-", "").lowercase().let { if (it.isEmpty()) it else it.padStart(8, '0') },
|
||||
gpuPnpId = s("gpuPnpId").ifEmpty { "None" },
|
||||
cpuVendor = s("cpuVendor"),
|
||||
cpuEdx = s("cpuEdx").uppercase(),
|
||||
cpuEax = s("cpuEax").uppercase(),
|
||||
cpuEcx = s("cpuEcx").lowercase(),
|
||||
brand = o.optString("cpuBrand"),
|
||||
hostname = s("hostname"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun machineHash(hw: Hardware, version: Int): String {
|
||||
val edxEax = hw.cpuEdx + hw.cpuEax
|
||||
val edx = hw.cpuEdx.lowercase()
|
||||
val shortGpu = hw.gpuPnpId.substringBeforeLast('\\', hw.gpuPnpId)
|
||||
val parts = arrayListOf(hw.boardManufacturer, hw.boardSerial)
|
||||
when {
|
||||
version <= 1 -> parts += listOf(hw.hostname, hw.biosManufacturer, hw.biosSerial, hw.osInstallDate, hw.osSerial)
|
||||
version == 2 -> parts += listOf(hw.biosManufacturer, hw.biosSerial, hw.osInstallDate, hw.osSerial, hw.volumeSerial, shortGpu, hw.cpuVendor, edx, hw.cpuEcx)
|
||||
version == 3 -> parts += listOf(hw.biosManufacturer, hw.biosSerial, hw.volumeSerial, shortGpu, hw.cpuVendor, edx, hw.cpuEcx)
|
||||
else -> parts += listOf(hw.biosManufacturer, hw.biosSerial, hw.volumeSerial, hw.gpuPnpId, hw.cpuVendor, edxEax)
|
||||
}
|
||||
val text = StringBuilder(parts.joinToString(";")).append(';')
|
||||
if (version >= 2) text.append(hw.brand.take(47).padEnd(47, '\u0000')).append(';')
|
||||
val digest = EaCrypto.sha1(text.toString().toByteArray(Charsets.ISO_8859_1))
|
||||
return if (version < 4) digest.joinToString("") { Integer.toHexString(it.toInt() and 0xFF) } else EaCrypto.hex(digest)
|
||||
}
|
||||
|
||||
/** Version of the game's activation layer, which decides how the machine hash is built. */
|
||||
fun activationVersion(core: File): Int {
|
||||
val dll = listOf("activation.dll", "awc.dll").firstNotNullOfOrNull { EaPaths.find(core, it) } ?: return 1
|
||||
val (major, minor, patch) = productVersion(dll.readBytes()) ?: return 1
|
||||
return when {
|
||||
major >= 5 -> 4
|
||||
major == 4 && (minor > 7 || (minor == 7 && patch >= 6)) -> 3
|
||||
major == 4 && minor == 7 -> 2
|
||||
major == 4 -> 1
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
|
||||
/** The activation layer's folder: `Core` beside the game's exe, where EA ships it, or at the game root. */
|
||||
fun coreDir(game: File, exe: String): File? {
|
||||
val exeDir = exe.replace('\\', '/').substringBeforeLast('/', "")
|
||||
val candidates = listOfNotNull(exeDir.takeIf { it.isNotEmpty() }?.let { "$it/Core" }, "Core")
|
||||
return candidates.firstNotNullOfOrNull { EaPaths.find(game, it)?.takeIf { f -> f.isDirectory } }
|
||||
}
|
||||
|
||||
/** Games built on the 2012-era EA Core client, which want an opaque token rather than a JWS one. */
|
||||
fun legacyCore(core: File?): Boolean = core != null && EaPaths.find(core, "EACore.dll") != null
|
||||
|
||||
private fun encodedSignature(core: File): Boolean = EaPaths.find(core, "activation.exe") != null
|
||||
|
||||
internal fun productVersion(pe: ByteArray): Triple<Int, Int, Int>? {
|
||||
val magic = byteArrayOf(0xBD.toByte(), 0x04, 0xEF.toByte(), 0xFE.toByte())
|
||||
var i = 0
|
||||
while (i + 24 <= pe.size) {
|
||||
if (pe[i] == magic[0] && pe[i + 1] == magic[1] && pe[i + 2] == magic[2] && pe[i + 3] == magic[3]) {
|
||||
fun u32(at: Int) = (pe[at].toLong() and 0xFF) or ((pe[at + 1].toLong() and 0xFF) shl 8) or ((pe[at + 2].toLong() and 0xFF) shl 16) or ((pe[at + 3].toLong() and 0xFF) shl 24)
|
||||
val ms = u32(i + 16)
|
||||
val ls = u32(i + 20)
|
||||
return Triple((ms ushr 16).toInt(), (ms and 0xFFFF).toInt(), (ls ushr 16).toInt())
|
||||
}
|
||||
i++
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
class License(val fields: LinkedHashMap<String, String>, val namespace: String, val signature: String) {
|
||||
val gameToken: String? get() = fields["GameToken"]?.takeIf { it.isNotEmpty() }
|
||||
val contentId: String get() = fields["ContentId"].orEmpty()
|
||||
}
|
||||
|
||||
internal fun parseLicense(xml: String, signature: String): License {
|
||||
val parser = XmlPullParserFactory.newInstance().apply { isNamespaceAware = false }.newPullParser()
|
||||
parser.setInput(StringReader(xml.replace(Regex("^<\\?xml[^>]*\\?>"), "").trim()))
|
||||
val fields = LinkedHashMap<String, String>()
|
||||
var ns = ""
|
||||
var depth = 0
|
||||
var tag = ""
|
||||
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
|
||||
when (parser.eventType) {
|
||||
XmlPullParser.START_TAG -> {
|
||||
depth++
|
||||
if (depth == 1) ns = parser.getAttributeValue(null, "xmlns").orEmpty() else tag = parser.name
|
||||
if (depth == 2) fields[tag] = ""
|
||||
}
|
||||
XmlPullParser.TEXT -> if (depth == 2) fields[tag] = fields[tag].orEmpty() + parser.text
|
||||
XmlPullParser.END_TAG -> depth--
|
||||
}
|
||||
parser.next()
|
||||
}
|
||||
return License(fields, ns, signature)
|
||||
}
|
||||
|
||||
private fun esc(s: String) = s.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """)
|
||||
|
||||
fun licenseXml(l: License): String {
|
||||
val order = listOf("CipherKey", "MachineHash", "ContentId", "UserId", "GameToken", "GrantTime", "StartTime", "Nonce")
|
||||
val body = StringBuilder("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>")
|
||||
body.append("<License xmlns=\"").append(esc(l.namespace)).append("\">")
|
||||
for (k in order) {
|
||||
val v = l.fields[k] ?: continue
|
||||
if (k == "GameToken" && v.isEmpty()) continue
|
||||
body.append('<').append(k).append('>').append(esc(v)).append("</").append(k).append('>')
|
||||
}
|
||||
return body.append("</License>").toString()
|
||||
}
|
||||
|
||||
fun licenseBlob(l: License, encoded: Boolean): ByteArray {
|
||||
val signature = if (encoded) l.signature.toByteArray() else java.util.Base64.getDecoder().decode(l.signature)
|
||||
val head = ByteArray(SIGNATURE_SLOT)
|
||||
System.arraycopy(signature, 0, head, 0, signature.size.coerceAtMost(SIGNATURE_SLOT))
|
||||
return head + EaCrypto.licenseEncrypt(licenseXml(l).toByteArray())
|
||||
}
|
||||
|
||||
fun request(token: String, contentId: String, machineHash: String, requestToken: String? = null, requestType: String? = null): License {
|
||||
val nonce = EaCrypto.hex(ByteArray(4).also(SecureRandom()::nextBytes))
|
||||
val params = mutableListOf("contentId" to contentId, "machineHash" to machineHash, "nonce" to nonce, "ea_eadmtoken" to token)
|
||||
if (requestToken != null) params += "requestToken" to requestToken
|
||||
if (requestType != null) params += "requestType" to requestType
|
||||
val url = LICENSES + "?" + params.joinToString("&") { (k, v) -> k + "=" + URLEncoder.encode(v, "UTF-8") }
|
||||
val reply = EaHttp.request(url, headers = mapOf("X-Requester-Id" to "Origin Online Activation", "User-Agent" to "EACTransaction"))
|
||||
if (!reply.ok) throw IllegalStateException("EA refused the licence (${reply.status}): ${reply.text.replace(Regex("(eadmToken value=\")[^\"]*"), "$1<redacted>\"").take(600)}")
|
||||
val signature = reply.header("x-signature") ?: throw IllegalStateException("EA's licence had no signature")
|
||||
return parseLicense(String(EaCrypto.licenseDecrypt(reply.body), Charsets.UTF_8), signature)
|
||||
}
|
||||
|
||||
/** Licence files for a game about to start, named as the activation layer looks for them. */
|
||||
fun files(core: File, license: License): List<Pair<String, ByteArray>> {
|
||||
val blob = licenseBlob(license, encodedSignature(core))
|
||||
val id = license.contentId
|
||||
return listOf("$LICENSE_DIR/$id.dlf" to blob, "$LICENSE_DIR/${id}_cached.dlf" to blob)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.stores.InstalledStoreGame
|
||||
import com.droiddeck.launcher.stores.ea.EaUpdates
|
||||
|
||||
@Composable
|
||||
internal fun EaUpdateButton(installed: InstalledStoreGame, enabled: Boolean) {
|
||||
val ctx = LocalContext.current
|
||||
LaunchedEffect(installed.key, installed.sidecar.installVersion) { EaUpdates.check(ctx, installed) }
|
||||
if (EaUpdates.available[installed.key] == null) return
|
||||
SecondaryButton(stringResource(R.string.common_update), enabled = enabled) { EaUpdates.start(ctx, installed) }
|
||||
}
|
||||
@@ -430,6 +430,7 @@ internal fun GameActions(
|
||||
openUrl(ctx, item?.storeUrl.orEmpty().ifBlank { GameJoltApi.pageUrl("game", installed.sidecar.id) })
|
||||
}
|
||||
}
|
||||
if (store == Store.EA && installed != null) EaUpdateButton(installed, download == null && StoresState.uninstalling[installed.key] == null)
|
||||
if (store == Store.GOG && installed != null) GogMaintenanceActions(installed)
|
||||
if (store == Store.XBOX && id != null) XboxInstalledStream(id, game?.name ?: item?.title.orEmpty(), item)
|
||||
if (store == Store.XBOX && installed != null && id != null) {
|
||||
@@ -915,7 +916,7 @@ private fun AddonRow(name: String, target: WowAddons.Target, a: FrontEndActions)
|
||||
|
||||
@Composable
|
||||
private fun CloudRows(g: Library.SteamGame) {
|
||||
val store = Store.byId(g.source)?.takeIf { it != Store.AMAZON } ?: return
|
||||
val store = Store.byId(g.source)?.takeIf { it != Store.AMAZON && it != Store.EA } ?: return
|
||||
val id = g.storeId ?: return
|
||||
val context = LocalContext.current.applicationContext
|
||||
var busy by remember(g.gameId) { mutableStateOf<String?>(null) }
|
||||
|
||||
@@ -742,7 +742,7 @@ internal fun LibraryMarker(e: LibraryEntry, download: DownloadEntry?, showDownlo
|
||||
}
|
||||
}
|
||||
|
||||
private fun libraryUpdateAvailable(e: LibraryEntry): Boolean = e.storeKey?.let { it in ItchUpdates.available || it in GameJoltUpdates.available } == true ||
|
||||
private fun libraryUpdateAvailable(e: LibraryEntry): Boolean = e.storeKey?.let { it in ItchUpdates.available || it in GameJoltUpdates.available || it in com.droiddeck.launcher.stores.ea.EaUpdates.available } == true ||
|
||||
e.source == Store.GOG.id && (e.item?.id ?: e.game?.storeId)?.let { GogMaintenance.checks[it]?.state } == GogMaintenance.State.AVAILABLE
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -33,6 +33,7 @@ internal fun sourceColours(source: String): SourceColours = when (source) {
|
||||
Store.ITCH.id -> SourceColours(Color(0xFF3A1A1E), Color(0xFFFFB3B3), Color(0xFFFF5C5C))
|
||||
Store.GAMEJOLT.id -> SourceColours(Color(0xFF232E00), Color(0xFFE3FF66), Color(0xFFCCFF00))
|
||||
Store.BATTLENET.id -> SourceColours(Color(0xFF0A2440), Color(0xFF9DD1FF), Color(0xFF148EFF))
|
||||
Store.EA.id -> SourceColours(Color(0xFF3A0E12), Color(0xFFFF9A9A), Color(0xFFFF4747))
|
||||
Library.SOURCE_STEAM -> SourceColours(Color(0xFF16293D), Color(0xFF7CC4FF), Color(0xFF1A9FFF))
|
||||
FLATHUB -> SourceColours(Color(0xFF0B2E2B), Color(0xFF9FE3DD), FlathubTeal)
|
||||
else -> SourceColours(Color(0xFF1A1D22), Color(0xFF9AA3AF), Color(0xFF9AA3AF))
|
||||
@@ -48,6 +49,7 @@ internal fun sourceLabel(source: String): String = when (source) {
|
||||
Store.ITCH.id -> Store.ITCH.label
|
||||
Store.GAMEJOLT.id -> Store.GAMEJOLT.label
|
||||
Store.BATTLENET.id -> Store.BATTLENET.label
|
||||
Store.EA.id -> Store.EA.label
|
||||
else -> stringResource(R.string.source_added)
|
||||
}
|
||||
|
||||
|
||||
@@ -1157,6 +1157,7 @@ internal fun storePalette(base: Palette, store: Store?): Palette {
|
||||
Store.ITCH -> Color(0xFFFF5C5C) to Color(0xFFD93B3B)
|
||||
Store.GAMEJOLT -> Color(0xFFCCFF00) to Color(0xFFA3CC00)
|
||||
Store.BATTLENET -> Color(0xFF148EFF) to Color(0xFF0B6BD3)
|
||||
Store.EA -> Color(0xFFFF4747) to Color(0xFFE03030)
|
||||
null -> return base
|
||||
}
|
||||
val on = if (accent.luminance() > 0.18f) Color(0xFF0B0B0D) else Color.White
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
query GameImages($gameSlug: String!, $locale: Locale!) {
|
||||
game(slug: $gameSlug, locale: $locale) {
|
||||
id
|
||||
slug
|
||||
title
|
||||
keyArt {
|
||||
...imageRenditionData
|
||||
__typename
|
||||
}
|
||||
packArt {
|
||||
...PackArt
|
||||
__typename
|
||||
}
|
||||
...PrimaryLogo
|
||||
__typename
|
||||
}
|
||||
}
|
||||
|
||||
fragment imageRenditionData on ImageRendition {
|
||||
path
|
||||
aspect9x16Image {
|
||||
path
|
||||
__typename
|
||||
}
|
||||
aspect16x9Image {
|
||||
path
|
||||
__typename
|
||||
}
|
||||
aspect1x1Image {
|
||||
path
|
||||
__typename
|
||||
}
|
||||
largestImage {
|
||||
path
|
||||
__typename
|
||||
}
|
||||
rawImages {
|
||||
path
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
|
||||
fragment PrimaryLogo on GameV2 {
|
||||
primaryLogo(uiClientContexts: ["juno-web-game-tile"]) {
|
||||
path
|
||||
title
|
||||
largestImage {
|
||||
path
|
||||
width
|
||||
height
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
|
||||
fragment PackArt on ImageRendition {
|
||||
aspect2x1Image {
|
||||
path
|
||||
__typename
|
||||
}
|
||||
aspect9x16Image {
|
||||
path
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
query GetUserPlayer {
|
||||
me {
|
||||
id: pd
|
||||
pd
|
||||
player {
|
||||
...Player
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
}
|
||||
|
||||
fragment Player on Player {
|
||||
id: pd
|
||||
pd
|
||||
psd
|
||||
displayName
|
||||
uniqueName
|
||||
nickname
|
||||
avatar {
|
||||
...Avatar
|
||||
__typename
|
||||
}
|
||||
relationship
|
||||
__typename
|
||||
}
|
||||
|
||||
fragment Avatar on AvatarList {
|
||||
large {
|
||||
...image
|
||||
__typename
|
||||
}
|
||||
medium {
|
||||
...image
|
||||
__typename
|
||||
}
|
||||
small {
|
||||
...image
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
|
||||
fragment image on Image {
|
||||
height
|
||||
width
|
||||
path
|
||||
__typename
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
query availableBuilds($offerId: String!) {
|
||||
availableBuilds(offerId: $offerId) {
|
||||
buildId
|
||||
downloadType
|
||||
gameVersion
|
||||
buildLiveDate
|
||||
__typename
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
query downloadUrl($offerId: String!, $buildId: String) {
|
||||
downloadUrl(offerId: $offerId, buildId: $buildId) {
|
||||
url
|
||||
syncUrl
|
||||
__typename
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
query getLegacyCatalogDefs($offerIds: [String!]!, $locale: Locale) {
|
||||
legacyOffers(offerIds: $offerIds, locale: $locale) {
|
||||
offerId: id
|
||||
contentId
|
||||
basePlatform
|
||||
primaryMasterTitleId
|
||||
mdmProjectNumber
|
||||
achievementSetOverride
|
||||
gameLauncherURL
|
||||
gameLauncherURLClientID
|
||||
stagingKeyPath
|
||||
mdmTitleIds
|
||||
multiplayerId
|
||||
executePathOverride
|
||||
installationDirectory
|
||||
installCheckOverride
|
||||
monitorPlay
|
||||
displayName
|
||||
displayType
|
||||
igoBrowserDefaultUrl
|
||||
executeParameters
|
||||
softwareLocales
|
||||
dipManifestRelativePath
|
||||
metadataInstallLocation
|
||||
distributionSubType
|
||||
downloads {
|
||||
igoApiEnabled
|
||||
downloadType
|
||||
version
|
||||
executeElevated
|
||||
buildReleaseVersion
|
||||
buildLiveDate
|
||||
buildMetaData
|
||||
gameVersion
|
||||
treatUpdatesAsMandatory
|
||||
enableDifferentialUpdate
|
||||
__typename
|
||||
}
|
||||
locale
|
||||
greyMarketControls
|
||||
isDownloadable
|
||||
isPreviewDownload
|
||||
downloadStartDate
|
||||
releaseDate
|
||||
useEndDate
|
||||
subscriptionUnlockDate
|
||||
subscriptionUseEndDate
|
||||
softwarePlatform
|
||||
softwareId
|
||||
downloadPackageType
|
||||
installerPath
|
||||
processorArchitecture
|
||||
macBundleID
|
||||
gameEditionTypeFacetKeyRankDesc
|
||||
appliedCountryCode
|
||||
cloudSaveConfigurationOverride
|
||||
firstParties{
|
||||
partner
|
||||
partnerId
|
||||
partnerIdType
|
||||
__typename
|
||||
}
|
||||
suppressedOfferIds
|
||||
__typename
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
query getLegacyEntitlements($limit: Int, $next: String, $storeFronts: [UserLegacyEntitlementStorefront!]!) {
|
||||
me {
|
||||
legacyEntitlements(
|
||||
paging: {limit: $limit, next: $next},
|
||||
storefronts: $storeFronts,
|
||||
status: [ACTIVE, DISABLED]
|
||||
) {
|
||||
next
|
||||
items {
|
||||
cdKey
|
||||
entitlementTag
|
||||
entitlementType
|
||||
externalType
|
||||
grantDate
|
||||
groupName
|
||||
id
|
||||
isSubscription
|
||||
isSubscriptionExpired
|
||||
lastModifiedDate
|
||||
originPermissions
|
||||
productId
|
||||
status
|
||||
statusReasonCode
|
||||
terminationDate
|
||||
trialTimeRemainingSeconds
|
||||
useCount
|
||||
version
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
query getPreloadedOwnedGames($next: String, $locale: Locale, $limit: Int, $type: [GameProductType!]!, $entitlementEnabled: Boolean, $isMac: Boolean = false, $storefronts: [UserGameProductStorefront!], $ownershipMethods: [OwnershipMethod!], $processorArchitectures: [ProcessorArchitecture!], $platforms: [GamePlatform!]!) {
|
||||
me {
|
||||
id: pd
|
||||
ownedGameProducts(storefronts: $storefronts, locale: $locale, paging: {limit: $limit, next: $next}, productFound: true, orderBy: {field: NAME, direction: ASC}, ownershipMethod: $ownershipMethods, processorArchitectures: $processorArchitectures, type: $type, downloadableOnly: false, entitlementEnabled: $entitlementEnabled, platforms: $platforms) {
|
||||
next
|
||||
totalCount
|
||||
items {
|
||||
...userGameProductFragment
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
}
|
||||
|
||||
fragment userGameProductFragment on UserGameProduct {
|
||||
id: originOfferId
|
||||
originOfferId
|
||||
status
|
||||
product {
|
||||
...OwnedGameProductFragment
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
|
||||
fragment OwnedGameProductFragment on GameProduct {
|
||||
id
|
||||
name
|
||||
downloadable
|
||||
gameSlug
|
||||
trialDetails {
|
||||
trialType
|
||||
__typename
|
||||
}
|
||||
baseItem(availabilities: [VISIBLE]) {
|
||||
title
|
||||
id
|
||||
baseGameSlug
|
||||
gameType
|
||||
__typename
|
||||
}
|
||||
gamePlatformDetails @include(if: $isMac) {
|
||||
gamePlatform
|
||||
__typename
|
||||
}
|
||||
processorArchitectureDetails @include(if: $isMac) {
|
||||
processorArchitecture
|
||||
platform
|
||||
__typename
|
||||
}
|
||||
gameProductUser(storefronts: $storefronts) {
|
||||
ownershipMethods
|
||||
initialEntitlementDate
|
||||
entitlementId
|
||||
gameProductUserTrial {
|
||||
trialTimeRemainingSeconds
|
||||
__typename
|
||||
}
|
||||
status
|
||||
__typename
|
||||
}
|
||||
purchaseStatus {
|
||||
repurchasable
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
query sdkEntitlements($productIds: [String!]!, $includeChildGroups: Boolean!, $groupNames: [String!]!, $entitlementTag: String!, $pageSize: Int!, $pageNumber: Int!) {
|
||||
me {
|
||||
sdkEntitlements(
|
||||
productIds: $productIds,
|
||||
includeChildGroups: $includeChildGroups,
|
||||
groupNames: $groupNames,
|
||||
entitlementTag: $entitlementTag,
|
||||
paging: {
|
||||
pageSize: $pageSize, pageNumber: $pageNumber
|
||||
}
|
||||
) {
|
||||
entitlements {
|
||||
entitlementTag
|
||||
entitlementType
|
||||
grantDate
|
||||
groupName
|
||||
id
|
||||
productId
|
||||
terminationDate
|
||||
useCount
|
||||
version
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
use std::panic::{catch_unwind, AssertUnwindSafe};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use jni::objects::{GlobalRef, JClass, JObject, JString, JValue};
|
||||
use jni::sys::{jlong, jstring, JNI_FALSE, JNI_TRUE};
|
||||
use jni::{JNIEnv, JavaVM};
|
||||
use serde_json::json;
|
||||
|
||||
use super::install;
|
||||
use crate::amazon::install::{Events, Stop};
|
||||
|
||||
fn read_string(env: &mut JNIEnv, value: &JString) -> String {
|
||||
if value.is_null() {
|
||||
return String::new();
|
||||
}
|
||||
env.get_string(value).map(|s| s.to_string_lossy().into_owned()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn new_string(env: &mut JNIEnv, text: &str) -> jstring {
|
||||
env.new_string(text).map(|s| s.into_raw()).unwrap_or(std::ptr::null_mut())
|
||||
}
|
||||
|
||||
fn call(op: &str, request: &str) -> String {
|
||||
let parsed: serde_json::Value = serde_json::from_str(request).unwrap_or(serde_json::Value::Null);
|
||||
let url = parsed.get("url").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let answer = match op {
|
||||
"zip.size" => install::size(url).unwrap_or_else(|e| json!({ "error": e })),
|
||||
_ => json!({ "error": format!("unknown operation {op}") }),
|
||||
};
|
||||
answer.to_string()
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_droiddeck_launcher_stores_ea_EaNative_nativeCall(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
op: JString,
|
||||
request: JString,
|
||||
) -> jstring {
|
||||
let op = read_string(&mut env, &op);
|
||||
let request = read_string(&mut env, &request);
|
||||
match catch_unwind(AssertUnwindSafe(|| call(&op, &request))) {
|
||||
Ok(text) => new_string(&mut env, &text),
|
||||
Err(_) => std::ptr::null_mut(),
|
||||
}
|
||||
}
|
||||
|
||||
struct Listener {
|
||||
vm: JavaVM,
|
||||
target: GlobalRef,
|
||||
}
|
||||
|
||||
impl Listener {
|
||||
fn with_env<R>(&self, fallback: R, f: impl FnOnce(&mut JNIEnv) -> R) -> R {
|
||||
let Ok(mut env) = self.vm.attach_current_thread_as_daemon() else {
|
||||
return fallback;
|
||||
};
|
||||
let out = f(&mut env);
|
||||
if env.exception_check().unwrap_or(false) {
|
||||
let _ = env.exception_clear();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn text(&self, method: &str, text: &str) {
|
||||
self.with_env((), |env| {
|
||||
let Ok(s) = env.new_string(text) else { return };
|
||||
let s = JObject::from(s);
|
||||
let _ = env.call_method(self.target.as_obj(), method, "(Ljava/lang/String;)V", &[JValue::Object(&s)]);
|
||||
let _ = env.delete_local_ref(s);
|
||||
});
|
||||
}
|
||||
|
||||
fn counts(&self, method: &str, a: u64, b: u64, c: u32, d: u32) {
|
||||
self.with_env((), |env| {
|
||||
let _ = env.call_method(
|
||||
self.target.as_obj(),
|
||||
method,
|
||||
"(JJII)V",
|
||||
&[
|
||||
JValue::Long(a.min(i64::MAX as u64) as i64),
|
||||
JValue::Long(b.min(i64::MAX as u64) as i64),
|
||||
JValue::Int(c.min(i32::MAX as u32) as i32),
|
||||
JValue::Int(d.min(i32::MAX as u32) as i32),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn complete(&self, success: bool, cancelled: bool, error: &str, result: &str) {
|
||||
self.with_env((), |env| {
|
||||
let (Ok(e), Ok(r)) = (env.new_string(error), env.new_string(result)) else { return };
|
||||
let (e, r) = (JObject::from(e), JObject::from(r));
|
||||
let _ = env.call_method(
|
||||
self.target.as_obj(),
|
||||
"onComplete",
|
||||
"(ZZLjava/lang/String;Ljava/lang/String;)V",
|
||||
&[
|
||||
JValue::Bool(if success { JNI_TRUE } else { JNI_FALSE }),
|
||||
JValue::Bool(if cancelled { JNI_TRUE } else { JNI_FALSE }),
|
||||
JValue::Object(&e),
|
||||
JValue::Object(&r),
|
||||
],
|
||||
);
|
||||
let _ = env.delete_local_ref(e);
|
||||
let _ = env.delete_local_ref(r);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Events for Listener {
|
||||
fn log(&self, line: &str) {
|
||||
self.text("onLog", &crate::amazon::net::scrub_urls(line));
|
||||
}
|
||||
|
||||
fn status(&self, text: &str) {
|
||||
self.text("onStatus", text);
|
||||
}
|
||||
|
||||
fn checking(&self, done_bytes: u64, total_bytes: u64, files: u32, files_total: u32) {
|
||||
self.counts("onCheck", done_bytes, total_bytes, files, files_total);
|
||||
}
|
||||
|
||||
fn sizes(&self, pending_bytes: u64, total_bytes: u64) {
|
||||
self.with_env((), |env| {
|
||||
let _ = env.call_method(
|
||||
self.target.as_obj(),
|
||||
"onSizes",
|
||||
"(JJ)V",
|
||||
&[
|
||||
JValue::Long(pending_bytes.min(i64::MAX as u64) as i64),
|
||||
JValue::Long(total_bytes.min(i64::MAX as u64) as i64),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn progress(&self, done: u64, total: u64, files: u32, files_total: u32) {
|
||||
self.counts("onProgress", done, total, files, files_total);
|
||||
}
|
||||
|
||||
fn before_replace(&self) -> bool {
|
||||
self.with_env(false, |env| match env.call_method(self.target.as_obj(), "beforeReplace", "()Z", &[]) {
|
||||
Ok(v) => v.z().unwrap_or(false),
|
||||
Err(_) => false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_droiddeck_launcher_stores_ea_EaNative_nativeInstall(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
request: JString,
|
||||
listener: JObject,
|
||||
) -> jlong {
|
||||
if listener.is_null() {
|
||||
return 0;
|
||||
}
|
||||
let Ok(vm) = env.get_java_vm() else { return 0 };
|
||||
let Ok(target) = env.new_global_ref(&listener) else { return 0 };
|
||||
let listener = Listener { vm, target };
|
||||
let text = read_string(&mut env, &request);
|
||||
let parsed = serde_json::from_str::<serde_json::Value>(&text)
|
||||
.map_err(|_| "The install request could not be read".to_string())
|
||||
.and_then(|v| install::Request::from_json(&v));
|
||||
let request = match parsed {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
listener.complete(false, false, &e, "");
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
let cancel = Arc::new(AtomicBool::new(false));
|
||||
let worker_cancel = Arc::clone(&cancel);
|
||||
let spawned = std::thread::Builder::new().name("ea-install".into()).spawn(move || {
|
||||
crate::priority::background();
|
||||
let result = catch_unwind(AssertUnwindSafe(|| install::run(&request, &worker_cancel, &listener)))
|
||||
.unwrap_or_else(|_| Err(Stop::Failed("The EA install stopped on an internal error".into())));
|
||||
match result {
|
||||
Ok(done) => listener.complete(true, false, "", &done.to_json().to_string()),
|
||||
Err(Stop::Cancelled) => listener.complete(false, true, "cancelled", ""),
|
||||
Err(Stop::Failed(e)) => listener.complete(false, false, &e, ""),
|
||||
}
|
||||
});
|
||||
if spawned.is_err() {
|
||||
return 0;
|
||||
}
|
||||
Box::into_raw(Box::new(cancel)) as jlong
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_droiddeck_launcher_stores_ea_EaNative_nativeCancel(_env: JNIEnv, _class: JClass, handle: jlong) {
|
||||
let ptr = handle as *const Arc<AtomicBool>;
|
||||
if let Some(cancel) = unsafe { ptr.as_ref() } {
|
||||
cancel.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_droiddeck_launcher_stores_ea_EaNative_nativeRelease(_env: JNIEnv, _class: JClass, handle: jlong) {
|
||||
let ptr = handle as *mut Arc<AtomicBool>;
|
||||
if !ptr.is_null() {
|
||||
drop(unsafe { Box::from_raw(ptr) });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,850 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//! Installs or updates an EA game from its build archive: files whose size and CRC already match
|
||||
//! are kept, everything else is fetched entry by entry with ranged reads and inflated in place.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::fs::FileExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant, UNIX_EPOCH};
|
||||
|
||||
use flate2::{Crc, Decompress, FlushDecompress, Status};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::net;
|
||||
use super::zip::{self, Entry};
|
||||
use crate::amazon::install::check::{free_bytes, space_problem};
|
||||
use crate::amazon::install::{Events, ReplaceGate, Stop};
|
||||
use crate::fetch_core::{distinct_host_count, run_fetch, FetchItem, FetchOptions, FetchSink, SinkError};
|
||||
|
||||
pub const LEDGER: &str = ".droiddeck-ea-files";
|
||||
pub const IN_PROGRESS_MARKER: &str = ".droiddeck-ea-downloading";
|
||||
const TEMP_SUFFIX: &str = ".ddpart";
|
||||
const PROGRESS_EVERY_MS: u64 = 50;
|
||||
const CHECK_REPORT_EVERY: Duration = Duration::from_millis(200);
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
const INFLATE_BUFFER: usize = 256 * 1024;
|
||||
/// Records larger than this are fetched as parallel ranges into a scratch file, then inflated,
|
||||
/// so one big file does not ride a single connection.
|
||||
pub const PART_BYTES: u64 = 16 * 1024 * 1024;
|
||||
const SPLIT_SUFFIX: &str = ".ddzip";
|
||||
|
||||
struct Split {
|
||||
tmp: PathBuf,
|
||||
file: Mutex<Option<File>>,
|
||||
received: Vec<AtomicU64>,
|
||||
left: AtomicUsize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Work {
|
||||
Whole(usize),
|
||||
Part { job: usize, part: usize, start: u64, len: u64 },
|
||||
}
|
||||
|
||||
/// The fetches for each job: whole records, or byte ranges of one when it is large.
|
||||
fn works(jobs: &[&Planned], part_bytes: u64) -> Vec<Work> {
|
||||
let mut out = Vec::new();
|
||||
for (i, p) in jobs.iter().enumerate() {
|
||||
let span = p.range_end - p.entry.local_offset + 1;
|
||||
if span <= part_bytes * 2 {
|
||||
out.push(Work::Whole(i));
|
||||
continue;
|
||||
}
|
||||
let mut start = 0;
|
||||
let mut part = 0;
|
||||
while start < span {
|
||||
let len = part_bytes.min(span - start);
|
||||
out.push(Work::Part { job: i, part, start, len });
|
||||
start += len;
|
||||
part += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
pub const REPLACE_REFUSED: &str = "The install record could not be switched to updating; nothing was replaced";
|
||||
|
||||
pub struct Request {
|
||||
pub url: String,
|
||||
pub game_dir: PathBuf,
|
||||
pub max_workers: usize,
|
||||
pub process_workers: usize,
|
||||
pub ca_bundle: String,
|
||||
pub part_bytes: u64,
|
||||
}
|
||||
|
||||
impl Request {
|
||||
pub fn from_json(v: &Value) -> Result<Request, String> {
|
||||
let text = |k: &str| v.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string();
|
||||
let game_dir = text("gameDir");
|
||||
if game_dir.is_empty() || !game_dir.starts_with('/') {
|
||||
return Err("No install folder was given".into());
|
||||
}
|
||||
let url = text("url");
|
||||
if !url.starts_with("https://") && !url.starts_with("http://") {
|
||||
return Err("EA gave no download for this game".into());
|
||||
}
|
||||
Ok(Request {
|
||||
url,
|
||||
game_dir: PathBuf::from(game_dir),
|
||||
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: text("caBundle"),
|
||||
part_bytes: v.get("partBytes").and_then(|n| n.as_u64()).filter(|n| *n > 0).unwrap_or(PART_BYTES),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Finished {
|
||||
pub total_bytes: u64,
|
||||
pub download_bytes: u64,
|
||||
pub file_count: usize,
|
||||
pub files_written: usize,
|
||||
pub bytes_written: u64,
|
||||
pub removed: usize,
|
||||
}
|
||||
|
||||
impl Finished {
|
||||
pub fn to_json(&self) -> Value {
|
||||
json!({
|
||||
"totalBytes": self.total_bytes,
|
||||
"downloadBytes": self.download_bytes,
|
||||
"fileCount": self.file_count,
|
||||
"filesWritten": self.files_written,
|
||||
"bytesWritten": self.bytes_written,
|
||||
"removed": self.removed,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct Known {
|
||||
crc: u32,
|
||||
size: u64,
|
||||
modified: u64,
|
||||
}
|
||||
|
||||
fn modified_ms(path: &Path) -> u64 {
|
||||
fs::metadata(path)
|
||||
.and_then(|m| m.modified())
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn read_ledger(dir: &Path) -> HashMap<String, Known> {
|
||||
let Ok(text) = fs::read_to_string(dir.join(LEDGER)) else { return HashMap::new() };
|
||||
let mut out = HashMap::new();
|
||||
for line in text.lines() {
|
||||
let mut parts = line.splitn(4, '\t');
|
||||
let (Some(crc), Some(size), Some(modified), Some(path)) = (parts.next(), parts.next(), parts.next(), parts.next()) else {
|
||||
continue;
|
||||
};
|
||||
let (Ok(crc), Ok(size), Ok(modified)) = (u32::from_str_radix(crc, 16), size.parse(), modified.parse()) else {
|
||||
continue;
|
||||
};
|
||||
out.insert(path.to_string(), Known { crc, size, modified });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn ledger_line(path: &str, k: Known) -> String {
|
||||
format!("{:08x}\t{}\t{}\t{path}\n", k.crc, k.size, k.modified)
|
||||
}
|
||||
|
||||
fn crc_of(path: &Path, cancel: &AtomicBool) -> Result<Option<u32>, ()> {
|
||||
let Ok(mut f) = File::open(path) else { return Ok(None) };
|
||||
let mut crc = Crc::new();
|
||||
let mut buf = vec![0u8; 1024 * 1024];
|
||||
loop {
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
return Err(());
|
||||
}
|
||||
match f.read(&mut buf) {
|
||||
Ok(0) => return Ok(Some(crc.sum())),
|
||||
Ok(n) => crc.update(&buf[..n]),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
|
||||
Err(_) => return Ok(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether two paths name one file, as they do for a case-only rename on case-insensitive storage.
|
||||
fn same_file(a: &Path, b: &Path) -> bool {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
match (fs::metadata(a), fs::metadata(b)) {
|
||||
(Ok(x), Ok(y)) => x.dev() == y.dev() && x.ino() == y.ino(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn temp_path(dest: &Path) -> PathBuf {
|
||||
let mut name = dest.as_os_str().to_owned();
|
||||
name.push(TEMP_SUFFIX);
|
||||
PathBuf::from(name)
|
||||
}
|
||||
|
||||
pub struct Planned {
|
||||
pub entry: Entry,
|
||||
pub path: String,
|
||||
pub range_end: u64,
|
||||
}
|
||||
|
||||
/// Every file entry with its safe path and the last byte its local record can occupy.
|
||||
pub fn plan(entries: &[Entry], directory_offset: u64) -> Result<Vec<Planned>, String> {
|
||||
let mut offsets: Vec<u64> = entries.iter().map(|e| e.local_offset).collect();
|
||||
offsets.push(directory_offset);
|
||||
offsets.sort_unstable();
|
||||
offsets.dedup();
|
||||
let mut seen = HashSet::new();
|
||||
let mut out = Vec::new();
|
||||
for e in entries.iter().filter(|e| !e.is_dir()) {
|
||||
if e.method != zip::STORED && e.method != zip::DEFLATED {
|
||||
return Err(format!("{} uses an unsupported compression method ({})", e.name, e.method));
|
||||
}
|
||||
let path = zip::safe_path(&e.name).ok_or_else(|| format!("The archive names a file outside the game folder: {}", e.name))?;
|
||||
if !seen.insert(path.to_lowercase()) {
|
||||
continue;
|
||||
}
|
||||
let at = offsets.partition_point(|&o| o <= e.local_offset);
|
||||
let next = offsets.get(at).copied().unwrap_or(directory_offset);
|
||||
let minimum = e.local_offset + zip::LOCAL_FIXED as u64 + e.compressed;
|
||||
if next < minimum {
|
||||
return Err(format!("{} overlaps the next record in the archive", e.name));
|
||||
}
|
||||
out.push(Planned { entry: e.clone(), path, range_end: next - 1 });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
struct Slot {
|
||||
header: Vec<u8>,
|
||||
payload_from: Option<usize>,
|
||||
consumed: u64,
|
||||
file: File,
|
||||
tmp: PathBuf,
|
||||
crc: Crc,
|
||||
written: u64,
|
||||
inflate: Option<Decompress>,
|
||||
buffer: Vec<u8>,
|
||||
}
|
||||
|
||||
struct Placer<'a> {
|
||||
jobs: &'a [&'a Planned],
|
||||
game_dir: &'a Path,
|
||||
slots: Vec<Mutex<Option<Slot>>>,
|
||||
splits: Vec<Option<Split>>,
|
||||
works: Vec<Work>,
|
||||
placed: Vec<AtomicBool>,
|
||||
ledger: Mutex<Option<File>>,
|
||||
events: &'a dyn Events,
|
||||
gate: &'a ReplaceGate,
|
||||
stop: &'a AtomicBool,
|
||||
failure: Mutex<Option<String>>,
|
||||
base_done: u64,
|
||||
base_files: u32,
|
||||
total: u64,
|
||||
total_files: u32,
|
||||
placed_bytes: AtomicU64,
|
||||
in_flight: AtomicU64,
|
||||
shown: AtomicU64,
|
||||
bytes_written: AtomicU64,
|
||||
files_placed: AtomicUsize,
|
||||
last_emit: AtomicU64,
|
||||
started: Instant,
|
||||
}
|
||||
|
||||
fn discard(slot: Slot) {
|
||||
drop(slot.file);
|
||||
let _ = fs::remove_file(&slot.tmp);
|
||||
}
|
||||
|
||||
impl Placer<'_> {
|
||||
fn fail(&self, message: String) -> String {
|
||||
let mut slot = self.failure.lock().expect("failure lock");
|
||||
if slot.is_none() {
|
||||
*slot = Some(message.clone());
|
||||
}
|
||||
drop(slot);
|
||||
self.stop.store(true, Ordering::SeqCst);
|
||||
message
|
||||
}
|
||||
|
||||
fn emit(&self, force: bool) {
|
||||
let now = self.started.elapsed().as_millis() as u64;
|
||||
let last = self.last_emit.load(Ordering::Relaxed);
|
||||
if !force && now < last + PROGRESS_EVERY_MS {
|
||||
return;
|
||||
}
|
||||
if !force && self.last_emit.compare_exchange(last, now, Ordering::Relaxed, Ordering::Relaxed).is_err() {
|
||||
return;
|
||||
}
|
||||
self.last_emit.store(now, Ordering::Relaxed);
|
||||
let value = self.base_done + self.placed_bytes.load(Ordering::Relaxed) + self.in_flight.load(Ordering::Relaxed);
|
||||
let shown = self.shown.fetch_max(value, Ordering::Relaxed).max(value);
|
||||
self.events.progress(
|
||||
shown.min(self.total),
|
||||
self.total,
|
||||
self.base_files + self.files_placed.load(Ordering::Relaxed) as u32,
|
||||
self.total_files,
|
||||
);
|
||||
}
|
||||
|
||||
fn open(&self, job: &Planned) -> Result<Slot, String> {
|
||||
let dest = self.game_dir.join(&job.path);
|
||||
if let Some(parent) = dest.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| format!("Could not create the folder for {}: {e}", job.path))?;
|
||||
}
|
||||
let tmp = temp_path(&dest);
|
||||
let file = File::create(&tmp).map_err(|e| format!("Could not write {}: {e}", job.path))?;
|
||||
Ok(Slot {
|
||||
header: Vec::new(),
|
||||
payload_from: None,
|
||||
consumed: 0,
|
||||
file,
|
||||
tmp,
|
||||
crc: Crc::new(),
|
||||
written: 0,
|
||||
inflate: (job.entry.method == zip::DEFLATED).then(|| Decompress::new(false)),
|
||||
buffer: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn write_out(&self, job: &Planned, slot: &mut Slot, data: &[u8]) -> Result<(), SinkError> {
|
||||
if slot.written + data.len() as u64 > job.entry.size {
|
||||
return Err(SinkError::Retry(format!("{}: more data than the archive lists", job.path)));
|
||||
}
|
||||
slot.file
|
||||
.write_all(data)
|
||||
.map_err(|e| SinkError::Fatal(self.fail(format!("Could not write {}: {e}", job.path))))?;
|
||||
slot.crc.update(data);
|
||||
slot.written += data.len() as u64;
|
||||
self.bytes_written.fetch_add(data.len() as u64, Ordering::Relaxed);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn feed(&self, job: &Planned, slot: &mut Slot, payload: &[u8], finish: bool) -> Result<(), SinkError> {
|
||||
let Some(mut inflate) = slot.inflate.take() else {
|
||||
return self.write_out(job, slot, payload);
|
||||
};
|
||||
let mut out = std::mem::take(&mut slot.buffer);
|
||||
out.resize(INFLATE_BUFFER, 0);
|
||||
let result = self.inflate_into(job, slot, &mut inflate, &mut out, payload, finish);
|
||||
slot.inflate = Some(inflate);
|
||||
slot.buffer = out;
|
||||
result
|
||||
}
|
||||
|
||||
fn inflate_into(&self, job: &Planned, slot: &mut Slot, inflate: &mut Decompress, out: &mut [u8], mut input: &[u8], finish: bool) -> Result<(), SinkError> {
|
||||
let mode = if finish { FlushDecompress::Finish } else { FlushDecompress::None };
|
||||
loop {
|
||||
let (in_before, out_before) = (inflate.total_in(), inflate.total_out());
|
||||
let status = inflate
|
||||
.decompress(input, out, mode)
|
||||
.map_err(|e| SinkError::Retry(format!("{}: the compressed data is damaged ({e})", job.path)))?;
|
||||
let used = (inflate.total_in() - in_before) as usize;
|
||||
let produced = (inflate.total_out() - out_before) as usize;
|
||||
input = &input[used..];
|
||||
if produced > 0 {
|
||||
self.write_out(job, slot, &out[..produced])?;
|
||||
}
|
||||
if status == Status::StreamEnd || (used == 0 && produced == 0) {
|
||||
return Ok(());
|
||||
}
|
||||
if input.is_empty() && !finish && produced < out.len() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn piece(&self, i: usize, offset: u64, data: &[u8]) -> Result<(), SinkError> {
|
||||
let job = self.jobs[i];
|
||||
let mut guard = self.slots[i].lock().expect("slot lock");
|
||||
if offset == 0 {
|
||||
if let Some(old) = guard.take() {
|
||||
self.in_flight.fetch_sub(old.consumed, Ordering::Relaxed);
|
||||
discard(old);
|
||||
}
|
||||
*guard = Some(self.open(job).map_err(|e| SinkError::Fatal(self.fail(e)))?);
|
||||
}
|
||||
let Some(slot) = guard.as_mut() else {
|
||||
return Err(SinkError::Retry(format!("{}: data arrived before the start of the record", job.path)));
|
||||
};
|
||||
self.consume(job, slot, data)
|
||||
}
|
||||
|
||||
fn consume(&self, job: &Planned, slot: &mut Slot, data: &[u8]) -> Result<(), SinkError> {
|
||||
if slot.payload_from.is_none() {
|
||||
slot.header.extend_from_slice(data);
|
||||
match zip::local_header_len(&slot.header) {
|
||||
None => return Ok(()),
|
||||
Some(Err(e)) => return Err(SinkError::Retry(format!("{}: {e}", job.path))),
|
||||
Some(Ok(len)) if slot.header.len() < len => return Ok(()),
|
||||
Some(Ok(len)) => {
|
||||
slot.payload_from = Some(len);
|
||||
let header = std::mem::take(&mut slot.header);
|
||||
let rest = header[len..].to_vec();
|
||||
return self.payload(job, slot, &rest);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.payload(job, slot, data)
|
||||
}
|
||||
|
||||
fn payload(&self, job: &Planned, slot: &mut Slot, data: &[u8]) -> Result<(), SinkError> {
|
||||
let left = job.entry.compressed.saturating_sub(slot.consumed);
|
||||
let take = &data[..(left.min(data.len() as u64) as usize)];
|
||||
if take.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
slot.consumed += take.len() as u64;
|
||||
self.in_flight.fetch_add(take.len() as u64, Ordering::Relaxed);
|
||||
self.feed(job, slot, take, false)?;
|
||||
self.emit(false);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finish(&self, i: usize) -> Result<u64, SinkError> {
|
||||
let job = self.jobs[i];
|
||||
let taken = self.slots[i].lock().expect("slot lock").take();
|
||||
let Some(slot) = taken else {
|
||||
return Err(SinkError::Retry(format!("{}: the record arrived empty", job.path)));
|
||||
};
|
||||
self.complete(i, slot)
|
||||
}
|
||||
|
||||
fn complete(&self, i: usize, mut slot: Slot) -> Result<u64, SinkError> {
|
||||
let job = self.jobs[i];
|
||||
self.in_flight.fetch_sub(slot.consumed, Ordering::Relaxed);
|
||||
if slot.consumed != job.entry.compressed {
|
||||
let got = slot.consumed;
|
||||
discard(slot);
|
||||
return Err(SinkError::Retry(format!("{}: {got} of {} compressed bytes arrived", job.path, job.entry.compressed)));
|
||||
}
|
||||
if slot.inflate.is_some() {
|
||||
if let Err(e) = self.feed(job, &mut slot, &[], true) {
|
||||
discard(slot);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
if slot.written != job.entry.size || slot.crc.sum() != job.entry.crc32 {
|
||||
let got = slot.written;
|
||||
discard(slot);
|
||||
return Err(SinkError::Retry(format!("{}: {got} bytes inflated with a CRC mismatch", job.path)));
|
||||
}
|
||||
let Slot { file, tmp, .. } = slot;
|
||||
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))));
|
||||
}
|
||||
drop(file);
|
||||
if self.stop.load(Ordering::SeqCst) {
|
||||
let _ = fs::remove_file(&tmp);
|
||||
return Ok(0);
|
||||
}
|
||||
if !self.gate.allow(self.events) {
|
||||
let _ = fs::remove_file(&tmp);
|
||||
return Err(SinkError::Fatal(self.fail(REPLACE_REFUSED.into())));
|
||||
}
|
||||
let dest = self.game_dir.join(&job.path);
|
||||
if let Err(e) = fs::rename(&tmp, &dest) {
|
||||
let _ = fs::remove_file(&tmp);
|
||||
return Err(SinkError::Fatal(self.fail(format!("Could not put {} in place: {e}", job.path))));
|
||||
}
|
||||
self.record(job, &dest);
|
||||
self.placed[i].store(true, Ordering::SeqCst);
|
||||
self.placed_bytes.fetch_add(job.entry.compressed, Ordering::Relaxed);
|
||||
self.files_placed.fetch_add(1, Ordering::Relaxed);
|
||||
self.emit(true);
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn part_chunk(&self, i: usize, part: usize, start: u64, offset: u64, data: &[u8]) -> Result<(), SinkError> {
|
||||
let job = self.jobs[i];
|
||||
let Some(split) = self.splits[i].as_ref() else {
|
||||
return Err(SinkError::Fatal(format!("{}: no split state", job.path)));
|
||||
};
|
||||
let mut file = split.file.lock().expect("split lock");
|
||||
if file.is_none() {
|
||||
let dest = self.game_dir.join(&job.path);
|
||||
if let Some(parent) = dest.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| SinkError::Fatal(self.fail(format!("Could not create the folder for {}: {e}", job.path))))?;
|
||||
}
|
||||
*file = Some(File::create(&split.tmp).map_err(|e| SinkError::Fatal(self.fail(format!("Could not write {}: {e}", job.path))))?);
|
||||
}
|
||||
let handle = file.as_ref().expect("split file");
|
||||
if offset == 0 {
|
||||
let old = split.received[part].swap(0, Ordering::Relaxed);
|
||||
self.in_flight.fetch_sub(old, Ordering::Relaxed);
|
||||
}
|
||||
handle
|
||||
.write_all_at(data, start + offset)
|
||||
.map_err(|e| SinkError::Fatal(self.fail(format!("Could not write {}: {e}", job.path))))?;
|
||||
drop(file);
|
||||
split.received[part].fetch_add(data.len() as u64, Ordering::Relaxed);
|
||||
self.in_flight.fetch_add(data.len() as u64, Ordering::Relaxed);
|
||||
self.emit(false);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn part_finish(&self, i: usize, part: usize, len: u64) -> Result<u64, SinkError> {
|
||||
let job = self.jobs[i];
|
||||
let Some(split) = self.splits[i].as_ref() else {
|
||||
return Err(SinkError::Fatal(format!("{}: no split state", job.path)));
|
||||
};
|
||||
let got = split.received[part].load(Ordering::Relaxed);
|
||||
if got != len {
|
||||
return Err(SinkError::Retry(format!("{}: part {part} got {got} of {len} bytes", job.path)));
|
||||
}
|
||||
if split.left.fetch_sub(1, Ordering::SeqCst) != 1 {
|
||||
return Ok(0);
|
||||
}
|
||||
let received: u64 = split.received.iter().map(|r| r.swap(0, Ordering::Relaxed)).sum();
|
||||
self.in_flight.fetch_sub(received, Ordering::Relaxed);
|
||||
let result = self.assemble(i, split);
|
||||
let _ = fs::remove_file(&split.tmp);
|
||||
*split.file.lock().expect("split lock") = None;
|
||||
result.map_err(|e| match e {
|
||||
SinkError::Retry(m) => SinkError::Fatal(self.fail(m)),
|
||||
fatal => fatal,
|
||||
})
|
||||
}
|
||||
|
||||
fn assemble(&self, i: usize, split: &Split) -> Result<u64, SinkError> {
|
||||
let job = self.jobs[i];
|
||||
let mut source = File::open(&split.tmp).map_err(|e| SinkError::Fatal(self.fail(format!("Could not read {}: {e}", job.path))))?;
|
||||
let mut slot = self.open(job).map_err(|e| SinkError::Fatal(self.fail(e)))?;
|
||||
let mut buf = vec![0u8; 1024 * 1024];
|
||||
loop {
|
||||
if self.stop.load(Ordering::Relaxed) {
|
||||
self.in_flight.fetch_sub(slot.consumed, Ordering::Relaxed);
|
||||
discard(slot);
|
||||
return Ok(0);
|
||||
}
|
||||
let n = match source.read(&mut buf) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => n,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
|
||||
Err(e) => {
|
||||
self.in_flight.fetch_sub(slot.consumed, Ordering::Relaxed);
|
||||
discard(slot);
|
||||
return Err(SinkError::Fatal(self.fail(format!("Could not read {}: {e}", job.path))));
|
||||
}
|
||||
};
|
||||
if let Err(e) = self.consume(job, &mut slot, &buf[..n]) {
|
||||
self.in_flight.fetch_sub(slot.consumed, Ordering::Relaxed);
|
||||
discard(slot);
|
||||
return Err(e);
|
||||
}
|
||||
if slot.consumed >= job.entry.compressed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
self.complete(i, slot)
|
||||
}
|
||||
|
||||
fn record(&self, job: &Planned, dest: &Path) {
|
||||
let known = Known { crc: job.entry.crc32, size: job.entry.size, modified: modified_ms(dest) };
|
||||
if let Some(file) = self.ledger.lock().expect("ledger lock").as_mut() {
|
||||
let _ = file.write_all(ledger_line(&job.path, known).as_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Sink<'p, 'a>(&'p Placer<'a>);
|
||||
|
||||
impl FetchSink for Sink<'_, '_> {
|
||||
fn process(&self, item: &FetchItem, _body: Vec<u8>) -> Result<u64, SinkError> {
|
||||
Err(SinkError::Fatal(format!("item {} arrived whole; streaming was expected", item.id)))
|
||||
}
|
||||
|
||||
fn on_chunk(&self, item: &FetchItem, offset: u64, data: &[u8]) -> Result<(), SinkError> {
|
||||
match self.0.works[item.id as usize] {
|
||||
Work::Whole(i) => self.0.piece(i, offset, data),
|
||||
Work::Part { job, part, start, .. } => self.0.part_chunk(job, part, start, offset, data),
|
||||
}
|
||||
}
|
||||
|
||||
fn on_finish(&self, item: &FetchItem, _total_len: u64) -> Result<u64, SinkError> {
|
||||
match self.0.works[item.id as usize] {
|
||||
Work::Whole(i) => self.0.finish(i),
|
||||
Work::Part { job, part, len, .. } => self.0.part_finish(job, part, len),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn origin(url: &str) -> String {
|
||||
let after = url.find("://").map(|i| i + 3).unwrap_or(0);
|
||||
let end = url[after..].find('/').map(|i| after + i).unwrap_or(url.len());
|
||||
url[..end].to_string()
|
||||
}
|
||||
|
||||
fn cancelled(cancel: &AtomicBool) -> Result<(), Stop> {
|
||||
if cancel.load(Ordering::SeqCst) {
|
||||
Err(Stop::Cancelled)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(req: &Request, cancel: &AtomicBool, ev: &dyn Events) -> Result<Finished, Stop> {
|
||||
let failed = Stop::Failed;
|
||||
let started = Instant::now();
|
||||
fs::create_dir_all(&req.game_dir).map_err(|e| failed(format!("Could not create the install folder: {e}")))?;
|
||||
let _ = File::create(req.game_dir.join(IN_PROGRESS_MARKER));
|
||||
|
||||
ev.status("Reading the build…");
|
||||
let archive = net::read_archive(&req.url).map_err(|e| {
|
||||
ev.log(&format!("archive: {e}"));
|
||||
failed(format!("The EA build could not be read: {e}"))
|
||||
})?;
|
||||
cancelled(cancel)?;
|
||||
let planned = plan(&archive.entries, archive.directory.offset).map_err(failed)?;
|
||||
let total_size: u64 = planned.iter().map(|p| p.entry.size).sum();
|
||||
let total_compressed: u64 = planned.iter().map(|p| p.entry.compressed).sum();
|
||||
ev.log(&format!(
|
||||
"archive bytes={} entries={} files={} size={total_size} compressed={total_compressed}",
|
||||
archive.total,
|
||||
archive.entries.len(),
|
||||
planned.len()
|
||||
));
|
||||
|
||||
for p in &planned {
|
||||
let dest = req.game_dir.join(&p.path);
|
||||
let _ = fs::remove_file(temp_path(&dest));
|
||||
let mut split = dest.into_os_string();
|
||||
split.push(SPLIT_SUFFIX);
|
||||
let _ = fs::remove_file(split);
|
||||
}
|
||||
|
||||
ev.status("Checking files…");
|
||||
let mut ledger = read_ledger(&req.game_dir);
|
||||
let mut pending: Vec<&Planned> = Vec::new();
|
||||
let mut kept: Vec<(String, Known)> = Vec::new();
|
||||
let mut checked = 0u64;
|
||||
let mut last_report = Instant::now();
|
||||
for (n, p) in planned.iter().enumerate() {
|
||||
let dest = req.game_dir.join(&p.path);
|
||||
let size_ok = fs::symlink_metadata(&dest).map(|m| m.is_file() && m.len() == p.entry.size).unwrap_or(false);
|
||||
let mut complete = false;
|
||||
if size_ok {
|
||||
let modified = modified_ms(&dest);
|
||||
let known = ledger.get(&p.path).copied();
|
||||
if known == Some(Known { crc: p.entry.crc32, size: p.entry.size, modified }) {
|
||||
complete = true;
|
||||
} else {
|
||||
match crc_of(&dest, cancel) {
|
||||
Err(()) => return Err(Stop::Cancelled),
|
||||
Ok(Some(crc)) if crc == p.entry.crc32 => {
|
||||
complete = true;
|
||||
kept.push((p.path.clone(), Known { crc, size: p.entry.size, modified }));
|
||||
}
|
||||
Ok(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !complete {
|
||||
pending.push(p);
|
||||
}
|
||||
checked += p.entry.size;
|
||||
if last_report.elapsed() >= CHECK_REPORT_EVERY || n + 1 == planned.len() {
|
||||
ev.checking(checked, total_size, (n + 1) as u32, planned.len() as u32);
|
||||
last_report = Instant::now();
|
||||
}
|
||||
cancelled(cancel)?;
|
||||
}
|
||||
for (path, known) in kept {
|
||||
ledger.insert(path, known);
|
||||
}
|
||||
|
||||
let pending_size: u64 = pending.iter().map(|p| p.entry.size).sum();
|
||||
let pending_compressed: u64 = pending.iter().map(|p| p.entry.compressed).sum();
|
||||
ev.log(&format!(
|
||||
"resume kept_files={} pending_files={} pending_bytes={pending_size} pending_download={pending_compressed}",
|
||||
planned.len() - pending.len(),
|
||||
pending.len()
|
||||
));
|
||||
ev.sizes(pending_size, total_size);
|
||||
if let Some(message) = space_problem(pending_size, free_bytes(&req.game_dir)) {
|
||||
return Err(failed(message));
|
||||
}
|
||||
|
||||
let gate = ReplaceGate::new();
|
||||
let (empty, remote): (Vec<&Planned>, Vec<&Planned>) = pending.into_iter().partition(|p| p.entry.size == 0 && p.entry.crc32 == 0);
|
||||
for p in &empty {
|
||||
let dest = req.game_dir.join(&p.path);
|
||||
if let Some(parent) = dest.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| failed(format!("Could not create the folder for {}: {e}", p.path)))?;
|
||||
}
|
||||
if !gate.allow(ev) {
|
||||
return Err(failed(REPLACE_REFUSED.into()));
|
||||
}
|
||||
File::create(&dest).map_err(|e| failed(format!("Could not write {}: {e}", p.path)))?;
|
||||
ledger.insert(p.path.clone(), Known { crc: 0, size: 0, modified: modified_ms(&dest) });
|
||||
}
|
||||
|
||||
let mut text = String::new();
|
||||
for (path, known) in &ledger {
|
||||
text.push_str(&ledger_line(path, *known));
|
||||
}
|
||||
fs::write(req.game_dir.join(LEDGER), text).map_err(|e| failed(format!("Could not write the install record: {e}")))?;
|
||||
let ledger_file = OpenOptions::new().append(true).open(req.game_dir.join(LEDGER)).ok();
|
||||
|
||||
let host = origin(&req.url);
|
||||
let works = works(&remote, req.part_bytes);
|
||||
let splits: Vec<Option<Split>> = remote
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, p)| {
|
||||
let parts = works.iter().filter(|w| matches!(w, Work::Part { job, .. } if *job == i)).count();
|
||||
(parts > 0).then(|| {
|
||||
let mut name = req.game_dir.join(&p.path).into_os_string();
|
||||
name.push(SPLIT_SUFFIX);
|
||||
Split {
|
||||
tmp: PathBuf::from(name),
|
||||
file: Mutex::new(None),
|
||||
received: (0..parts).map(|_| AtomicU64::new(0)).collect(),
|
||||
left: AtomicUsize::new(parts),
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let placer = Placer {
|
||||
jobs: &remote,
|
||||
game_dir: &req.game_dir,
|
||||
slots: remote.iter().map(|_| Mutex::new(None)).collect(),
|
||||
splits,
|
||||
works: works.clone(),
|
||||
placed: remote.iter().map(|_| AtomicBool::new(false)).collect(),
|
||||
ledger: Mutex::new(ledger_file),
|
||||
events: ev,
|
||||
gate: &gate,
|
||||
stop: cancel,
|
||||
failure: Mutex::new(None),
|
||||
base_done: total_compressed - pending_compressed,
|
||||
base_files: (planned.len() - remote.len()) as u32,
|
||||
total: total_compressed,
|
||||
total_files: planned.len() as u32,
|
||||
placed_bytes: AtomicU64::new(0),
|
||||
in_flight: AtomicU64::new(0),
|
||||
shown: AtomicU64::new(0),
|
||||
bytes_written: AtomicU64::new(0),
|
||||
files_placed: AtomicUsize::new(0),
|
||||
last_emit: AtomicU64::new(0),
|
||||
started: Instant::now(),
|
||||
};
|
||||
let cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(2);
|
||||
let window = req.max_workers.clamp(1, 64);
|
||||
let hosts = vec![host.clone()];
|
||||
let options = FetchOptions {
|
||||
max_workers: window,
|
||||
per_host_cap: window,
|
||||
timeout: REQUEST_TIMEOUT,
|
||||
headers: vec![("User-Agent".to_string(), net::user_agent())],
|
||||
ca_bundle_path: req.ca_bundle.clone(),
|
||||
process_workers: req.process_workers.max(cores / 2).max(1),
|
||||
label: "ea".to_string(),
|
||||
stream: true,
|
||||
};
|
||||
let items: Vec<FetchItem> = works
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(n, w)| {
|
||||
let range = match *w {
|
||||
Work::Whole(i) => (remote[i].entry.local_offset, remote[i].range_end),
|
||||
Work::Part { job, start, len, .. } => {
|
||||
let at = remote[job].entry.local_offset + start;
|
||||
(at, at + len - 1)
|
||||
}
|
||||
};
|
||||
FetchItem { id: n as u64, urls: vec![req.url.clone()], reserve: 0, range: Some(range) }
|
||||
})
|
||||
.collect();
|
||||
ev.status("Downloading…");
|
||||
ev.log(&format!("engine files={} requests={} window={window} hosts={}", remote.len(), items.len(), distinct_host_count(&hosts)));
|
||||
placer.emit(true);
|
||||
let log = |line: &str| ev.log(line);
|
||||
let outcome = if items.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(run_fetch(items, &hosts, &options, &Sink(&placer), cancel, &|_: u64, _: u64| {}, &log))
|
||||
};
|
||||
for slot in &placer.slots {
|
||||
if let Some(active) = slot.lock().expect("slot lock").take() {
|
||||
discard(active);
|
||||
}
|
||||
}
|
||||
for split in placer.splits.iter().flatten() {
|
||||
*split.file.lock().expect("split lock") = None;
|
||||
let _ = fs::remove_file(&split.tmp);
|
||||
}
|
||||
placer.emit(true);
|
||||
let placed_all = placer.placed.iter().all(|p| p.load(Ordering::SeqCst));
|
||||
let bytes_written = placer.bytes_written.load(Ordering::Relaxed);
|
||||
let files_written = placer.files_placed.load(Ordering::Relaxed) + empty.len();
|
||||
let elapsed = started.elapsed().as_secs_f64().max(0.001);
|
||||
ev.log(&format!(
|
||||
"summary files_written={files_written} bytes_written={bytes_written} elapsed_s={elapsed:.1} avg_mbps={:.1}",
|
||||
bytes_written as f64 / 1_048_576.0 / elapsed
|
||||
));
|
||||
if let Some(message) = placer.failure.lock().expect("failure lock").take() {
|
||||
return Err(Stop::Failed(message));
|
||||
}
|
||||
if !placed_all {
|
||||
if let Some(error) = outcome.as_ref().and_then(|o| o.error.clone()) {
|
||||
ev.log(&format!("download failed: {error}"));
|
||||
return Err(Stop::Failed("A file failed to download".into()));
|
||||
}
|
||||
if cancel.load(Ordering::SeqCst) {
|
||||
return Err(Stop::Cancelled);
|
||||
}
|
||||
return Err(Stop::Failed("The download ended before every file was written".into()));
|
||||
}
|
||||
|
||||
let wanted: HashSet<&str> = planned.iter().map(|p| p.path.as_str()).collect();
|
||||
let by_case: HashMap<String, &str> = planned.iter().map(|p| (p.path.to_lowercase(), p.path.as_str())).collect();
|
||||
let mut removed = 0;
|
||||
let current = read_ledger(&req.game_dir);
|
||||
let mut text = String::new();
|
||||
for (path, known) in ¤t {
|
||||
if wanted.contains(path.as_str()) {
|
||||
text.push_str(&ledger_line(path, *known));
|
||||
} else if by_case.get(&path.to_lowercase()).is_some_and(|kept| same_file(&req.game_dir.join(kept), &req.game_dir.join(path))) {
|
||||
continue;
|
||||
} else if zip::safe_path(path).as_deref() == Some(path.as_str()) && fs::remove_file(req.game_dir.join(path)).is_ok() {
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
let _ = fs::write(req.game_dir.join(LEDGER), text);
|
||||
if removed > 0 {
|
||||
ev.log(&format!("removed {removed} files the new build no longer has"));
|
||||
}
|
||||
let _ = fs::remove_file(req.game_dir.join(IN_PROGRESS_MARKER));
|
||||
Ok(Finished {
|
||||
total_bytes: total_size,
|
||||
download_bytes: total_compressed,
|
||||
file_count: planned.len(),
|
||||
files_written,
|
||||
bytes_written,
|
||||
removed,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn size(url: &str) -> Result<Value, String> {
|
||||
let archive = net::read_archive(url)?;
|
||||
let planned = plan(&archive.entries, archive.directory.offset)?;
|
||||
Ok(json!({
|
||||
"totalBytes": planned.iter().map(|p| p.entry.size).sum::<u64>(),
|
||||
"downloadBytes": planned.iter().map(|p| p.entry.compressed).sum::<u64>(),
|
||||
"fileCount": planned.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,258 @@
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use flate2::write::DeflateEncoder;
|
||||
use flate2::Compression;
|
||||
|
||||
use super::*;
|
||||
|
||||
struct Built {
|
||||
bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
fn build_zip(files: &[(&str, Vec<u8>, bool)]) -> Built {
|
||||
let mut out = Vec::new();
|
||||
let mut central = Vec::new();
|
||||
for (name, data, deflate) in files {
|
||||
let mut crc = Crc::new();
|
||||
crc.update(data);
|
||||
let body = if *deflate {
|
||||
let mut enc = DeflateEncoder::new(Vec::new(), Compression::default());
|
||||
enc.write_all(data).unwrap();
|
||||
enc.finish().unwrap()
|
||||
} else {
|
||||
data.clone()
|
||||
};
|
||||
let method: u16 = if *deflate { 8 } else { 0 };
|
||||
let offset = out.len() as u32;
|
||||
let extra = [0xAAu8, 0xBB, 2, 0, 1, 2];
|
||||
out.extend_from_slice(&0x0403_4b50u32.to_le_bytes());
|
||||
out.extend_from_slice(&[20, 0, 0, 0]);
|
||||
out.extend_from_slice(&method.to_le_bytes());
|
||||
out.extend_from_slice(&[0, 0, 0, 0]);
|
||||
out.extend_from_slice(&crc.sum().to_le_bytes());
|
||||
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&(name.len() as u16).to_le_bytes());
|
||||
out.extend_from_slice(&(extra.len() as u16).to_le_bytes());
|
||||
out.extend_from_slice(name.as_bytes());
|
||||
out.extend_from_slice(&extra);
|
||||
out.extend_from_slice(&body);
|
||||
|
||||
central.extend_from_slice(&0x0201_4b50u32.to_le_bytes());
|
||||
central.extend_from_slice(&[20, 0, 20, 0, 0, 0]);
|
||||
central.extend_from_slice(&method.to_le_bytes());
|
||||
central.extend_from_slice(&[0, 0, 0, 0]);
|
||||
central.extend_from_slice(&crc.sum().to_le_bytes());
|
||||
central.extend_from_slice(&(body.len() as u32).to_le_bytes());
|
||||
central.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
central.extend_from_slice(&(name.len() as u16).to_le_bytes());
|
||||
central.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
|
||||
central.extend_from_slice(&offset.to_le_bytes());
|
||||
central.extend_from_slice(name.as_bytes());
|
||||
}
|
||||
let cd_offset = out.len() as u32;
|
||||
let count = files.len() as u16;
|
||||
out.extend_from_slice(¢ral);
|
||||
out.extend_from_slice(&0x0605_4b50u32.to_le_bytes());
|
||||
out.extend_from_slice(&[0, 0, 0, 0]);
|
||||
out.extend_from_slice(&count.to_le_bytes());
|
||||
out.extend_from_slice(&count.to_le_bytes());
|
||||
out.extend_from_slice(&(central.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&cd_offset.to_le_bytes());
|
||||
out.extend_from_slice(&[0, 0]);
|
||||
Built { bytes: out }
|
||||
}
|
||||
|
||||
struct Server {
|
||||
url: String,
|
||||
requests: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
fn serve(body: Vec<u8>) -> Server {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let body = Arc::new(body);
|
||||
let requests = Arc::new(AtomicUsize::new(0));
|
||||
let counter = Arc::clone(&requests);
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming() {
|
||||
let Ok(stream) = stream else { continue };
|
||||
let body = Arc::clone(&body);
|
||||
counter.fetch_add(1, Ordering::SeqCst);
|
||||
std::thread::spawn(move || answer(stream, &body));
|
||||
}
|
||||
});
|
||||
Server { url: format!("http://{addr}/build/game.zip?sig=abc"), requests }
|
||||
}
|
||||
|
||||
fn answer(mut stream: TcpStream, body: &[u8]) {
|
||||
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
||||
let mut first = String::new();
|
||||
if reader.read_line(&mut first).is_err() {
|
||||
return;
|
||||
}
|
||||
let mut range = None;
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
match reader.read_line(&mut line) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(_) if line == "\r\n" => break,
|
||||
Ok(_) => {
|
||||
if let Some(v) = line.to_ascii_lowercase().strip_prefix("range: bytes=") {
|
||||
range = Some(v.trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let total = body.len() as u64;
|
||||
let Some(range) = range else {
|
||||
let _ = stream.write_all(format!("HTTP/1.1 200 OK\r\nContent-Length: {total}\r\nConnection: close\r\n\r\n").as_bytes());
|
||||
let _ = stream.write_all(body);
|
||||
return;
|
||||
};
|
||||
let (a, b) = range.split_once('-').unwrap();
|
||||
let (start, end) = if a.is_empty() {
|
||||
let n: u64 = b.parse().unwrap();
|
||||
(total.saturating_sub(n), total - 1)
|
||||
} else {
|
||||
let start: u64 = a.parse().unwrap();
|
||||
let end = if b.is_empty() { total - 1 } else { b.parse::<u64>().unwrap().min(total - 1) };
|
||||
(start, end)
|
||||
};
|
||||
let slice = &body[start as usize..=end as usize];
|
||||
let head = format!(
|
||||
"HTTP/1.1 206 Partial Content\r\nContent-Length: {}\r\nContent-Range: bytes {start}-{end}/{total}\r\nConnection: close\r\n\r\n",
|
||||
slice.len()
|
||||
);
|
||||
let _ = stream.write_all(head.as_bytes());
|
||||
let _ = stream.write_all(slice);
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Recorder {
|
||||
logs: Mutex<Vec<String>>,
|
||||
gates: AtomicUsize,
|
||||
last: Mutex<(u64, u64)>,
|
||||
}
|
||||
|
||||
impl Events for Recorder {
|
||||
fn log(&self, line: &str) {
|
||||
self.logs.lock().unwrap().push(line.to_string());
|
||||
}
|
||||
fn status(&self, _text: &str) {}
|
||||
fn checking(&self, _d: u64, _t: u64, _f: u32, _ft: u32) {}
|
||||
fn sizes(&self, _pending: u64, _total: u64) {}
|
||||
fn progress(&self, done: u64, total: u64, _files: u32, _files_total: u32) {
|
||||
*self.last.lock().unwrap() = (done, total);
|
||||
}
|
||||
fn before_replace(&self) -> bool {
|
||||
self.gates.fetch_add(1, Ordering::SeqCst);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn sample() -> Vec<(&'static str, Vec<u8>, bool)> {
|
||||
let mut seed = 0x1234_5678u32;
|
||||
let big: Vec<u8> = (0..900_000u32).map(|_| { seed = seed.wrapping_mul(1_103_515_245).wrapping_add(12_345); (seed >> 16) as u8 }).collect();
|
||||
vec![
|
||||
("__Installer/installerdata.xml", b"<DiPManifest version=\"4.0\"></DiPManifest>".to_vec(), true),
|
||||
("Game.exe", big, true),
|
||||
("Data\\stored.bin", vec![9u8; 4096], false),
|
||||
("Data/", Vec::new(), false),
|
||||
("empty.txt", Vec::new(), false),
|
||||
]
|
||||
}
|
||||
|
||||
fn request(url: &str, dir: &Path) -> Request {
|
||||
Request { url: url.to_string(), game_dir: dir.to_path_buf(), max_workers: 4, process_workers: 2, ca_bundle: String::new(), part_bytes: 64 * 1024 }
|
||||
}
|
||||
|
||||
fn temp_dir(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("droiddeck-ea-{name}-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installs_every_file_and_keeps_them_on_the_next_run() {
|
||||
let files = sample();
|
||||
let zip = build_zip(&files);
|
||||
let server = serve(zip.bytes);
|
||||
let dir = temp_dir("install");
|
||||
let ev = Recorder::default();
|
||||
let done = run(&request(&server.url, &dir), &AtomicBool::new(false), &ev).unwrap();
|
||||
assert_eq!(done.file_count, 4);
|
||||
assert_eq!(fs::read(dir.join("Game.exe")).unwrap(), files[1].1);
|
||||
assert_eq!(fs::read(dir.join("Data/stored.bin")).unwrap(), vec![9u8; 4096]);
|
||||
assert!(dir.join("empty.txt").is_file());
|
||||
assert!(dir.join("__Installer/installerdata.xml").is_file());
|
||||
assert!(!dir.join(IN_PROGRESS_MARKER).exists());
|
||||
let (shown, total) = *ev.last.lock().unwrap();
|
||||
assert_eq!(shown, total);
|
||||
|
||||
let before = server.requests.load(Ordering::SeqCst);
|
||||
let again = run(&request(&server.url, &dir), &AtomicBool::new(false), &Recorder::default()).unwrap();
|
||||
assert_eq!(again.files_written, 0);
|
||||
assert!(server.requests.load(Ordering::SeqCst) - before <= 2);
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_update_replaces_changed_files_and_drops_removed_ones() {
|
||||
let dir = temp_dir("update");
|
||||
let first = serve(build_zip(&sample()).bytes);
|
||||
run(&request(&first.url, &dir), &AtomicBool::new(false), &Recorder::default()).unwrap();
|
||||
|
||||
let mut next = sample();
|
||||
next[1].1[10] ^= 0xFF;
|
||||
next.retain(|f| f.0 != "Data\\stored.bin");
|
||||
let second = serve(build_zip(&next).bytes);
|
||||
let ev = Recorder::default();
|
||||
let done = run(&request(&second.url, &dir), &AtomicBool::new(false), &ev).unwrap();
|
||||
assert_eq!(done.files_written, 1);
|
||||
assert_eq!(done.removed, 1);
|
||||
assert_eq!(fs::read(dir.join("Game.exe")).unwrap(), next[1].1);
|
||||
assert!(!dir.join("Data/stored.bin").exists());
|
||||
assert_eq!(ev.gates.load(Ordering::SeqCst), 1);
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_file_on_disk_is_downloaded_again() {
|
||||
let dir = temp_dir("repair");
|
||||
let server = serve(build_zip(&sample()).bytes);
|
||||
run(&request(&server.url, &dir), &AtomicBool::new(false), &Recorder::default()).unwrap();
|
||||
let mut data = fs::read(dir.join("Game.exe")).unwrap();
|
||||
data[5] ^= 1;
|
||||
fs::write(dir.join("Game.exe"), &data).unwrap();
|
||||
let done = run(&request(&server.url, &dir), &AtomicBool::new(false), &Recorder::default()).unwrap();
|
||||
assert_eq!(done.files_written, 1);
|
||||
assert_eq!(fs::read(dir.join("Game.exe")).unwrap(), sample()[1].1);
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_the_archive_size() {
|
||||
let server = serve(build_zip(&sample()).bytes);
|
||||
let answer = size(&server.url).unwrap();
|
||||
assert_eq!(answer["fileCount"], 4);
|
||||
let expected: usize = sample().iter().map(|f| f.1.len()).sum();
|
||||
assert_eq!(answer["totalBytes"], expected as u64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_records_arrive_in_parallel_parts() {
|
||||
let dir = temp_dir("parts");
|
||||
let server = serve(build_zip(&sample()).bytes);
|
||||
let before = server.requests.load(Ordering::SeqCst);
|
||||
let done = run(&request(&server.url, &dir), &AtomicBool::new(false), &Recorder::default()).unwrap();
|
||||
assert_eq!(done.files_written, 4);
|
||||
assert!(server.requests.load(Ordering::SeqCst) - before > 6);
|
||||
assert_eq!(fs::read(dir.join("Game.exe")).unwrap(), sample()[1].1);
|
||||
assert!(fs::read_dir(&dir).unwrap().all(|e| !e.unwrap().file_name().to_string_lossy().ends_with(".ddzip")));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod ffi;
|
||||
pub mod install;
|
||||
pub mod net;
|
||||
pub mod zip;
|
||||
@@ -0,0 +1,98 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//! Ranged reads of EA's build archives.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use super::zip::{self, Directory, Entry};
|
||||
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const READ_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
const MAX_DIRECTORY: u64 = 512 * 1024 * 1024;
|
||||
|
||||
pub fn user_agent() -> String {
|
||||
crate::amazon::net::user_agent()
|
||||
}
|
||||
|
||||
pub struct Ranged {
|
||||
pub body: Vec<u8>,
|
||||
pub start: u64,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
fn content_range(value: &str) -> Option<(u64, u64)> {
|
||||
let rest = value.trim().strip_prefix("bytes")?.trim_start();
|
||||
let (span, total) = rest.split_once('/')?;
|
||||
let start = span.split_once('-')?.0.trim().parse().ok()?;
|
||||
Some((start, total.trim().parse().ok()?))
|
||||
}
|
||||
|
||||
pub fn get_range(url: &str, range: &str) -> Result<Ranged, String> {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|e| format!("runtime: {e}"))?;
|
||||
runtime.block_on(async {
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent(user_agent())
|
||||
.connect_timeout(CONNECT_TIMEOUT)
|
||||
.read_timeout(READ_TIMEOUT)
|
||||
.build()
|
||||
.map_err(|e| format!("http client: {}", e.without_url()))?;
|
||||
let response = client
|
||||
.get(url)
|
||||
.header(reqwest::header::RANGE, format!("bytes={range}"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("request failed: {}", e.without_url()))?;
|
||||
let status = response.status().as_u16();
|
||||
if status == 403 || status == 404 {
|
||||
return Err(format!("the download link was refused ({status})"));
|
||||
}
|
||||
if status != 206 {
|
||||
return Err(format!("the server did not answer a ranged read ({status})"));
|
||||
}
|
||||
let (start, total) = response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_RANGE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(content_range)
|
||||
.ok_or("the server gave no content range")?;
|
||||
let body = response.bytes().await.map_err(|e| format!("answer could not be read: {}", e.without_url()))?.to_vec();
|
||||
Ok(Ranged { body, start, total })
|
||||
})
|
||||
}
|
||||
|
||||
pub struct Archive {
|
||||
pub total: u64,
|
||||
pub directory: Directory,
|
||||
pub entries: Vec<Entry>,
|
||||
}
|
||||
|
||||
pub fn read_archive(url: &str) -> Result<Archive, String> {
|
||||
let tail = get_range(url, &format!("-{}", zip::TAIL_LEN))?;
|
||||
let directory = zip::locate(&tail.body, tail.start)?;
|
||||
if directory.size > MAX_DIRECTORY || directory.offset + directory.size > tail.total {
|
||||
return Err("The archive directory is out of bounds".into());
|
||||
}
|
||||
let bytes = if directory.offset >= tail.start {
|
||||
let at = (directory.offset - tail.start) as usize;
|
||||
let end = at + directory.size as usize;
|
||||
tail.body.get(at..end).ok_or("The archive directory is cut short")?.to_vec()
|
||||
} else {
|
||||
let end = directory.offset + directory.size - 1;
|
||||
get_range(url, &format!("{}-{end}", directory.offset))?.body
|
||||
};
|
||||
let entries = zip::parse(&bytes, directory.entries)?;
|
||||
Ok(Archive { total: tail.total, directory, entries })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::content_range;
|
||||
|
||||
#[test]
|
||||
fn reads_content_range() {
|
||||
assert_eq!(content_range("bytes 100-199/5000"), Some((100, 5000)));
|
||||
assert_eq!(content_range("bytes */5000"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//! The central directory of a remote ZIP archive, which is how EA publishes a game build.
|
||||
|
||||
const EOCD: u32 = 0x0605_4b50;
|
||||
const EOCD64: u32 = 0x0606_4b50;
|
||||
const EOCD64_LOCATOR: u32 = 0x0706_4b50;
|
||||
const CENTRAL: u32 = 0x0201_4b50;
|
||||
const LOCAL: u32 = 0x0403_4b50;
|
||||
const EOCD_LEN: usize = 22;
|
||||
const LOCATOR_LEN: usize = 20;
|
||||
const EOCD64_LEN: usize = 56;
|
||||
pub const LOCAL_FIXED: usize = 30;
|
||||
pub const TAIL_LEN: u64 = 128 * 1024;
|
||||
|
||||
pub const STORED: u16 = 0;
|
||||
pub const DEFLATED: u16 = 8;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Entry {
|
||||
pub name: String,
|
||||
pub crc32: u32,
|
||||
pub method: u16,
|
||||
pub compressed: u64,
|
||||
pub size: u64,
|
||||
pub local_offset: u64,
|
||||
}
|
||||
|
||||
impl Entry {
|
||||
pub fn is_dir(&self) -> bool {
|
||||
self.name.ends_with('/') || self.name.ends_with('\\')
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Directory {
|
||||
pub offset: u64,
|
||||
pub size: u64,
|
||||
pub entries: u64,
|
||||
}
|
||||
|
||||
fn u16_at(b: &[u8], at: usize) -> Option<u16> {
|
||||
b.get(at..at + 2).map(|s| u16::from_le_bytes([s[0], s[1]]))
|
||||
}
|
||||
|
||||
fn u32_at(b: &[u8], at: usize) -> Option<u32> {
|
||||
b.get(at..at + 4).map(|s| u32::from_le_bytes([s[0], s[1], s[2], s[3]]))
|
||||
}
|
||||
|
||||
fn u64_at(b: &[u8], at: usize) -> Option<u64> {
|
||||
b.get(at..at + 8).map(|s| u64::from_le_bytes(s.try_into().unwrap_or([0; 8])))
|
||||
}
|
||||
|
||||
/// Finds the central directory from the last bytes of the archive. `tail_start` is where `tail`
|
||||
/// begins in the whole file.
|
||||
pub fn locate(tail: &[u8], tail_start: u64) -> Result<Directory, String> {
|
||||
if tail.len() < EOCD_LEN {
|
||||
return Err("The archive is too short to be a ZIP".into());
|
||||
}
|
||||
let mut at = tail.len() - EOCD_LEN;
|
||||
let found = loop {
|
||||
if u32_at(tail, at) == Some(EOCD) {
|
||||
let comment = u16_at(tail, at + 20).unwrap_or(0) as usize;
|
||||
if at + EOCD_LEN + comment <= tail.len() {
|
||||
break at;
|
||||
}
|
||||
}
|
||||
if at == 0 {
|
||||
return Err("The archive has no end of central directory".into());
|
||||
}
|
||||
at -= 1;
|
||||
};
|
||||
let entries = u16_at(tail, found + 10).unwrap_or(0) as u64;
|
||||
let size = u32_at(tail, found + 12).unwrap_or(0) as u64;
|
||||
let offset = u32_at(tail, found + 16).unwrap_or(0) as u64;
|
||||
let wide = entries == 0xFFFF || size == 0xFFFF_FFFF || offset == 0xFFFF_FFFF;
|
||||
if !wide {
|
||||
return Ok(Directory { offset, size, entries });
|
||||
}
|
||||
let locator = found.checked_sub(LOCATOR_LEN).filter(|&l| u32_at(tail, l) == Some(EOCD64_LOCATOR));
|
||||
let Some(locator) = locator else {
|
||||
return Err("The archive needs ZIP64 but has no ZIP64 locator".into());
|
||||
};
|
||||
let record_at = u64_at(tail, locator + 8).unwrap_or(0);
|
||||
let Some(rel) = record_at.checked_sub(tail_start).map(|r| r as usize) else {
|
||||
return Err("The ZIP64 directory record lies before the fetched tail".into());
|
||||
};
|
||||
if u32_at(tail, rel) != Some(EOCD64) || rel + EOCD64_LEN > tail.len() {
|
||||
return Err("The ZIP64 directory record is damaged".into());
|
||||
}
|
||||
Ok(Directory {
|
||||
entries: u64_at(tail, rel + 32).unwrap_or(0),
|
||||
size: u64_at(tail, rel + 40).unwrap_or(0),
|
||||
offset: u64_at(tail, rel + 48).unwrap_or(0),
|
||||
})
|
||||
}
|
||||
|
||||
fn wide_values(extra: &[u8], need_size: bool, need_compressed: bool, need_offset: bool) -> (Option<u64>, Option<u64>, Option<u64>) {
|
||||
let mut at = 0;
|
||||
while at + 4 <= extra.len() {
|
||||
let id = u16_at(extra, at).unwrap_or(0);
|
||||
let len = u16_at(extra, at + 2).unwrap_or(0) as usize;
|
||||
let body = &extra[(at + 4).min(extra.len())..(at + 4 + len).min(extra.len())];
|
||||
if id == 0x0001 {
|
||||
let mut p = 0;
|
||||
let mut take = |want: bool| {
|
||||
if !want {
|
||||
return None;
|
||||
}
|
||||
let v = u64_at(body, p);
|
||||
p += 8;
|
||||
v
|
||||
};
|
||||
let size = take(need_size);
|
||||
let compressed = take(need_compressed);
|
||||
let offset = take(need_offset);
|
||||
return (size, compressed, offset);
|
||||
}
|
||||
at += 4 + len;
|
||||
}
|
||||
(None, None, None)
|
||||
}
|
||||
|
||||
pub fn parse(directory: &[u8], expected: u64) -> Result<Vec<Entry>, String> {
|
||||
let mut entries = Vec::with_capacity(expected.min(1 << 20) as usize);
|
||||
let mut at = 0usize;
|
||||
while at + 46 <= directory.len() {
|
||||
if u32_at(directory, at) != Some(CENTRAL) {
|
||||
break;
|
||||
}
|
||||
let flags = u16_at(directory, at + 8).unwrap_or(0);
|
||||
let method = u16_at(directory, at + 10).unwrap_or(0);
|
||||
let crc32 = u32_at(directory, at + 16).unwrap_or(0);
|
||||
let compressed32 = u32_at(directory, at + 20).unwrap_or(0);
|
||||
let size32 = u32_at(directory, at + 24).unwrap_or(0);
|
||||
let name_len = u16_at(directory, at + 28).unwrap_or(0) as usize;
|
||||
let extra_len = u16_at(directory, at + 30).unwrap_or(0) as usize;
|
||||
let comment_len = u16_at(directory, at + 32).unwrap_or(0) as usize;
|
||||
let offset32 = u32_at(directory, at + 42).unwrap_or(0);
|
||||
let name_at = at + 46;
|
||||
let end = name_at + name_len + extra_len + comment_len;
|
||||
if end > directory.len() {
|
||||
return Err("The archive directory is cut short".into());
|
||||
}
|
||||
let raw = &directory[name_at..name_at + name_len];
|
||||
let name = if flags & 0x0800 != 0 {
|
||||
String::from_utf8_lossy(raw).into_owned()
|
||||
} else {
|
||||
raw.iter().map(|&b| b as char).collect()
|
||||
};
|
||||
let extra = &directory[name_at + name_len..name_at + name_len + extra_len];
|
||||
let (size, compressed, offset) =
|
||||
wide_values(extra, size32 == 0xFFFF_FFFF, compressed32 == 0xFFFF_FFFF, offset32 == 0xFFFF_FFFF);
|
||||
if flags & 0x0001 != 0 {
|
||||
return Err(format!("{name} is encrypted"));
|
||||
}
|
||||
entries.push(Entry {
|
||||
name,
|
||||
crc32,
|
||||
method,
|
||||
compressed: compressed.unwrap_or(compressed32 as u64),
|
||||
size: size.unwrap_or(size32 as u64),
|
||||
local_offset: offset.unwrap_or(offset32 as u64),
|
||||
});
|
||||
at = end;
|
||||
}
|
||||
if expected != 0 && entries.len() as u64 != expected {
|
||||
return Err(format!("The archive directory lists {} of {expected} entries", entries.len()));
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Bytes before the compressed data of an entry, read from its local header.
|
||||
pub fn local_header_len(header: &[u8]) -> Option<Result<usize, String>> {
|
||||
if header.len() < LOCAL_FIXED {
|
||||
return None;
|
||||
}
|
||||
if u32_at(header, 0) != Some(LOCAL) {
|
||||
return Some(Err("An entry has no local header where the directory points".into()));
|
||||
}
|
||||
let name = u16_at(header, 26).unwrap_or(0) as usize;
|
||||
let extra = u16_at(header, 28).unwrap_or(0) as usize;
|
||||
Some(Ok(LOCAL_FIXED + name + extra))
|
||||
}
|
||||
|
||||
/// A relative path inside the install folder, or None for names that would leave it.
|
||||
pub fn safe_path(name: &str) -> Option<String> {
|
||||
let unified = name.replace('\\', "/");
|
||||
if unified.starts_with('/') || unified.contains(':') || unified.contains('\0') {
|
||||
return None;
|
||||
}
|
||||
let parts: Vec<&str> = unified.split('/').filter(|p| !p.is_empty() && *p != ".").collect();
|
||||
if parts.is_empty() || parts.iter().any(|p| *p == "..") {
|
||||
return None;
|
||||
}
|
||||
Some(parts.join("/"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn central(name: &str, method: u16, crc: u32, compressed: u32, size: u32, offset: u32, extra: &[u8]) -> Vec<u8> {
|
||||
let mut v = Vec::new();
|
||||
v.extend_from_slice(&CENTRAL.to_le_bytes());
|
||||
v.extend_from_slice(&[20, 0, 20, 0, 0, 0]);
|
||||
v.extend_from_slice(&method.to_le_bytes());
|
||||
v.extend_from_slice(&[0, 0, 0, 0]);
|
||||
v.extend_from_slice(&crc.to_le_bytes());
|
||||
v.extend_from_slice(&compressed.to_le_bytes());
|
||||
v.extend_from_slice(&size.to_le_bytes());
|
||||
v.extend_from_slice(&(name.len() as u16).to_le_bytes());
|
||||
v.extend_from_slice(&(extra.len() as u16).to_le_bytes());
|
||||
v.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
|
||||
v.extend_from_slice(&offset.to_le_bytes());
|
||||
v.extend_from_slice(name.as_bytes());
|
||||
v.extend_from_slice(extra);
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_a_plain_directory() {
|
||||
let mut dir = central("Game.exe", DEFLATED, 7, 10, 20, 0, &[]);
|
||||
dir.extend(central("__Installer/", STORED, 0, 0, 0, 40, &[]));
|
||||
let mut tail = vec![0u8; 5];
|
||||
tail.extend_from_slice(&dir);
|
||||
let cd_size = dir.len() as u32;
|
||||
tail.extend_from_slice(&EOCD.to_le_bytes());
|
||||
tail.extend_from_slice(&[0, 0, 0, 0, 2, 0, 2, 0]);
|
||||
tail.extend_from_slice(&cd_size.to_le_bytes());
|
||||
tail.extend_from_slice(&1000u32.to_le_bytes());
|
||||
tail.extend_from_slice(&[0, 0]);
|
||||
let found = locate(&tail, 995).unwrap();
|
||||
assert_eq!(found, Directory { offset: 1000, size: cd_size as u64, entries: 2 });
|
||||
let entries = parse(&dir, 2).unwrap();
|
||||
assert_eq!(entries[0].name, "Game.exe");
|
||||
assert_eq!(entries[0].size, 20);
|
||||
assert!(entries[1].is_dir());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_zip64_values() {
|
||||
let mut extra = vec![1, 0, 24, 0];
|
||||
extra.extend_from_slice(&6_000_000_000u64.to_le_bytes());
|
||||
extra.extend_from_slice(&5_000_000_000u64.to_le_bytes());
|
||||
extra.extend_from_slice(&7_000_000_000u64.to_le_bytes());
|
||||
let dir = central("big.pak", DEFLATED, 1, 0xFFFF_FFFF, 0xFFFF_FFFF, 0xFFFF_FFFF, &extra);
|
||||
let e = &parse(&dir, 1).unwrap()[0];
|
||||
assert_eq!((e.size, e.compressed, e.local_offset), (6_000_000_000, 5_000_000_000, 7_000_000_000));
|
||||
|
||||
let mut tail = Vec::new();
|
||||
let record_at = 9_000_000_000u64;
|
||||
tail.extend_from_slice(&EOCD64.to_le_bytes());
|
||||
tail.extend_from_slice(&44u64.to_le_bytes());
|
||||
tail.extend_from_slice(&[45, 0, 45, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
|
||||
tail.extend_from_slice(&3u64.to_le_bytes());
|
||||
tail.extend_from_slice(&3u64.to_le_bytes());
|
||||
tail.extend_from_slice(&500u64.to_le_bytes());
|
||||
tail.extend_from_slice(&8_000_000_000u64.to_le_bytes());
|
||||
tail.extend_from_slice(&EOCD64_LOCATOR.to_le_bytes());
|
||||
tail.extend_from_slice(&0u32.to_le_bytes());
|
||||
tail.extend_from_slice(&record_at.to_le_bytes());
|
||||
tail.extend_from_slice(&1u32.to_le_bytes());
|
||||
tail.extend_from_slice(&EOCD.to_le_bytes());
|
||||
tail.extend_from_slice(&[0, 0, 0, 0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0, 0]);
|
||||
let found = locate(&tail, record_at).unwrap();
|
||||
assert_eq!(found, Directory { offset: 8_000_000_000, size: 500, entries: 3 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_paths_that_leave_the_folder() {
|
||||
assert_eq!(safe_path("Data\\Win32\\a.big").as_deref(), Some("Data/Win32/a.big"));
|
||||
assert_eq!(safe_path("./x//y").as_deref(), Some("x/y"));
|
||||
assert!(safe_path("../evil").is_none());
|
||||
assert!(safe_path("/etc/passwd").is_none());
|
||||
assert!(safe_path("C:/x").is_none());
|
||||
assert!(safe_path("a/../../b").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_header_length_counts_name_and_extra() {
|
||||
let mut h = LOCAL.to_le_bytes().to_vec();
|
||||
h.extend_from_slice(&[0u8; 22]);
|
||||
h.extend_from_slice(&5u16.to_le_bytes());
|
||||
h.extend_from_slice(&9u16.to_le_bytes());
|
||||
assert_eq!(local_header_len(&h), Some(Ok(44)));
|
||||
assert_eq!(local_header_len(&h[..10]), None);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
#![allow(clippy::missing_safety_doc, clippy::result_large_err)]
|
||||
|
||||
pub mod amazon;
|
||||
pub mod ea;
|
||||
pub mod epic;
|
||||
pub mod fetch_core;
|
||||
pub mod fetch_tuning;
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
package com.droiddeck.launcher.stores.ea
|
||||
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreGameSidecar
|
||||
import com.droiddeck.launcher.stores.StoreLaunch
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.time.LocalDate
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class EaTest {
|
||||
private val wine = EaOoa.Hardware(
|
||||
boardManufacturer = "Wine", boardSerial = "None", biosManufacturer = "The Wine Project", biosSerial = "0",
|
||||
osInstallDate = "20240101000000.000000+000", osSerial = "12345-OEM", volumeSerial = "43000000",
|
||||
gpuPnpId = "PCI\\VEN_5143&DEV_0001&SUBSYS_00000000&REV_00\\0&DEADBEEF&0&DEAD",
|
||||
cpuVendor = "GenuineIntel", cpuEdx = "BFEBFBFF", cpuEax = "000906EA", cpuEcx = "7ffafbbf", brand = "FEX CPU", hostname = "myhost",
|
||||
)
|
||||
|
||||
@Test fun machineHashFollowsTheActivationVersion() {
|
||||
assertEquals("01804062be939efcd4462c65de292af3a56359b5", EaOoa.machineHash(wine, 4))
|
||||
assertEquals("41fa9c614390c5a82534becd338b798197cc5b", EaOoa.machineHash(wine, 3))
|
||||
}
|
||||
|
||||
@Test fun launchCodeAndSessionKeyMatchTheProtocol() {
|
||||
assertEquals(1196115787L, EaCrypto.rtpCode(LocalDate.of(2026, 11, 7)))
|
||||
assertEquals("f057eb86a0bf8c57537b1aa6b98c6495", EaCrypto.hex(EaCrypto.lsxKey(0x6162)))
|
||||
assertEquals(EaCrypto.hex(EaCrypto.LSX_DEFAULT_KEY), EaCrypto.hex(EaCrypto.lsxKey(0)))
|
||||
val answer = EaCrypto.challengeAnswer("abc")
|
||||
assertEquals("abc", String(EaCrypto.ecbDecrypt(EaCrypto.unhex(answer), EaCrypto.LSX_DEFAULT_KEY)))
|
||||
assertEquals((answer[0].code shl 8) or answer[1].code, EaCrypto.challengeSeed("3", answer))
|
||||
assertNull(EaCrypto.challengeSeed("9", answer))
|
||||
}
|
||||
|
||||
@Test fun licenceRoundTripsThroughTheFileFormat() {
|
||||
val xml = "<?xml version=\"1.0\"?><License xmlns=\"http://ea.com/license\"><CipherKey>k</CipherKey><MachineHash>h</MachineHash>" +
|
||||
"<ContentId>1234</ContentId><UserId>9</UserId><GameToken>tok</GameToken><GrantTime>g</GrantTime><StartTime>s</StartTime><Nonce>n</Nonce></License>"
|
||||
val l = EaOoa.parseLicense(xml, "c2ln")
|
||||
assertEquals("tok", l.gameToken)
|
||||
assertEquals("1234", l.contentId)
|
||||
val blob = EaOoa.licenseBlob(l, encoded = true)
|
||||
assertEquals("c2ln", String(blob.copyOfRange(0, 4)))
|
||||
assertEquals(0, blob[64].toInt())
|
||||
val back = String(EaCrypto.licenseDecrypt(blob.copyOfRange(65, blob.size)))
|
||||
assertTrue(back.contains("<GameToken>tok</GameToken>"))
|
||||
assertEquals("sig", String(EaOoa.licenseBlob(l, encoded = false).copyOfRange(0, 3)))
|
||||
}
|
||||
|
||||
@Test fun readsTheVersionOfTheActivationLibrary() {
|
||||
val pe = ByteArray(64)
|
||||
val fixed = byteArrayOf(0xBD.toByte(), 0x04, 0xEF.toByte(), 0xFE.toByte(), 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 7, 0, 4, 0, 0, 0, 6, 0)
|
||||
System.arraycopy(fixed, 0, pe, 10, fixed.size)
|
||||
assertEquals(Triple(4, 7, 6), EaOoa.productVersion(pe))
|
||||
}
|
||||
|
||||
@Test fun manifestNamesTheLauncherAndTouchup() {
|
||||
val xml = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<DiPManifest version="4.0">
|
||||
<buildMetaData><gameVersion version="1.0.64.43203"/></buildMetaData>
|
||||
<runtime>
|
||||
<launcher uid="trial"><filePath>[HKEY_LOCAL_MACHINE\SOFTWARE\EA Games\Game\Install Dir]trial.exe</filePath><trial>true</trial></launcher>
|
||||
<launcher uid="main"><filePath>[HKEY_LOCAL_MACHINE\SOFTWARE\EA Games\Game\Install Dir]bin\Game.exe</filePath><parameters>-dx12</parameters><trial>false</trial></launcher>
|
||||
</runtime>
|
||||
<touchup><filePath>/__Installer/Touchup.exe</filePath><parameters>-locale {locale} -installPath "{installLocation}"</parameters></touchup>
|
||||
</DiPManifest>"""
|
||||
val m = EaManifest.parse(xml)
|
||||
assertEquals("1.0.64.43203", m.version)
|
||||
assertEquals("bin/Game.exe", EaPaths.relative(m.launcher()!!.path))
|
||||
assertEquals("-dx12", m.launcher()!!.parameters)
|
||||
assertEquals("/__Installer/Touchup.exe", m.touchupPath)
|
||||
val r = EaPaths.registered(m.launcher()!!.path)!!
|
||||
assertEquals("HKEY_LOCAL_MACHINE\\SOFTWARE\\EA Games\\Game", r.key)
|
||||
assertEquals("Install Dir", r.value)
|
||||
val utf16 = byteArrayOf(0xFF.toByte(), 0xFE.toByte()) + "<DiPManifest><buildMetaData><gameVersion version=\"1.0\"/></buildMetaData></DiPManifest>".toByteArray(Charsets.UTF_16LE)
|
||||
assertEquals("1.0", EaManifest.parse(EaManifest.decode(utf16)).version)
|
||||
}
|
||||
|
||||
@Test fun findsFilesWhateverTheirCase() {
|
||||
val dir = Files.createTempDirectory("ea").toFile()
|
||||
File(dir, "Bin").mkdirs()
|
||||
File(dir, "Bin/GAME.exe").writeText("x")
|
||||
assertEquals("Bin/GAME.exe", EaPaths.relativeOnDisk(dir, "bin/game.exe"))
|
||||
assertNull(EaPaths.relativeOnDisk(dir, "bin/other.exe"))
|
||||
assertEquals(listOf("-a", "two words", "-c"), EaPaths.splitArgs("-a \"two words\" -c"))
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
|
||||
@Test fun lsxRequestsAreParsed() {
|
||||
val m = EaLsx.parse("<LSX><Request recipient=\"EbisuSDK\" id=\"4\"><GetSetting SettingId=\"ENVIRONMENT\" version=\"\"/></Request></LSX>")!!
|
||||
assertEquals("GetSetting", m.name)
|
||||
assertEquals("4", m.id)
|
||||
assertEquals("ENVIRONMENT", m.attrs["SettingId"])
|
||||
val c = EaLsx.parse("<LSX><Request recipient=\"EALS\" id=\"1\"><ChallengeResponse response=\"ab\" key=\"k\" version=\"3\"><ContentId>1</ContentId><Title>Game</Title></ChallengeResponse></Request></LSX>")!!
|
||||
assertEquals(listOf("ContentId" to "1", "Title" to "Game"), c.children)
|
||||
assertNull(EaLsx.parse("<LSX><Event sender=\"x\"><Challenge/></Event></LSX>"))
|
||||
}
|
||||
|
||||
@Test fun socketOwnerIsReadFromTheProcTable() {
|
||||
val rows = listOf(
|
||||
" sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode",
|
||||
" 0: 0100007F:C350 0100007F:0C90 01 00000000:00000000 00:00000000 00000000 10234 0 1 1 0",
|
||||
)
|
||||
assertEquals(10234, EaLsx.owner(rows, 0xC350))
|
||||
assertNull(EaLsx.owner(rows, 1234))
|
||||
assertNull(EaLsx.owner(emptyList(), 0xC350))
|
||||
}
|
||||
|
||||
@Test fun rapidConnectionsNeverPassTheClientCap() {
|
||||
fun session() = EaLsx.Session(java.net.Socket(), java.io.ByteArrayOutputStream(), false)
|
||||
EaLsx.sessions.clear()
|
||||
val first = (1..8).map { session().also { s -> assertTrue(EaLsx.admit(s)) } }
|
||||
first[0].claimed = true
|
||||
repeat(20) { assertTrue(EaLsx.admit(session())) }
|
||||
assertEquals(8, EaLsx.sessions.count { !it.evicted })
|
||||
assertFalse(first[0].evicted)
|
||||
EaLsx.sessions.forEach { it.trusted = true }
|
||||
assertFalse(EaLsx.admit(session()))
|
||||
EaLsx.sessions.clear()
|
||||
}
|
||||
|
||||
@Test fun anEvictedSessionCannotBecomeTrusted() {
|
||||
val s = EaLsx.Session(java.net.Socket(), java.io.ByteArrayOutputStream(), true)
|
||||
s.evicted = true
|
||||
EaLsx.promote(s, true)
|
||||
assertFalse(s.trusted)
|
||||
val ok = EaLsx.Session(java.net.Socket(), java.io.ByteArrayOutputStream(), true)
|
||||
EaLsx.promote(ok, true)
|
||||
assertTrue(ok.trusted)
|
||||
EaLsx.stop()
|
||||
}
|
||||
|
||||
@Test fun aPeerAndroidCannotNameIsTrustedOnlyForTheClaimedGame() {
|
||||
val hidden = EaLsx.Session(java.net.Socket(), java.io.ByteArrayOutputStream(), false, unverifiable = true)
|
||||
assertFalse(hidden.mayTrust)
|
||||
hidden.claimed = true
|
||||
assertTrue(hidden.mayTrust)
|
||||
val other = EaLsx.Session(java.net.Socket(), java.io.ByteArrayOutputStream(), false)
|
||||
other.claimed = true
|
||||
assertFalse(other.mayTrust)
|
||||
}
|
||||
|
||||
@Test fun aSessionFromAnOlderLaunchCannotBecomeTrusted() {
|
||||
val old = EaLsx.Session(java.net.Socket(), java.io.ByteArrayOutputStream(), true)
|
||||
EaLsx.generation++
|
||||
EaLsx.promote(old, true)
|
||||
assertFalse(old.trusted)
|
||||
EaLsx.stop()
|
||||
}
|
||||
|
||||
@Test fun lateExitDoesNotStopANewerLaunch() {
|
||||
val game = EaLsx.Game("Origin.OFR.2", "2", "Two", "1", "en_US", null, 0)
|
||||
EaLsx.game = game
|
||||
EaLsx.stop("Origin.OFR.1")
|
||||
assertEquals(game, EaLsx.game)
|
||||
EaLsx.stop("Origin.OFR.2")
|
||||
assertNull(EaLsx.game)
|
||||
}
|
||||
|
||||
@Test fun redirectCarriesTheCode() {
|
||||
assertEquals("QUOX", EaAuth.redirectValue("qrc:///html/login_successful.html?code=QUOX&state=1", "code"))
|
||||
assertEquals("tok", EaAuth.redirectValue("qrc:/html/login_successful.html#access_token=tok&token_type=Bearer", "access_token"))
|
||||
assertNull(EaAuth.redirectValue("https://signin.ea.com/p/x", "code"))
|
||||
}
|
||||
|
||||
@Test fun pcSignIsSigned() {
|
||||
val sign = EaAuth.pcSign("ab12", java.util.Date(0), "v1")
|
||||
val (payload, signature) = sign.split('.')
|
||||
val json = JSONObject(String(java.util.Base64.getUrlDecoder().decode(payload)))
|
||||
assertEquals("v1", json.getString("sv"))
|
||||
assertEquals("1970-01-01 00:00:00:000", json.getString("ts"))
|
||||
assertEquals(EaCrypto.base64Url(EaCrypto.hmacSha256("ISa3dpGOc8wW7Adn4auACSQmaccrOyR2".toByteArray(), payload.toByteArray())), signature)
|
||||
}
|
||||
|
||||
@Test fun libraryKeepsOneEntryPerGame() {
|
||||
fun owned(id: String, slug: String, base: String = "", type: String = "BASE_GAME", trial: Boolean = false) =
|
||||
EaApi.OwnedProduct(id, "p$id", "Game $id", slug, base, type, true, trial, emptyList())
|
||||
fun offer(id: String, type: String = "FullGame") = EaApi.Offer(id, "c$id", "", type, true, "", "", "", "", "", "1.0", "", emptyList())
|
||||
val list = listOf(owned("1", "game"), owned("2", "game-deluxe", base = "game"), owned("3", "other"), owned("4", "trial", trial = true), owned("5", "dlc", type = "EXTRA_CONTENT"))
|
||||
val picked = EaLibrary.pick(list, list.associate { it.offerId to offer(it.offerId) })
|
||||
assertEquals(listOf("1", "3"), picked.map { it.first.offerId })
|
||||
}
|
||||
|
||||
@Test fun ownedPageParses() {
|
||||
val page = JSONObject(
|
||||
"""{"me":{"ownedGameProducts":{"next":"100","items":[{"originOfferId":"Origin.OFR.50.0001","product":{"id":"x","name":"N","downloadable":true,"gameSlug":"titanfall-2",
|
||||
"baseItem":{"title":"Titanfall 2","gameType":"BASE_GAME","baseGameSlug":null},"gameProductUser":{"ownershipMethods":["PURCHASE"]}}}]}}}""",
|
||||
)
|
||||
val (items, next) = EaApi.parseOwned(page)
|
||||
assertEquals("100", next)
|
||||
assertEquals("Titanfall 2", items.single().name)
|
||||
assertEquals("", items.single().baseSlug)
|
||||
val offers = EaApi.parseOffers(JSONObject("""{"legacyOffers":[{"offerId":"Origin.OFR.50.0001","contentId":"1035052","displayType":"FullGame","isDownloadable":true,
|
||||
"downloads":[{"downloadType":"LIVE","gameVersion":"2.0.11.0"}],"softwareLocales":["en_US","fr_FR"],"executeParameters":null}]}"""))
|
||||
assertEquals("2.0.11.0", offers.single().gameVersion)
|
||||
assertEquals("", offers.single().executeParameters)
|
||||
assertNotNull(EaApi.query("getPreloadedOwnedGames"))
|
||||
}
|
||||
|
||||
@Test fun launcherReadsTheSessionFileOnce() {
|
||||
val text = StoreLaunch.launcherText(StoreGameSidecar(Store.EA, "Origin.OFR.1", "Game", exe = "bin/Game.exe", args = listOf("-dx12")))
|
||||
assertTrue(text.contains("for /f \"usebackq tokens=1,* delims==\" %%a in (\"%~dp0.droiddeck-ea-env\") do set \"%%a=%%b\""))
|
||||
assertTrue(text.contains("del \"%~dp0.droiddeck-ea-env\""))
|
||||
assertTrue(text.contains("start \"\" \"${StoreLaunch.EA_ORIGIN}\"\r\n\"%~dp0bin\\Game.exe\" -dx12\r\n"))
|
||||
assertTrue(text.trimEnd().endsWith("taskkill /f /im Origin.exe >nul 2>&1"))
|
||||
}
|
||||
|
||||
@Test fun environmentCarriesTheSession() {
|
||||
val account = EaAuth.Account("a", "r", 0, "1001", "2002", "Name", "EaId")
|
||||
val env = EaLaunch.environment("Origin.OFR.1", "1035052", "fr_FR", "generic", "jwt", "opaque", account, "session")
|
||||
assertEquals("generic", env["EAGenericAuthToken"])
|
||||
assertEquals("jwt", env["EAAccessTokenJWS"])
|
||||
assertEquals("3216", env["EALsxPort"])
|
||||
assertEquals("opaque", env["EALaunchUserAuthToken"])
|
||||
assertEquals("EaId", env["EALaunchEAID"])
|
||||
assertEquals("1035052", env["ContentId"])
|
||||
assertEquals("fr_FR", env["EAGameLocale"])
|
||||
}
|
||||
|
||||
@Test fun activationCoreSitsBesideTheExe() {
|
||||
val dir = Files.createTempDirectory("ea").toFile()
|
||||
File(dir, "Binaries/Win32/Core").mkdirs()
|
||||
File(dir, "Binaries/Win32/Core/EACore.dll").writeText("x")
|
||||
val core = EaOoa.coreDir(dir, "Binaries/Win32/MassEffect3.exe")
|
||||
assertEquals(File(dir, "Binaries/Win32/Core").canonicalPath, core?.canonicalPath)
|
||||
assertTrue(EaOoa.legacyCore(core))
|
||||
assertNull(EaOoa.coreDir(dir, "Game.exe"))
|
||||
File(dir, "Core").mkdirs()
|
||||
assertEquals(File(dir, "Core").canonicalPath, EaOoa.coreDir(dir, "Game.exe")?.canonicalPath)
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
|
||||
@Test fun anIncompleteProbeIsNotHashed() {
|
||||
val empty = EaOoa.Hardware.fromJson(JSONObject())
|
||||
assertTrue(!empty.complete(0) && !empty.complete(4))
|
||||
val wmiOnly = EaOoa.Hardware.fromJson(JSONObject().put("boardManufacturer", "Intel Corporation").put("biosManufacturer", "The Wine Project")
|
||||
.put("osSerial", "00330").put("osInstallDate", "2026").put("hostname", "DROIDDECK"))
|
||||
assertTrue(wmiOnly.complete(0))
|
||||
assertTrue(!wmiOnly.complete(4))
|
||||
assertTrue(wine.complete(4))
|
||||
assertTrue(wine.complete(2))
|
||||
val noInstallDate = EaOoa.Hardware.fromJson(JSONObject().put("boardManufacturer", "Wine").put("biosManufacturer", "Wine").put("osSerial", "1")
|
||||
.put("volumeSerial", "43000000").put("cpuVendor", "GenuineIntel").put("cpuEdx", "BFEBFBFF").put("cpuEax", "000906EA").put("cpuEcx", "7ffafbbf"))
|
||||
assertTrue(!noInstallDate.complete(2))
|
||||
assertTrue(noInstallDate.complete(3) && noInstallDate.complete(4))
|
||||
}
|
||||
|
||||
@Test fun legacyManifestNamesItsContentAndTitle() {
|
||||
val xml = """<game gameVersion="1.05.0.0" manifestVersion="2.1"><contentIDs><contentID>71073</contentID><contentID>71183</contentID></contentIDs>
|
||||
<metadata><localeInfo locale="ru_RU"><title>RU</title></localeInfo><localeInfo locale="en_US"><title>Mass Effect 3</title></localeInfo></metadata>
|
||||
<executable><filePath>/__Installer/touchup.exe</filePath><parameters>install -locale {locale}</parameters></executable></game>"""
|
||||
val m = EaManifest.parse(xml)
|
||||
assertEquals(listOf("71073", "71183"), m.contentIds)
|
||||
assertEquals("Mass Effect 3", m.title("en_US"))
|
||||
assertEquals("RU", m.title("ru_RU"))
|
||||
assertEquals("Mass Effect 3", m.title("de_DE"))
|
||||
assertEquals("/__Installer/touchup.exe", m.touchupPath)
|
||||
}
|
||||
|
||||
@Test fun aNumericTrialFlagKeepsTheTrialExeOut() {
|
||||
val xml = """<DiPManifest version="4.0"><runtime>
|
||||
<launcher uid="1-2"><filePath>[HKEY_LOCAL_MACHINE\SOFTWARE\EA Games\Need for Speed Heat\Install Dir]NeedForSpeedHeatTrial.exe</filePath><parameters /><trial>1</trial></launcher>
|
||||
<launcher uid="2-2"><filePath>[HKEY_LOCAL_MACHINE\SOFTWARE\EA Games\Need for Speed Heat\Install Dir]NeedForSpeedHeat.exe</filePath><parameters /><trial>0</trial></launcher>
|
||||
</runtime></DiPManifest>"""
|
||||
assertTrue(EaManifest.parse(xml).launcher()!!.path.endsWith("NeedForSpeedHeat.exe"))
|
||||
}
|
||||
|
||||
@Test fun aSyncRequestedWhileOneRunsIsNotLost() {
|
||||
val gate = SyncGate()
|
||||
assertTrue(gate.enter())
|
||||
assertTrue(!gate.enter())
|
||||
assertTrue(gate.leave())
|
||||
assertTrue(!gate.leave())
|
||||
assertTrue(gate.enter())
|
||||
assertTrue(!gate.leave())
|
||||
}
|
||||
|
||||
@Test fun aRequestRacingTheRunnersExitIsPickedUpBySomeone() {
|
||||
repeat(200) {
|
||||
val gate = SyncGate()
|
||||
val runs = java.util.concurrent.atomic.AtomicInteger()
|
||||
assertTrue(gate.enter())
|
||||
val latch = java.util.concurrent.CountDownLatch(1)
|
||||
val requester = Thread { latch.await(); if (gate.enter()) { runs.incrementAndGet(); while (gate.leave()) runs.incrementAndGet() } }
|
||||
requester.start()
|
||||
latch.countDown()
|
||||
while (gate.leave()) runs.incrementAndGet()
|
||||
requester.join()
|
||||
assertTrue("the second request was dropped", runs.get() >= 1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun aSyncThatSnapshottedBeforeSignOutCannotWriteItsLibrary() {
|
||||
val app = org.robolectric.RuntimeEnvironment.getApplication()
|
||||
val game = EaGame("Origin.OFR.1", "1", "Old account game", "old")
|
||||
val (gen, _) = EaLibrary.snapshot(app)
|
||||
EaLibrary.reset(app)
|
||||
assertTrue(!EaLibrary.storeCache(app, listOf(game), gen))
|
||||
assertTrue(EaLibrary.cached(app).isEmpty())
|
||||
val (fresh, cache) = EaLibrary.snapshot(app)
|
||||
assertTrue(cache.isEmpty())
|
||||
assertTrue(EaLibrary.storeCache(app, listOf(game), fresh))
|
||||
assertEquals("Old account game", EaLibrary.cached(app).single().title)
|
||||
EaLibrary.reset(app)
|
||||
}
|
||||
|
||||
@Test fun aRefreshFinishingAfterSignOutDoesNotRestoreTheSignIn() {
|
||||
val app = org.robolectric.RuntimeEnvironment.getApplication()
|
||||
com.droiddeck.launcher.stores.StoreAccounts.write(app, Store.EA, JSONObject().put("access_token", "old").put("refresh_token", "r").put("user_id", "1"))
|
||||
assertTrue(EaAuth.storeRefreshed(app, "newer", 3600, "r2"))
|
||||
assertEquals("newer", EaAuth.load(app)?.accessToken)
|
||||
EaAuth.signOut(app)
|
||||
assertTrue(!EaAuth.storeRefreshed(app, "late", 3600, "r3"))
|
||||
assertNull(EaAuth.load(app))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
# EA store
|
||||
|
||||
EA games install, update and start without the EA app. The research behind it is in
|
||||
[ea-ubisoft-stores.md](ea-ubisoft-stores.md).
|
||||
|
||||
## Pieces
|
||||
|
||||
| Piece | Where | What it does |
|
||||
|---|---|---|
|
||||
| Sign-in | `stores/ea/EaAuth.kt`, `EaLogin` in `EaBackend.kt` | `JUNO_PC_CLIENT` OAuth with PKCE in the store login WebView; the code comes back on EA's `qrc:///html/login_successful.html` redirect. Tokens refresh with the refresh token. |
|
||||
| Library | `EaApi.kt`, `EaLibrary.kt` | Owned games (`getPreloadedOwnedGames`, EA storefront), their offers (`getLegacyCatalogDefs`) and art (`GameImages`, SteamGridDB when EA has no portrait). Editions of one game collapse onto the base offer; trials, DLC and non-downloadable offers are left out. |
|
||||
| Queries | `app/src/main/resources/ea/graphql/*.gql` | EA's own query texts, byte for byte. The gateway matches persisted queries by SHA-256, so these files must not be reformatted (`-text` in `.gitattributes`). |
|
||||
| Downloads | Rust `src/ea/` in `libdroiddeckstores.so`, `EaInstaller.kt` | A build is one ZIP on EA's CDN. The engine reads its central directory with ranged reads, keeps files whose size and CRC match, and streams every other entry through inflate into place. `.droiddeck-ea-files` records what it placed, so a resume or update does not re-hash those files; files a new build drops are removed. |
|
||||
| Updates | `EaUpdates` in `EaBackend.kt` | The game page compares the installed version with the offer's live `gameVersion` (every 30 minutes at most). Update runs the same install, which only fetches changed files. |
|
||||
| Launch, guest | `droiddeck-store-launch` (`prepare_ea`) | Creates the prefix, reads the machine as Wine reports it (`wmic`, `vol`, `hostname`; cached in `pfx/.droiddeck-ea-hw.json`), writes EA app presence keys and the game's `Install Dir` keys in both registry views, runs the build's Touchup once per installed build, then asks the app for the session and writes any licence files. |
|
||||
| Launch, app | `EaLaunch.kt`, `EaOoa.kt`, `EaLsx.kt` | Answers `ea-prepare`: starts the LSX endpoint on `127.0.0.1:3216`, mints the short-lived opaque token, writes `.droiddeck-ea-env` (read once and deleted by the launcher `.bat`), and for games with a `Core` folder requests an online-activation licence for the machine hash. |
|
||||
| LSX | `EaLsx.kt` | The local endpoint EA's SDK talks to instead of the EA app: challenge, AES session key, config, profile, settings, game info, auth codes for the game's own client ids, licences with Denuvo tokens, entitlements. Friends, presence and the overlay answer empty. |
|
||||
|
||||
## Machine hash
|
||||
|
||||
The licence names a hash of the machine the game's activation layer computes itself. The hash
|
||||
version follows the activation library's product version (`Core/activation.dll` or `awc.dll`):
|
||||
5.x → 4, 4.7.6+ → 3, 4.7.x → 2, 4.x → 1. Versions 2 and 3 also need CPUID leaf 1 ECX, which Wine's
|
||||
WMI does not expose, so those use `00000000` unless the probe learns it.
|
||||
|
||||
## Not done yet
|
||||
|
||||
- Cloud saves (`cloudsync.juno.ea.com`).
|
||||
- Steam copies of EA games still install the EA app (#2036).
|
||||
- Pre-DiP games (no `installerdata.xml`) fall back to the exe picker.
|
||||
- Javelin anti-cheat games do not run under Proton.
|
||||
@@ -0,0 +1,245 @@
|
||||
# EA app and Ubisoft Connect as stores: research and plan
|
||||
|
||||
Research as of 2026-11-07, against `origin/main` at `914b3588e`. Nothing here is implemented yet.
|
||||
|
||||
## Summary
|
||||
|
||||
Both stores can follow the path Battle.net took (#979, then #1602): first the vendor client in its own
|
||||
Proton prefix, then more of it done by DroidDeck itself. Unlike Battle.net, both now have open-source
|
||||
clients that already handle sign-in, library and downloads without the vendor app. So the second step
|
||||
can go further than Battle.net's did.
|
||||
|
||||
| | EA | Ubisoft |
|
||||
|---|---|---|
|
||||
| Sign-in without the client | Yes: OAuth `JUNO_PC_CLIENT` in a WebView | Yes: UbiServices session (ticket + remember-me) through Ubisoft's WebAuth page |
|
||||
| Owned library without the client | Yes: juno GraphQL `ownedGameProducts` | Yes: Demux ownership service (protobuf over TLS), or the GraphQL library |
|
||||
| Downloads without the client | Yes: a build is one ZIP on EA's CDN, read with HTTP Range per entry (Maxima) | Yes: Demux download service → manifest → zstd/deflate slices (Optima, ubi-cli-mvp) |
|
||||
| Launch without the client | **Yes, legitimately**: LSX server on `127.0.0.1:3216` plus a real OOA licence from `proxy.novafusion.ea.com` (Maxima) | **Only by replacing Ubisoft's DRM DLLs** (Optima, Re0xCat loaders). Don't ship that. Modern titles need the real Connect anyway |
|
||||
| Cloud saves without the client | Yes: `cloudsync.juno.ea.com` (Maxima) | Not researched; Connect handles them |
|
||||
| Effectively out of scope | Javelin kernel anti-cheat titles (Battlefield, FC, Apex) | Kernel AC and online-only titles that misbehave under Proton |
|
||||
|
||||
**Recommendation**
|
||||
|
||||
- **EA should go fully native in-app**: sign-in, library, downloads, launch and cloud saves in Kotlin
|
||||
and Rust, with no EA app in the prefix. The EA app stays as an optional fallback, like the Epic
|
||||
launcher option (`EpicClient`). The LSX and licence half also fixes Steam's EA titles, which today
|
||||
install the whole EA app (#2036) just to answer `link2ea://`.
|
||||
- **Ubisoft should be hybrid**: native sign-in, library and downloads in Rust, then **register the
|
||||
files with the real Ubisoft Connect and launch through `uplay://`**. That means a Connect prefix
|
||||
managed the way Battle.net's is.
|
||||
- Ubisoft's launch step is the biggest open question (see "Unknowns"). If Connect can't be made to
|
||||
adopt natively downloaded files cleanly, start with the Connect-driven download, as #979 did for
|
||||
Battle.net.
|
||||
|
||||
## What DroidDeck already has, and how much of it each store reuses
|
||||
|
||||
| Existing piece | Where | EA | Ubisoft |
|
||||
|---|---|---|---|
|
||||
| `Store` enum + `StoreBackend` (signIn, syncLibrary, loadShelves, install, uninstall) | `stores/Store.kt`, `stores/StoresState.kt:355` | Reuse as is: new `EA` entry | Reuse as is: new `UBISOFT` entry |
|
||||
| WebView sign-in with redirect capture (`StoreLoginActivity.Flow`) | `stores/LoginFlows.kt`, `StoreLoginActivity.kt` | Reuse: the redirect is `qrc:///html/login_successful.html?code=…`, caught in `isRedirect` | Reuse: capture the `ubi_v1` ticket from the WebAuth page, then exchange it for a full session |
|
||||
| Keystore-backed account store | `stores/StoreAccounts.kt`, `CredentialCipher.kt` | Reuse | Reuse |
|
||||
| Rust store engine `libdroiddeckstores.so` (JNI, `fetch_core.rs`, hand-rolled protobuf in `amazon/proto.rs`) | `app/src/main/rust/stores` | Reuse: ZIP central directory + Range fetch + inflate (`flate2` is already a dependency) | Reuse `fetch_core`. **New:** a TLS socket client for Demux, a protobuf schema, and a zstd decoder (`ruzstd` is pure Rust; the `zstd` crate needs `cc`, which the crate already has) |
|
||||
| Download queue: pause, resume, cancel, tiers, Downloads tab | `stores/download/*` | Reuse | Reuse |
|
||||
| Install roots: internal, other storage, Games folder | `StoreInstallRoot.kt` | Reuse | Reuse |
|
||||
| Cloud saves: plan, paths, transport | `stores/CloudSaves.kt`, `CloudPlan.kt`, `CloudSavePaths.kt` | Reuse with a new EA transport | Later, or leave to Connect |
|
||||
| Vendor client in its own prefix, a guest Python driver, a headless helper under gamescope, FEX choice, memory policy | `runtime/BattleNet.kt`, `tools/linuxfs/overlay/usr/local/bin/droiddeck-battlenet` | Fallback only | **Needed**: `droiddeck-ubisoft` modelled on `droiddeck-battlenet` |
|
||||
| "Use the vendor launcher instead" option | `stores/epic/EpicClient*.kt` | Pattern for the EA app fallback | Pattern for "Open Ubisoft Connect" |
|
||||
| Steam install-script seeding that lets EA app and Ubisoft Connect installers run | `droiddeck-seed-redists` (#1449, #2036) | Replaced by the LSX stub for Steam EA titles | Unchanged |
|
||||
| Ubisoft registry handoff for Ubisoft titles bought on Epic | open PR #1445 | n/a | Same registry key (`HKLM\SOFTWARE\WOW6432Node\Ubisoft\Launcher\Installs\<id>\InstallDir`); merge or share it |
|
||||
| Library, Steam shortcuts, art, game page, sidecars | `frontend/Library.kt`, `SteamLiveShortcuts.kt`, `StoreArt.kt`, `StoreGameSidecar.kt` | Reuse | Reuse |
|
||||
| Clean-room process for store modules | `docs/development/epic-cleanroom.md`, `amazon-cleanroom.md` | Use: spec from Maxima, then implement from the spec | Use: spec from Optima/ubi-cli-mvp/YoobieRE |
|
||||
|
||||
Genuinely new engineering:
|
||||
|
||||
1. **An LSX server (EA).** EA's local SDK protocol is XML over TCP on 3216, with a challenge handshake,
|
||||
GetConfig, GetProfile, GetGameInfo, license and entitlement requests. Add a Wine-side stub registered
|
||||
as the `link2ea://` / `origin2://` handler with EA app presence keys, plus OOA licence requests.
|
||||
This half is new; the other EA parts are the familiar store pattern.
|
||||
2. **The EA machine hash.** Licences are bound to a `machineHash` built from WMI/BIOS/disk/CPUID
|
||||
values. They have to match what the game computes inside Wine under FEX on the phone.
|
||||
Nobody has shown that on Android yet, so it has to be measured on device.
|
||||
3. **A Demux client (Ubisoft).** This is a TLS socket to `dmx.upc.ubisoft.com` speaking protobuf, with
|
||||
patch negotiation, auth, ownership tokens and download-service URL signing. No store we have uses
|
||||
a socket protocol.
|
||||
4. **A way for Connect to adopt files (Ubisoft).** Connect keeps its own DPAPI-encrypted sign-in, tied
|
||||
to the Wine `MachineGuid`. Our session can't be handed to it (ubi-cli-mvp checked). Connect also has
|
||||
to consider the game installed before `uplay://launch` works.
|
||||
|
||||
## Prior art
|
||||
|
||||
### EA
|
||||
|
||||
| Project | Licence | State | What it gives us |
|
||||
|---|---|---|---|
|
||||
| [ArmchairDevelopers/Maxima](https://github.com/ArmchairDevelopers/Maxima) (Rust, `maxima-lib`) | GPL-3.0 | Pre-alpha by its own README; last commit 2026-01; open issues through 2026-11. Backs KYBER | The whole stack: OAuth (`JUNO_PC_CLIENT`, localhost 31033 redirect), juno GraphQL (`.gql` files for owned games, `downloadUrl`, `availableBuilds`, achievements), the ZIP/Range downloader, OOA licences (`ooa/mod.rs`, `machineHash`), the LSX server (`lsx/`), cloud sync, the hardware hash (`auth/hardware.rs`), Wine registry setup. Unsupported: Battlefield 3/4 (Battlelog), pre-DiP games (Dead Space 2, BFBC2), DLC installs |
|
||||
| [Maxima #71](https://github.com/ArmchairDevelopers/Maxima/issues/71) | | 2026-11 | Games never connect to LSX unless `HKLM\SOFTWARE\WOW6432Node\Origin` is right, and `INSTALLED_LANGUAGE` must be answered. Check ours against it |
|
||||
| [Maxima #49](https://github.com/ArmchairDevelopers/Maxima/issues/49) | | | ARM64: the hash must match what `Activation.dll` reads from CPUID under Rosetta/Box64/FEX. Directly our problem |
|
||||
| unifideck `docs/feasibility/ea.md` | GPL-3.0 | 2026-07 | EA app installer runs unattended (`/quiet`), login renders on GE-Proton11-1, `origin2://game/launch?offerIds=<id>&autoDownload=1` is the install idiom, ~2.5 GB client, aggressive self-update |
|
||||
| Lutris `services/ea_app.py` | GPL-3.0 | Maintained | juno queries, `installerdata.xml` scan for installed games |
|
||||
| BellezaEmporium/galaxy-integration-ead | **none** | Maintained | Facts only: OAuth params, `pc_sign` fallback |
|
||||
| erri120/GameFinder wiki | GPL-3.0 | | Encrypted `IS` file format (only for reading EA app installs) |
|
||||
| Heroic | | wontfix #352 | No EA support beyond Epic-hosted EA titles |
|
||||
|
||||
### Ubisoft
|
||||
|
||||
| Project | Licence | State | What it gives us |
|
||||
|---|---|---|---|
|
||||
| [xXJSONDeruloXx/ubi-cli-mvp](https://github.com/xXJSONDeruloXx/ubi-cli-mvp) (yours, TypeScript) | MIT | 2026-07 | The best-tested data plane: session login with `Ubi-Challenge` replay, `ubi_2fa_v1`, refresh; Demux ownership, ownership tokens and signed URLs; manifest decode; slice layouts (`slices_v3/<bucket>/<HASH>` and flat `slices/`); zstd and both deflates; integrity and path safety. On the Connect side: `connect-install` (`uplay://install/<id>`), **`connect-seed`** (let Connect start the official download, pause, exit, then fill the staging area with verified files), remembered-auth migration between prefixes, `ubi play` via `uplay://`. Docs: `research.md`, `validation.md`, `DEMUX_JOURNAL.md`, `optima-comparison.md` |
|
||||
| [Starkka15/Optima](https://github.com/Starkka15/Optima) (Rust) | GPL-3.0 | 0.2.0, 2026-08 | Rust version of the same data plane (`auth.rs`, `demux.rs`, `install.rs`, `proto/*.proto`), plus launcherless launch through replacement `uplay_r1_loader` / `ubiorbitapi_r2_loader` DLLs, a self-signed certificate that mimics Ubisoft's (imported into the prefix's Root store so Watch_Dogs accepts it), and an EAX shim. Powers GameVault's Ubisoft extension |
|
||||
| YoobieRE: `ubisoft-demux-node`, `ubisoft-dmx-decode`, `ubisoft-manifest-downloader`, `uplay-install-reverse`, `product-db-archive` | mixed or none | 2022–2024 | The original protocol reversing that both of the above build on |
|
||||
| Re0xCat `uplay-r1-loader`, `ubiorbitapi-r2-loader` | | | The DRM-DLL replacements (see Risks) |
|
||||
| [mubaraknumann/unifideck](https://github.com/mubaraknumann/unifideck) `stores/ubisoft/` | GPL-3.0 | Shipping, active | The full Connect-wrapper archetype in Python: three-prefix model (auth / template / per-game, shared `MachineGuid`), credential capture and fan-out, parsing Connect's binary `configurations` and `ownership` catalogs, leveldb `spaceId ↔ ubisoftConnectGameId`, a manual-install watcher, `uplay://launch/<id>/0`, session capture after exit. `docs/ubisoft-store-spec.md` is a good map of what breaks |
|
||||
| Heroic | | | Installs Ubisoft Connect automatically for Ubisoft titles from Epic; no Ubisoft store |
|
||||
| DroidDeck #1445 (open), #1449, #2286 / #2295 / #2317 / #2292 / #2050 | | | Our own Ubisoft-under-Proton knowledge: the `Installs` registry key, `UplayLaunch.exe` being a stub, the bundled `UplayInstaller.exe`, legacy installer layouts, SD-card staging, helper processes during the exit watch |
|
||||
|
||||
Licences: Maxima, Optima, unifideck and Lutris are GPL-3.0, which is compatible with
|
||||
DroidDeck (GPL-3.0). ubi-cli-mvp is MIT. Even so, the Epic and Amazon modules were written clean-room,
|
||||
so do the same here: write the spec from these sources, implement from the spec, and credit them in
|
||||
the README the way #1602 does.
|
||||
|
||||
## Design
|
||||
|
||||
### EA
|
||||
|
||||
```text
|
||||
StoreLoginActivity (OAuth JUNO_PC_CLIENT) ─► EaAccount (access + refresh token, persona)
|
||||
EaLibrary ── juno GraphQL ownedGameProducts / legacyOffers ─► CatalogItem(offerId, contentId, slug)
|
||||
EaInstaller (DownloadQueue job) ── downloadUrl ─► build ZIP ─► Rust: central directory, Range per entry, inflate, CRC
|
||||
└─ writes __Installer/installerdata.xml as EA's own layout expects
|
||||
Launch: droiddeck-ea (guest) ── registers stub for link2ea:// origin2:// + Origin/EA Desktop keys
|
||||
app-side EaLsxServer on 3216 ◄── game (OriginSDK / EA SDK)
|
||||
before launch: OOA licence (novafusion, machineHash) → ProgramData/Electronic Arts/EA Services/License
|
||||
before/after: cloudsync pull / push through CloudSaves
|
||||
```
|
||||
|
||||
- **Where the LSX server runs.** In the Android app. DroidDeck's guest shares
|
||||
the network namespace, so an app-side server on 127.0.0.1:3216 is reachable from Wine. Keeping it
|
||||
in Kotlin or Rust, not a guest daemon, matches how DroidDeck owns other store logic. Check this
|
||||
against proot networking first.
|
||||
- **The stub.** A protocol handler for `link2ea://` / `origin2://` that hands the request to DroidDeck.
|
||||
- **Steam's EA titles.** Use the same stub and LSX path, keyed by `link2ea://` in the launch config,
|
||||
plus entitlement refresh and Steam account linking. #2036's EA app install becomes
|
||||
the fallback.
|
||||
- **The EA app fallback.** Its own prefix (a `compatdata` id like Battle.net's `2596113170`), installed
|
||||
with `EAappInstaller.exe /quiet`, opened from the store row. That's for titles the LSX path can't
|
||||
start (pre-DiP, Battlelog) and for buying games.
|
||||
|
||||
### Ubisoft
|
||||
|
||||
```text
|
||||
StoreLoginActivity (Ubisoft WebAuth) ─► ticket ─► full session (expiry, rememberMe); refresh ~3 h
|
||||
UbisoftLibrary ── Demux ownership ─► products + config YAML (name, exe, uplay id, space id)
|
||||
UbisoftInstaller (DownloadQueue job) ── Rust Demux: ownership token → download_service urlReq
|
||||
→ manifest (356-byte header, zlib) → slices (zstd/deflate) → files, verified
|
||||
Register + launch: droiddeck-ubisoft (guest, like droiddeck-battlenet)
|
||||
Connect in its own prefix, signed in once in its own window (DPAPI/MachineGuid bound)
|
||||
adopt files: Installs\<id>\InstallDir registry (+ whatever state Connect checks)
|
||||
or ubi-cli-mvp's connect-seed: official download starts, pause, seed, let Connect verify
|
||||
launch: upc.exe uplay://launch/<id>/0, Connect closed or minimised once the game runs (#979's 20 s rule)
|
||||
```
|
||||
|
||||
- **One Connect prefix, not one per game.** unifideck uses per-game prefixes because Steam on the
|
||||
Deck gives each shortcut its own. DroidDeck already runs Battle.net games inside Battle.net's prefix,
|
||||
which avoids unifideck's whole credential fan-out problem.
|
||||
- **Two stages, like Battle.net:**
|
||||
1. Connect in its prefix, install by `uplay://install/<id>`, and Downloads shows an indeterminate
|
||||
"Installing in Ubisoft Connect" until the folder settles. That is #979's level.
|
||||
2. Native downloads in the Rust engine with real progress, pause and resume, then Connect adopts the
|
||||
files. Like #1602, but we own the transfer instead of driving the vendor's downloader. If Connect
|
||||
has a scriptable local API (Battle.net's Agent had one), that's a cheaper middle stage, but none is
|
||||
known.
|
||||
- **Launcherless DRM shims: don't ship them.** See Risks.
|
||||
|
||||
## Known
|
||||
|
||||
- EA sign-in, library, downloads, licences, LSX and cloud sync all work outside the EA app on Linux
|
||||
(Maxima).
|
||||
- The EA app installs unattended and its login renders under GE-Proton 11 on x86 (unifideck).
|
||||
- Ubisoft sign-in, refresh, Demux ownership, signed URLs, manifests and slice reconstruction work
|
||||
(ubi-cli-mvp `validation.md`, Optima). A session ticket lasts about 3 hours and remember-me renews it.
|
||||
- Connect's sign-in can't be created from our session. It is DPAPI-encrypted and tied to
|
||||
`MachineGuid`, but it can be copied between prefixes that share that identity.
|
||||
- Connect has repeatedly broken under Proton after its own updates (2023, and a single-source April 2026
|
||||
report). #1449 and the open Ubisoft PRs show the same churn here.
|
||||
- Javelin titles don't run under Proton. EA's March 2026 job listing mentions future Linux/Proton work;
|
||||
nothing has shipped.
|
||||
|
||||
## Unknowns, in the order to settle them
|
||||
|
||||
| # | Question | How to find out | Blocks |
|
||||
|---|---|---|---|
|
||||
| U1 | Does an EA OOA licence fetched with our `machineHash` validate inside DroidDeck-Proton under FEX on Adreno phones? Which WMI/CPUID values does the game see? | Prototype: Maxima's hash code against values read through Wine in our prefix; one DiP single-player title and one Denuvo title. | EA launch |
|
||||
| U2 | Can the app-side LSX server on 127.0.0.1:3216 be reached from proot + Wine, and do games connect with our registry keys (Maxima #71)? | Spike with a Steam EA title already in the library | EA launch, Steam EA titles |
|
||||
| U3 | What does EA's CDN serve for a current title: one ZIP, multiple, or delta? How do language selection and `installerdata.xml` work? | Maxima `content/` + `downloadUrl.gql`, one real download through the Rust engine | EA install |
|
||||
| U4 | Does Connect accept a tree we downloaded once the `Installs` registry key is written, or does it re-verify or re-download? Does `connect-seed` work under FEX on a phone? | Spike with a small owned title (ubi-cli-mvp used Splinter Cell / product 109). #1445's registry work is the start | Ubisoft stage 2 |
|
||||
| U5 | Does Ubisoft Connect (Qt + CEF) run acceptably under FEX in our prefix? How much RAM does it hold while a game runs? | Install it like Battle.net; measure with the Memory readout from #979 | Ubisoft stage 1 |
|
||||
| U6 | Ubisoft's WebAuth page in an Android WebView: captcha, 2FA, `Ubi-Challenge` | Real sign-in on device | Ubisoft sign-in |
|
||||
| U7 | Demux over TLS from Android (certificate pinning, patch-version negotiation drift) | Rust spike against `dmx.upc.ubisoft.com` | Ubisoft library and downloads |
|
||||
| U8 | Ubisoft+ subscription entitlements in Demux ownership | Account with Ubisoft+ | Library completeness |
|
||||
| U9 | EA Play subscription entitlements in `ownedGameProducts` | Account with EA Play | Library completeness |
|
||||
| U10 | Ubisoft cloud saves outside Connect | Not researched; Connect handles them while it's in the path | Later |
|
||||
|
||||
## Plan
|
||||
|
||||
Each step is its own PR, tested on device as usual (REDMAGIC tablet, Fold7, Thor).
|
||||
|
||||
### EA
|
||||
|
||||
1. **Spike (no PR):** settle U1–U3 with throwaway code against one owned DiP title and one Steam EA title.
|
||||
2. **Steam EA titles without the EA app:** the `link2ea` stub, app-side LSX server, OOA licence and
|
||||
entitlement refresh. It replaces #2036's EA app install where it works and falls back to it otherwise.
|
||||
This ships value first and proves the hard half.
|
||||
3. **EA store: sign-in, library, shelves:** a `Store.EA` entry, OAuth flow, juno library, art,
|
||||
a "not installable here" flag for Javelin titles.
|
||||
4. **EA downloads:** a Rust `ea` module (ZIP/Range/inflate on `fetch_core`), with Downloads progress,
|
||||
pause, resume, cancel, updates and uninstall.
|
||||
5. **EA cloud saves** through `CloudSaves`.
|
||||
6. **EA app fallback** prefix and store-row button, for pre-DiP and Battlelog titles.
|
||||
7. Clean-room record: `docs/development/ea-cleanroom.md`.
|
||||
|
||||
### Ubisoft
|
||||
|
||||
1. **Spike (no PR):** U4, U5 and U7.
|
||||
2. **Ubisoft Connect in its prefix (#979 level):** `droiddeck-ubisoft`, the store row, sign-in inside
|
||||
Connect, the installed-games scan (registry `Installs` keys), Play by `uplay://launch`, Connect closed
|
||||
once the game runs, install handed to Connect with indeterminate progress. Fold in #1445.
|
||||
3. **Ubisoft store sign-in and library natively:** the WebAuth flow, session refresh, a Rust Demux
|
||||
client, the owned library and art, without opening Connect.
|
||||
4. **Native Ubisoft downloads:** a Rust `ubisoft` module (manifest, slices, zstd), then hand off to
|
||||
Connect for registration (U4). Updates come from the manifest version.
|
||||
5. Clean-room record: `docs/development/ubisoft-cleanroom.md`.
|
||||
|
||||
The two stores share almost nothing beyond the existing store layer, so they can run in parallel.
|
||||
EA step 2 has the best ratio of value to risk and could start first.
|
||||
|
||||
## Risks
|
||||
|
||||
- **DRM circumvention.** Optima's launcherless mode replaces Ubisoft's DRM loader DLLs, answers the
|
||||
game's ownership calls itself, and installs a certificate mimicking Ubisoft's so Authenticode checks
|
||||
pass. Gating it on real ownership doesn't change that it bypasses Ubisoft's access control. Keep it
|
||||
out of DroidDeck. EA is different: Maxima requests real licences from EA's licence
|
||||
service and speak EA's documented-by-reversing launcher protocol. Nothing is forged.
|
||||
- **Terms of service.** Both publishers' terms broadly forbid unofficial clients. No bans are known
|
||||
for Linux or alt-launcher users (Maxima, Lutris, unifideck, GameVault). This is the same position as
|
||||
our Epic, GOG and Amazon modules.
|
||||
- **Vendor churn:** juno schema changes (EA already killed the Origin-era integrations once), Demux
|
||||
patch versions, and Connect or EA app updates that break under Proton. Plan for tests on recorded
|
||||
responses, as Epic has.
|
||||
- **Memory.** A resident Connect costs a phone RAM, so reuse #979's close-on-launch policy and zRAM work
|
||||
(#1882). EA's native path avoids the client entirely.
|
||||
- **FEX hardware fingerprint (EA, U1)**, if FEX's CPUID differs from what licences are bound to.
|
||||
- **Kernel anti-cheat:** Javelin, and whatever Ubisoft titles use one. Flag them in the catalog
|
||||
instead of letting launches fail.
|
||||
|
||||
## Sources
|
||||
|
||||
- DroidDeck: #979, #1602, #1610, #1611, #1662, #2087 (Battle.net); #1449, #2036 (Steam Ubisoft/EA
|
||||
installers); #1445, #2050, #2286, #2292, #2295, #2317 (Ubisoft); `docs/development/epic-cleanroom.md`.
|
||||
- Maxima: README, `maxima-lib/src/{core/launch.rs, core/endpoints.rs, ooa/mod.rs, core/auth/*, content/*, lsx/*}`, issues #49, #71.
|
||||
- Optima: README, `TODO-hardening.md`, `src/install.rs`, `src/launch.rs`.
|
||||
- ubi-cli-mvp: README, `docs/optima-comparison.md`, `research.md`, `validation.md`.
|
||||
- unifideck: `docs/feasibility/{README,ea,battlenet}.md`, `docs/ubisoft-store-spec.md`.
|
||||
- [Heroic: Ubisoft Connect on Linux and Mac](https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher/wiki/How-to-install-Ubisoft-Connect-on-Linux-and-Mac).
|
||||
- [GamingOnLinux: EA Javelin job listing mentions Linux and Proton (2026-03)](https://gamingonlinux.com/2026/03/ea-javelin-anticheat-job-listing-mentions-future-support-for-linux-and-proton).
|
||||
- [GamingOnLinux: KYBER goes open source (2026-01)](https://www.gamingonlinux.com/2026/01/star-wars-battlefront-ii-private-server-and-mod-launcher-kyber-goes-open-source).
|
||||
- [PC Gamer: Ubisoft's launcher broke Steam games on Linux and Steam Deck](https://www.pcgamer.com/ubisofts-launcher-broke-steam-games-on-linux-and-steam-deck).
|
||||
@@ -5,6 +5,7 @@ import binascii
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
@@ -276,6 +277,10 @@ def exited(argv, code=0):
|
||||
subprocess.run([EPIC_CLIENT, "exited", "--code", str(code)], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, timeout=10)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
if found is not None and launch_dir and str(found[1].get("store")) == "ea":
|
||||
ask(Path(launch_dir) / "stores", {"op": "ea-exited", "store": "ea", "id": str(found[1].get("id"))}, timeout=1.0)
|
||||
log("ea %s exited" % found[1].get("id"))
|
||||
return 0
|
||||
if found is None or not launch_dir or not cloud_on(found[1]):
|
||||
return 0
|
||||
store, game = str(found[1].get("store")), str(found[1].get("id"))
|
||||
@@ -613,6 +618,344 @@ def apply_gog_registry(folder, compat_data, proton, timeout=90, installation=Non
|
||||
return "import-failed" + note
|
||||
|
||||
|
||||
EA_HARDWARE = ".droiddeck-ea-hw.json"
|
||||
EA_CLIENT = "C:\\Program Files (x86)\\Origin\\Origin.exe"
|
||||
EA_WMI = (
|
||||
("boardManufacturer", "Win32_BaseBoard", "Manufacturer"),
|
||||
("boardSerial", "Win32_BaseBoard", "SerialNumber"),
|
||||
("biosManufacturer", "Win32_BIOS", "Manufacturer"),
|
||||
("biosSerial", "Win32_BIOS", "SerialNumber"),
|
||||
("osInstallDate", "Win32_OperatingSystem", "InstallDate"),
|
||||
("osSerial", "Win32_OperatingSystem", "SerialNumber"),
|
||||
("gpuPnpId", "Win32_VideoController", "PNPDeviceID"),
|
||||
("cpuManufacturer", "Win32_Processor", "Manufacturer"),
|
||||
("processorId", "Win32_Processor", "ProcessorId"),
|
||||
("brand", "Win32_Processor", "Name"),
|
||||
)
|
||||
EA_ROOTS = {"HKLM": "HKEY_LOCAL_MACHINE", "HKCU": "HKEY_CURRENT_USER"}
|
||||
|
||||
|
||||
def decode_output(data):
|
||||
if data[:2] in (b"\xff\xfe", b"\xfe\xff") or (len(data) > 1 and data[1:2] == b"\x00"):
|
||||
return data.decode("utf-16", "replace")
|
||||
return data.decode("utf-8", "replace")
|
||||
|
||||
|
||||
def wmi_value(text):
|
||||
rows = [row.strip() for row in text.replace("\r", "\n").split("\n") if row.strip()]
|
||||
return rows[1] if len(rows) > 1 else ""
|
||||
|
||||
|
||||
CPUID_LEAVES = (0, 1, 0x80000002, 0x80000003, 0x80000004)
|
||||
|
||||
|
||||
def cpuid_exe():
|
||||
"""A 32-bit console program that writes the raw CPUID leaves an x86 game sees under the emulator."""
|
||||
import struct
|
||||
base, rva = 0x400000, 0x1000
|
||||
buf, written = base + rva + 0x180, base + rva + 0x1D0
|
||||
iat = base + rva + 0x138
|
||||
code = b"\x53\x57\xbf" + struct.pack("<I", buf)
|
||||
for leaf in CPUID_LEAVES:
|
||||
code += b"\xb8" + struct.pack("<I", leaf) + b"\x31\xc9\x0f\xa2\x89\x07\x89\x5f\x04\x89\x4f\x08\x89\x57\x0c\x83\xc7\x10"
|
||||
code += b"\x6a\xf5\xff\x15" + struct.pack("<I", iat)
|
||||
code += b"\x6a\x00\x68" + struct.pack("<I", written) + b"\x6a\x50\x68" + struct.pack("<I", buf) + b"\x50\xff\x15" + struct.pack("<I", iat + 4)
|
||||
code += b"\x6a\x00\xff\x15" + struct.pack("<I", iat + 8)
|
||||
section = bytearray(0x200)
|
||||
section[:len(code)] = code
|
||||
names = [(0x148, b"GetStdHandle"), (0x158, b"WriteFile"), (0x164, b"ExitProcess")]
|
||||
struct.pack_into("<IIIII", section, 0x100, rva + 0x128, 0, 0, rva + 0x172, rva + 0x138)
|
||||
for i, (at, name) in enumerate(names):
|
||||
struct.pack_into("<I", section, 0x128 + 4 * i, rva + at)
|
||||
struct.pack_into("<I", section, 0x138 + 4 * i, rva + at)
|
||||
section[at + 2:at + 2 + len(name)] = name
|
||||
section[0x172:0x172 + 12] = b"KERNEL32.dll"
|
||||
header = bytearray(0x200)
|
||||
header[0:2] = b"MZ"
|
||||
struct.pack_into("<I", header, 0x3C, 0x40)
|
||||
header[0x40:0x44] = b"PE\0\0"
|
||||
struct.pack_into("<HHIIIHH", header, 0x44, 0x14C, 1, 0, 0, 0, 0xE0, 0x0103)
|
||||
opt = struct.pack("<HBBIIIIIIIIIHHHHHHIIIIHHIIIIII", 0x10B, 0, 0, 0x200, 0, 0, rva, rva, rva, base, 0x1000, 0x200,
|
||||
4, 0, 0, 0, 4, 0, 0, 0x2000, 0x200, 0, 3, 0, 0x100000, 0x1000, 0x100000, 0x1000, 0, 16)
|
||||
dirs = bytearray(128)
|
||||
struct.pack_into("<II", dirs, 8, rva + 0x100, 40)
|
||||
struct.pack_into("<II", dirs, 96, rva + 0x138, 16)
|
||||
header[0x58:0x58 + 0xE0] = opt + bytes(dirs)
|
||||
header[0x138:0x138 + 40] = struct.pack("<8sIIIIIIHHI", b".text", 0x1000, rva, 0x200, 0x200, 0, 0, 0, 0, 0xE0000060)
|
||||
return bytes(header) + bytes(section)
|
||||
|
||||
|
||||
EA_STUB_DIR = "Program Files (x86)/Origin"
|
||||
EA_STUB = "C:\\Program Files (x86)\\Origin\\Origin.exe"
|
||||
|
||||
|
||||
def origin_stub_exe():
|
||||
"""A 32-bit program that only sleeps: the process older EA SDKs look for while a game runs."""
|
||||
import struct
|
||||
base, rva = 0x400000, 0x1000
|
||||
iat = base + rva + 0x138
|
||||
code = b"\x6a\xff\xff\x15" + struct.pack("<I", iat) + b"\xeb\xf6"
|
||||
section = bytearray(0x200)
|
||||
section[:len(code)] = code
|
||||
struct.pack_into("<IIIII", section, 0x100, rva + 0x128, 0, 0, rva + 0x172, rva + 0x138)
|
||||
struct.pack_into("<I", section, 0x128, rva + 0x148)
|
||||
struct.pack_into("<I", section, 0x138, rva + 0x148)
|
||||
section[0x14A:0x14A + 5] = b"Sleep"
|
||||
section[0x172:0x172 + 12] = b"KERNEL32.dll"
|
||||
header = bytearray(cpuid_exe()[:0x200])
|
||||
struct.pack_into("<H", header, 0x58 + 68, 2)
|
||||
return bytes(header) + bytes(section)
|
||||
|
||||
|
||||
def install_origin_stub(compat_data):
|
||||
if not compat_data:
|
||||
return "no-prefix"
|
||||
target = Path(compat_data) / "pfx/drive_c" / EA_STUB_DIR / "Origin.exe"
|
||||
data = origin_stub_exe()
|
||||
try:
|
||||
if target.is_file() and target.read_bytes() == data:
|
||||
return "ready"
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(data)
|
||||
return "written"
|
||||
except OSError:
|
||||
return "failed"
|
||||
|
||||
|
||||
def cpuid_values(raw):
|
||||
import struct
|
||||
if len(raw) < 80:
|
||||
return {}
|
||||
regs = [struct.unpack_from("<IIII", raw, 16 * i) for i in range(5)]
|
||||
vendor = struct.pack("<III", regs[0][1], regs[0][3], regs[0][2]).split(b"\0")[0].decode("latin-1")
|
||||
brand = b"".join(struct.pack("<IIII", *r) for r in regs[2:]).split(b"\0")[0].decode("latin-1")
|
||||
return {"cpuVendor": vendor, "cpuEax": "%08X" % regs[1][0], "cpuEdx": "%08X" % regs[1][3],
|
||||
"cpuEcx": "%08x" % regs[1][2], "cpuBrand": brand}
|
||||
|
||||
|
||||
EA_PROBE_FIELDS = ("boardManufacturer", "biosManufacturer", "osInstallDate", "osSerial", "volumeSerial", "hostname",
|
||||
"cpuVendor", "cpuEax", "cpuEdx", "cpuEcx")
|
||||
|
||||
|
||||
def probe_complete(values):
|
||||
return all(str(values.get(key) or "").strip() for key in EA_PROBE_FIELDS)
|
||||
|
||||
|
||||
def ea_hardware(compat_data, proton, timeout=240):
|
||||
pfx = Path(compat_data) / "pfx"
|
||||
cache = pfx / EA_HARDWARE
|
||||
try:
|
||||
cached = json.loads(cache.read_text())
|
||||
if isinstance(cached, dict) and probe_complete(cached):
|
||||
return cached, "cached"
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
if not proton or not Path(proton).is_file():
|
||||
return {}, "no-proton"
|
||||
values = {}
|
||||
with tempfile.TemporaryDirectory(prefix="droiddeck-ea-") as tmp:
|
||||
folder = Path(tmp)
|
||||
lines = ["@echo off"]
|
||||
for key, cls, prop in EA_WMI:
|
||||
lines.append('wmic path %s get %s > "%s"' % (cls, prop, windows_path(str(folder / (key + ".txt")))))
|
||||
lines.append('vol C: > "%s"' % windows_path(str(folder / "volume.txt")))
|
||||
lines.append('hostname > "%s"' % windows_path(str(folder / "hostname.txt")))
|
||||
(folder / "cpuid.exe").write_bytes(cpuid_exe())
|
||||
lines.append('"%s" > "%s"' % (windows_path(str(folder / "cpuid.exe")), windows_path(str(folder / "cpuid.bin"))))
|
||||
bat = folder / "probe.bat"
|
||||
bat.write_bytes(("\r\n".join(lines) + "\r\n").encode())
|
||||
try:
|
||||
subprocess.run([proton, "run", "cmd.exe", "/d", "/c", windows_path(str(bat))], stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
return {}, "timeout"
|
||||
except OSError:
|
||||
return {}, "failed"
|
||||
for key, _, _ in EA_WMI:
|
||||
try:
|
||||
values[key] = wmi_value(decode_output((folder / (key + ".txt")).read_bytes()))
|
||||
except OSError:
|
||||
values[key] = ""
|
||||
try:
|
||||
found = re.search(r"([0-9A-Fa-f]{4})-([0-9A-Fa-f]{4})", decode_output((folder / "volume.txt").read_bytes()))
|
||||
values["volumeSerial"] = (found.group(1) + found.group(2)).lower() if found else ""
|
||||
except OSError:
|
||||
values["volumeSerial"] = ""
|
||||
try:
|
||||
values["hostname"] = decode_output((folder / "hostname.txt").read_bytes()).strip().splitlines()[0].strip()
|
||||
except (OSError, IndexError):
|
||||
values["hostname"] = ""
|
||||
try:
|
||||
values.update(cpuid_values((folder / "cpuid.bin").read_bytes()))
|
||||
except OSError:
|
||||
pass
|
||||
if probe_complete(values):
|
||||
try:
|
||||
cache.write_text(json.dumps(values))
|
||||
except OSError:
|
||||
pass
|
||||
return values, "probed"
|
||||
return values, "incomplete"
|
||||
|
||||
|
||||
def ea_registry_entries(folder, sidecar):
|
||||
install = windows_path(str(folder)).rstrip("\\") + "\\"
|
||||
extra = sidecar.get("extra") if isinstance(sidecar.get("extra"), dict) else {}
|
||||
title = str(extra.get("displayName") or sidecar.get("title") or folder.name).strip()
|
||||
locale = str(extra.get("locale") or "en_US")
|
||||
entries = [
|
||||
("HKEY_LOCAL_MACHINE\\SOFTWARE\\Electronic Arts\\EA Desktop", "InstallSuccessful", "true"),
|
||||
("HKEY_LOCAL_MACHINE\\SOFTWARE\\Electronic Arts\\Origin", "InstallSuccessful", "true"),
|
||||
("HKEY_LOCAL_MACHINE\\SOFTWARE\\Electronic Arts\\Origin", "ClientPath", EA_CLIENT),
|
||||
("HKEY_LOCAL_MACHINE\\SOFTWARE\\Origin", "ClientPath", EA_CLIENT),
|
||||
]
|
||||
for content in [c.strip() for c in str(extra.get("contentIds") or extra.get("contentId") or "").split(",")]:
|
||||
if re.fullmatch(r"[A-Za-z0-9._-]{1,64}", content):
|
||||
key = "HKEY_LOCAL_MACHINE\\SOFTWARE\\Origin Games\\" + content
|
||||
entries += [(key, "DisplayName", title), (key, "Install Dir", install), (key, "Locale", locale)]
|
||||
try:
|
||||
keys = json.loads(extra.get("registry") or "[]")
|
||||
except ValueError:
|
||||
keys = []
|
||||
for pair in keys if isinstance(keys, list) else []:
|
||||
if not isinstance(pair, list) or len(pair) != 2:
|
||||
continue
|
||||
key, name = str(pair[0]), str(pair[1])
|
||||
root, _, rest = key.partition("\\")
|
||||
key = EA_ROOTS.get(root.upper(), root) + ("\\" + rest if rest else "")
|
||||
if key.upper().startswith(("HKEY_LOCAL_MACHINE\\", "HKEY_CURRENT_USER\\")) and name and "\n" not in key + name:
|
||||
entries.append((key, name, install))
|
||||
if title:
|
||||
entries.append((key, "DisplayName", title))
|
||||
entries.append((key, "Locale", locale))
|
||||
return entries
|
||||
|
||||
|
||||
def ea_registry(folder, sidecar, compat_data, proton, timeout=90):
|
||||
if not compat_data or not proton or not Path(proton).is_file():
|
||||
return "no-proton"
|
||||
pfx = Path(compat_data) / "pfx"
|
||||
text = "Windows Registry Editor Version 5.00\r\n\r\n" + "".join(
|
||||
'[%s]\r\n"%s"="%s"\r\n\r\n' % (key, reg_escape(name), reg_escape(value)) for key, name, value in ea_registry_entries(folder, sidecar))
|
||||
stamp = hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
marker = pfx / (".droiddeck-ea-registry-" + re.sub(r"[^A-Za-z0-9._-]", "_", str(sidecar.get("id"))))
|
||||
try:
|
||||
if marker.is_file() and marker.read_text() == stamp and (pfx / "system.reg").is_file():
|
||||
return "ready"
|
||||
with tempfile.TemporaryDirectory(prefix="droiddeck-ea-") as tmp:
|
||||
source = Path(tmp) / "registry.reg"
|
||||
source.write_bytes(b"\xff\xfe" + text.encode("utf-16-le"))
|
||||
executable = pfx / "drive_c/windows/system32/reg.exe"
|
||||
for view in ("/reg:32", "/reg:64"):
|
||||
run = subprocess.run([proton, "run", str(executable) if executable.is_file() else "reg.exe", "import", windows_path(str(source)), view],
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=timeout)
|
||||
if run.returncode:
|
||||
return "import-failed"
|
||||
marker.write_text(stamp)
|
||||
return "written"
|
||||
except subprocess.TimeoutExpired:
|
||||
return "timeout"
|
||||
except OSError:
|
||||
return "import-failed"
|
||||
|
||||
|
||||
def find_case(folder, relative):
|
||||
current = Path(folder)
|
||||
for part in [p for p in relative.replace("\\", "/").split("/") if p and p != "."]:
|
||||
exact = current / part
|
||||
if exact.exists():
|
||||
current = exact
|
||||
continue
|
||||
try:
|
||||
current = next(c for c in current.iterdir() if c.name.lower() == part.lower())
|
||||
except (OSError, StopIteration):
|
||||
return None
|
||||
return current if current != Path(folder) else None
|
||||
|
||||
|
||||
def touchup_args(raw, locale, install):
|
||||
raw = raw.replace("{locale}", locale)
|
||||
raw = raw.replace('"{installLocation}"', '"%s"' % install).replace("{installLocation}", install)
|
||||
try:
|
||||
return shlex.split(raw.replace("\\", "\\\\"))
|
||||
except ValueError:
|
||||
return raw.split()
|
||||
|
||||
|
||||
def ea_touchup(folder, sidecar, compat_data, proton, timeout=900):
|
||||
extra = sidecar.get("extra") if isinstance(sidecar.get("extra"), dict) else {}
|
||||
relative = str(extra.get("touchup") or "")
|
||||
if not relative:
|
||||
return "none"
|
||||
exe = find_case(folder, relative)
|
||||
if exe is None or not exe.is_file():
|
||||
return "missing"
|
||||
if not compat_data or not proton or not Path(proton).is_file():
|
||||
return "no-proton"
|
||||
pfx = Path(compat_data) / "pfx"
|
||||
stamp = str(sidecar.get("installVersion") or "") + " " + str(sidecar.get("installedAt") or "")
|
||||
marker = pfx / (".droiddeck-ea-touchup-" + re.sub(r"[^A-Za-z0-9._-]", "_", str(sidecar.get("id"))))
|
||||
try:
|
||||
if marker.is_file() and marker.read_text() == stamp:
|
||||
return "done"
|
||||
except OSError:
|
||||
pass
|
||||
args = touchup_args(str(extra.get("touchupArgs") or ""), str(extra.get("locale") or "en_US"), windows_path(str(folder)).rstrip("\\"))
|
||||
try:
|
||||
run = subprocess.run([proton, "run", str(exe)] + args, cwd=str(exe.parent), stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=timeout)
|
||||
result = "ok" if run.returncode == 0 else "exit-%d" % run.returncode
|
||||
except subprocess.TimeoutExpired:
|
||||
result = "timeout"
|
||||
except OSError:
|
||||
return "failed"
|
||||
try:
|
||||
marker.write_text(stamp)
|
||||
except OSError:
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
def write_licenses(compat_data, licenses):
|
||||
if not compat_data:
|
||||
return 0
|
||||
root = Path(compat_data) / "pfx/drive_c"
|
||||
written = 0
|
||||
for item in licenses or []:
|
||||
relative = str(item.get("path") or "") if isinstance(item, dict) else ""
|
||||
if not relative or relative.startswith("/") or ".." in relative.split("/"):
|
||||
continue
|
||||
target = root / relative
|
||||
try:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(base64.b64decode(item.get("data") or ""))
|
||||
written += 1
|
||||
except (OSError, binascii.Error):
|
||||
pass
|
||||
return written
|
||||
|
||||
|
||||
def prepare_ea(folder, sidecar, compat_data, launch_dir):
|
||||
game = str(sidecar.get("id"))
|
||||
proton = os.environ.get("BL_STORE_PROTON", "")
|
||||
made = create_prefix(compat_data, proton)
|
||||
ready = made in ("present", "created") or (compat_data and (Path(compat_data) / "pfx/user.reg").is_file())
|
||||
stub = install_origin_stub(compat_data) if ready else "no-prefix"
|
||||
hardware, probe = ea_hardware(compat_data, proton) if ready else ({}, "no-prefix")
|
||||
answer = None
|
||||
if launch_dir:
|
||||
answer = ask(Path(launch_dir) / "stores", {"op": "ea-prepare", "store": "ea", "id": game, "hardware": hardware}, timeout=40.0)
|
||||
sidecar = sidecar_at(Path(folder)) or sidecar
|
||||
log("ea %s prefix=%s stub=%s hardware=%s registry=%s touchup=%s" % (
|
||||
game, made, stub, probe, ea_registry(folder, sidecar, compat_data, proton), ea_touchup(folder, sidecar, compat_data, proton)))
|
||||
if answer is None:
|
||||
log("ea %s session=no reason=%s" % (game, "timeout" if launch_dir else "no-channel"))
|
||||
return
|
||||
written = write_licenses(compat_data, answer.get("licenses"))
|
||||
log("ea %s session=%s reason=%s lsx=%s licence=%s files=%d" % (
|
||||
game, "yes" if answer.get("ok") else "no", answer.get("reason", ""), answer.get("lsx"), answer.get("license", ""), written))
|
||||
|
||||
|
||||
def gog_support_actions(folder):
|
||||
found = []
|
||||
for script in sorted(Path(folder).glob(GOG_SCRIPTS)):
|
||||
@@ -808,6 +1151,9 @@ def main(argv):
|
||||
got = bool(answer.get("code"))
|
||||
log("epic launch id=%s code=%s reason=%s" % (game, "yes" if got else "no", answer.get("reason", "")))
|
||||
log("epic prefix=%s %s" % (prefix_id or "?", provision_prefix(compat_data)))
|
||||
if store == "ea":
|
||||
prepare_ea(folder, sidecar, compat_data, launch_dir)
|
||||
return STORE_GAME
|
||||
if not cloud_on(sidecar):
|
||||
if store in CLOUD_STORES:
|
||||
log("cloud %s %s down result=skipped reason=off" % (store, game))
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import base64
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
BIN = Path(__file__).resolve().parents[1] / 'linuxfs/overlay/usr/local/bin'
|
||||
loader = importlib.machinery.SourceFileLoader('droiddeck_store_launch_ea', str(BIN / 'droiddeck-store-launch'))
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
store = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(store)
|
||||
|
||||
|
||||
class EaHook(unittest.TestCase):
|
||||
def test_wmi_output_reads_the_value_under_the_header(self):
|
||||
text = 'Manufacturer \r\r\nThe Wine Project \r\r\n\r\r\n'
|
||||
self.assertEqual(store.wmi_value(text), 'The Wine Project')
|
||||
self.assertEqual(store.wmi_value(store.decode_output('Name\r\nFEX CPU\r\n'.encode('utf-16'))), 'FEX CPU')
|
||||
self.assertEqual(store.wmi_value(''), '')
|
||||
|
||||
def test_registry_points_the_game_keys_at_the_install_folder(self):
|
||||
sidecar = {'id': 'Origin.OFR.50.0001', 'title': 'Mass Effect 3', 'extra': {'registry': json.dumps([
|
||||
[r'HKEY_LOCAL_MACHINE\SOFTWARE\EA Games\Game', 'Install Dir'],
|
||||
[r'HKLM\SOFTWARE\Respawn\Titanfall2', 'Install Dir'],
|
||||
[r'HKEY_CLASSES_ROOT\x', 'y'],
|
||||
])}}
|
||||
entries = store.ea_registry_entries(Path('/root/Games/Stores/EA/Game'), sidecar)
|
||||
self.assertIn((r'HKEY_LOCAL_MACHINE\SOFTWARE\Origin', 'ClientPath', store.EA_CLIENT), entries)
|
||||
game = [e for e in entries if e[1] == 'Install Dir']
|
||||
self.assertEqual([e[0] for e in game], [r'HKEY_LOCAL_MACHINE\SOFTWARE\EA Games\Game', r'HKEY_LOCAL_MACHINE\SOFTWARE\Respawn\Titanfall2'])
|
||||
self.assertEqual(game[0][2], 'Z:' + '\\'.join(['', 'root', 'Games', 'Stores', 'EA', 'Game', '']))
|
||||
self.assertIn((r'HKEY_LOCAL_MACHINE\SOFTWARE\EA Games\Game', 'DisplayName', 'Mass Effect 3'), entries)
|
||||
self.assertIn((r'HKEY_LOCAL_MACHINE\SOFTWARE\Respawn\Titanfall2', 'DisplayName', 'Mass Effect 3'), entries)
|
||||
|
||||
def test_legacy_games_are_registered_by_content_id(self):
|
||||
sidecar = {'id': 'Origin.OFR.50.0005237', 'title': 'Mass Effect 3 (2012)',
|
||||
'extra': {'contentIds': '71073,71183, bad id', 'displayName': 'Mass Effect 3', 'locale': 'fr_FR'}}
|
||||
entries = store.ea_registry_entries(Path('/root/Games/ME3'), sidecar)
|
||||
key = r'HKEY_LOCAL_MACHINE\SOFTWARE\Origin Games' + '\\'
|
||||
self.assertIn((key + '71073', 'DisplayName', 'Mass Effect 3'), entries)
|
||||
self.assertIn((key + '71183', 'Locale', 'fr_FR'), entries)
|
||||
self.assertFalse(any('bad id' in e[0] for e in entries))
|
||||
|
||||
def test_cpuid_probe_is_a_32_bit_console_program(self):
|
||||
import struct
|
||||
exe = store.cpuid_exe()
|
||||
self.assertEqual(exe[:2], b'MZ')
|
||||
self.assertEqual(exe[0x40:0x44], b'PE\0\0')
|
||||
self.assertEqual(struct.unpack_from('<H', exe, 0x44)[0], 0x14C)
|
||||
self.assertEqual(struct.unpack_from('<H', exe, 0x58)[0], 0x10B)
|
||||
self.assertEqual(struct.unpack_from('<H', exe, 0x58 + 68)[0], 3)
|
||||
self.assertIn(b'KERNEL32.dll', exe)
|
||||
self.assertEqual(len(exe), 0x400)
|
||||
|
||||
def test_origin_stub_is_a_windowless_program_installed_once(self):
|
||||
import struct
|
||||
exe = store.origin_stub_exe()
|
||||
self.assertEqual(exe[:2], b'MZ')
|
||||
self.assertEqual(struct.unpack_from('<H', exe, 0x58 + 68)[0], 2)
|
||||
self.assertIn(b'Sleep', exe)
|
||||
self.assertEqual(exe[0x208:0x20A], b'\xeb\xf6')
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
self.assertEqual(store.install_origin_stub(tmp), 'written')
|
||||
self.assertEqual(store.install_origin_stub(tmp), 'ready')
|
||||
self.assertTrue((Path(tmp) / 'pfx/drive_c/Program Files (x86)/Origin/Origin.exe').is_file())
|
||||
self.assertTrue(store.EA_CLIENT.endswith('Origin.exe'))
|
||||
|
||||
def test_cpuid_output_is_decoded_as_the_game_sees_it(self):
|
||||
raw = bytes.fromhex('1600000047656e756e74656c696e654961060a00000805000f23d882fffb8b27436f727465782d4135323000000000000000000000000000000000000000000000000000000000000000000000000000')
|
||||
self.assertEqual(store.cpuid_values(raw), {'cpuVendor': 'GenuineIntel', 'cpuEax': '000A0661', 'cpuEdx': '278BFBFF',
|
||||
'cpuEcx': '82d8230f', 'cpuBrand': 'Cortex-A520'})
|
||||
self.assertEqual(store.cpuid_values(b''), {})
|
||||
|
||||
def test_touchup_arguments_fill_locale_and_folder(self):
|
||||
install = r'Z:\root\My Game'
|
||||
args = store.touchup_args('-locale {locale} -installPath "{installLocation}" /silent', 'fr_FR', install)
|
||||
self.assertEqual(args, ['-locale', 'fr_FR', '-installPath', install, '/silent'])
|
||||
|
||||
def test_licences_stay_inside_the_prefix(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
data = base64.b64encode(b'licence').decode()
|
||||
written = store.write_licenses(tmp, [
|
||||
{'path': 'ProgramData/Electronic Arts/EA Services/License/X.dlf', 'data': data},
|
||||
{'path': '../escape.dlf', 'data': data},
|
||||
{'path': '/abs.dlf', 'data': data},
|
||||
])
|
||||
self.assertEqual(written, 1)
|
||||
self.assertEqual((Path(tmp) / 'pfx/drive_c/ProgramData/Electronic Arts/EA Services/License/X.dlf').read_bytes(), b'licence')
|
||||
|
||||
def test_prepare_asks_the_app_and_writes_the_licence(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
compat = Path(tmp) / 'compat'
|
||||
(compat / 'pfx').mkdir(parents=True)
|
||||
(compat / 'pfx/user.reg').write_text('')
|
||||
probe = {key: 'x' for key in store.EA_PROBE_FIELDS}
|
||||
(compat / 'pfx' / store.EA_HARDWARE).write_text(json.dumps(probe))
|
||||
folder = Path(tmp) / 'game'
|
||||
folder.mkdir()
|
||||
asked = {}
|
||||
|
||||
def fake_ask(channel, request, timeout=5.0):
|
||||
asked.update(request)
|
||||
return {'ok': True, 'lsx': True, 'license': 'ok v4', 'licenses': [{'path': 'ProgramData/L/x.dlf', 'data': base64.b64encode(b'z').decode()}]}
|
||||
|
||||
with patch.object(store, 'ask', fake_ask), patch.object(store, 'ea_registry', lambda *a, **k: 'ready'), \
|
||||
patch.object(store, 'ea_touchup', lambda *a, **k: 'none'), patch.dict('os.environ', {'BL_STORE_PROTON': ''}):
|
||||
store.prepare_ea(folder, {'id': 'Origin.OFR.1'}, str(compat), str(Path(tmp) / 'launch'))
|
||||
self.assertEqual(asked['op'], 'ea-prepare')
|
||||
self.assertEqual(asked['hardware'], probe)
|
||||
self.assertEqual((compat / 'pfx/drive_c/ProgramData/L/x.dlf').read_bytes(), b'z')
|
||||
|
||||
def test_an_incomplete_cached_probe_is_probed_again(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
pfx = Path(tmp) / 'pfx'
|
||||
pfx.mkdir()
|
||||
partial = {key: 'x' for key in store.EA_PROBE_FIELDS if key != 'osInstallDate'}
|
||||
(pfx / store.EA_HARDWARE).write_text(json.dumps(partial))
|
||||
self.assertFalse(store.probe_complete(partial))
|
||||
self.assertEqual(store.ea_hardware(tmp, ''), ({}, 'no-proton'))
|
||||
whole = dict(partial, osInstallDate='20261108')
|
||||
(pfx / store.EA_HARDWARE).write_text(json.dumps(whole))
|
||||
self.assertEqual(store.ea_hardware(tmp, ''), (whole, 'cached'))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user