mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-12 11:31:08 +00:00
fix: Xbox search results open with their metadata and Game Pass installs (#1279)
This commit is contained in:
1 parent
af37432251
commit
b68bb054a9
9 files changed
+145
-17
No files matched your search
@@ -104,7 +104,6 @@ class LinuxNetworkLinkComponent(
|
||||
synchronized(lock) { writeNetworkState(currentProperties, currentCapabilities) }
|
||||
}
|
||||
}
|
||||
wifiReceiver = receiver
|
||||
appContext.registerReceiver(receiver, IntentFilter().apply {
|
||||
addAction(WifiManager.SCAN_RESULTS_AVAILABLE_ACTION)
|
||||
addAction(WifiManager.RSSI_CHANGED_ACTION)
|
||||
@@ -112,6 +111,7 @@ class LinuxNetworkLinkComponent(
|
||||
addAction(WifiManager.NETWORK_STATE_CHANGED_ACTION)
|
||||
addAction(LocationManager.MODE_CHANGED_ACTION)
|
||||
})
|
||||
wifiReceiver = receiver
|
||||
scanRequestStamp = File(rootDir, "etc/droiddeck-wifi-scan-request").lastModified()
|
||||
scanHandler.post(scanRequests)
|
||||
requestWifiScan()
|
||||
@@ -119,7 +119,13 @@ class LinuxNetworkLinkComponent(
|
||||
|
||||
override fun stop() {
|
||||
scanHandler.removeCallbacks(scanRequests)
|
||||
wifiReceiver?.let { appContext.unregisterReceiver(it) }
|
||||
wifiReceiver?.let { receiver ->
|
||||
try {
|
||||
appContext.unregisterReceiver(receiver)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Log.w(TAG, "Wi-Fi receiver was already gone", e)
|
||||
}
|
||||
}
|
||||
wifiReceiver = null
|
||||
val registered = synchronized(lock) { callback.also { callback = null } } ?: return
|
||||
try {
|
||||
|
||||
@@ -22,8 +22,11 @@ data class CatalogItem(
|
||||
val extra: Map<String, String> = emptyMap(),
|
||||
val mature: Boolean = false,
|
||||
val subscription: Boolean = false,
|
||||
val includedWith: String = "",
|
||||
) {
|
||||
val accessible: Boolean get() = owned || subscription
|
||||
/** Licensed now, or a supported package the account's subscription can install. */
|
||||
val installable: Boolean get() = accessible || includedWith.isNotBlank()
|
||||
val isDiscounted: Boolean get() = discountPercent > 0 && originalPrice.isNotBlank()
|
||||
val key: String get() = "${store.id}:$id"
|
||||
}
|
||||
@@ -86,6 +89,18 @@ internal fun retainCatalogOrder(items: List<CatalogItem>, previous: List<Catalog
|
||||
return previous.map { byKey.getValue(it.key) }
|
||||
}
|
||||
|
||||
/** A detail page opens for search results too; the library wins when it already knows the product. */
|
||||
fun detailCatalogItem(
|
||||
store: Store,
|
||||
id: String,
|
||||
library: List<CatalogItem>,
|
||||
found: List<CatalogItem>,
|
||||
shelves: StoreShelves?,
|
||||
): CatalogItem? =
|
||||
library.firstOrNull { it.store == store && it.id == id }
|
||||
?: found.firstOrNull { it.store == store && it.id == id }
|
||||
?: shelves?.all?.firstOrNull { it.store == store && it.id == id }
|
||||
|
||||
fun mergeItems(old: List<CatalogItem>?, new: List<CatalogItem>): List<CatalogItem> {
|
||||
if (old.isNullOrEmpty()) return new
|
||||
val before = old.associateBy { it.key }
|
||||
|
||||
@@ -128,6 +128,10 @@ object StoresState {
|
||||
|
||||
fun installedGame(store: Store, id: String): InstalledStoreGame? = installed.firstOrNull { it.sidecar.store == store && it.sidecar.id == id }
|
||||
|
||||
fun detailItem(store: Store, id: String): CatalogItem? = detailCatalogItem(
|
||||
store, id, library[store].orEmpty(), found[store].orEmpty(), shelves[store],
|
||||
)
|
||||
|
||||
fun open(context: Context, store: Store, force: Boolean = false) {
|
||||
val backend = backends[store] ?: return
|
||||
val app = context.applicationContext
|
||||
|
||||
@@ -10,6 +10,14 @@ import java.time.Instant
|
||||
internal object XboxCatalog {
|
||||
private val productId = Regex("[A-Z0-9]{12}")
|
||||
private val plans = setOf("CFQ7TTC0KGQ8", "CFQ7TTC0KHS0", "CFQ7TTC0P85B", "CFQ7TTC0K5DJ", "CFQ7TTC0K6L8", "CFQ7TTC10QFD")
|
||||
/** Plans that include PC games; the retired console-only plan must not offer a Windows install. */
|
||||
private val pcPlans = mapOf(
|
||||
"CFQ7TTC0KGQ8" to "PC Game Pass",
|
||||
"CFQ7TTC0KHS0" to "Game Pass Ultimate",
|
||||
"CFQ7TTC0P85B" to "Xbox Game Pass Premium",
|
||||
"CFQ7TTC0K5DJ" to "Xbox Game Pass Essential",
|
||||
"CFQ7TTC10QFD" to "Xbox Game Pass (Starter Edition)",
|
||||
)
|
||||
|
||||
fun access(json: JSONObject, account: String, market: String, now: Instant = Instant.now(), report: (String) -> Unit = {}): Map<String, String> {
|
||||
require(json.getInt("schema") == 1 && json.getString("account") == account && json.getString("market") == market)
|
||||
@@ -53,7 +61,7 @@ internal object XboxCatalog {
|
||||
val parent = row.optString("satisfiedByProductId").takeUnless { it.isBlank() || it == "null" }
|
||||
if (parent == null) return if (id in plans) null else "owned"
|
||||
if (parent in blocked) return null
|
||||
if (parent in plans) return if (activePlan(parent)) "gamepass" else null
|
||||
if (parent in plans) return if (parent in pcPlans && activePlan(parent)) "gamepass" else null
|
||||
return kind(parent, visited + id)
|
||||
}
|
||||
val result = rows.keys.mapNotNull { id -> kind(id)?.let { id to it } }.toMap()
|
||||
@@ -110,9 +118,12 @@ internal object XboxCatalog {
|
||||
}
|
||||
}
|
||||
val kind = access[id]
|
||||
val includedWith = objects(localized.optJSONObject("EligibilityProperties")?.optJSONArray("Affirmations"))
|
||||
.mapNotNull { pcPlans[it.optString("AffirmationProductId")] }
|
||||
.firstOrNull().orEmpty()
|
||||
return CatalogItem(Store.XBOX, id, title, image("SuperHeroArt", "Hero", "BoxArt"),
|
||||
tallImageUrl = image("TitledPoster", "Poster", "BoxArt"),
|
||||
owned = kind == "owned", subscription = kind == "gamepass",
|
||||
owned = kind == "owned", subscription = kind == "gamepass", includedWith = includedWith,
|
||||
storeUrl = "https://www.xbox.com/games/store/$id",
|
||||
publisher = localized.optString("PublisherName"), developer = localized.optString("DeveloperName"),
|
||||
description = localized.optString("ProductDescription"),
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.droiddeck.launcher.stores.xbox
|
||||
import android.content.Context
|
||||
import android.os.Environment
|
||||
import com.droiddeck.launcher.core.AddonPack
|
||||
import com.droiddeck.launcher.runtime.LinuxNetworkLinkComponent
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import org.json.JSONObject
|
||||
@@ -14,7 +15,8 @@ internal object Xodus {
|
||||
const val DIR = "/usr/local/lib/droiddeck-xbox"
|
||||
fun market(): String = java.util.Locale.getDefault().country.uppercase().takeIf { it.matches(Regex("[A-Z]{2}")) } ?: "US"
|
||||
|
||||
class Child(val process: Process, private val credentials: XboxCredentials, private val runtimeDirectory: File? = null, private val launchDirectory: File? = null) : Closeable {
|
||||
class Child(val process: Process, private val credentials: XboxCredentials, private val runtimeDirectory: File? = null, private val launchDirectory: File? = null,
|
||||
private val networkLink: LinuxNetworkLinkComponent) : Closeable {
|
||||
@Synchronized fun send(frame: JSONObject) {
|
||||
process.outputStream.write((frame.toString() + "\n").toByteArray())
|
||||
process.outputStream.flush()
|
||||
@@ -34,6 +36,7 @@ internal object Xodus {
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
networkLink.stop()
|
||||
runtimeDirectory?.let { directory ->
|
||||
File(directory, "xodus.sock").delete()
|
||||
directory.delete()
|
||||
@@ -69,8 +72,12 @@ internal object Xodus {
|
||||
val pack = stage(app)
|
||||
val binary = "$DIR/${if (service) "xodus-service" else "xodus-cli"}"
|
||||
check(File(LinuxRuntime.rootDir(app), binary.removePrefix("/")).isFile) { "The Xbox backend is missing. Update the Linux runtime." }
|
||||
val networkLink = LinuxNetworkLinkComponent(app, LinuxRuntime.rootDir(app))
|
||||
val credentials = XboxCredentials(app, credentialGeneration)
|
||||
try {
|
||||
networkLink.attach(app)
|
||||
networkLink.publish()
|
||||
networkLink.start()
|
||||
val runtime = (runtimeDirectory ?: File(app.filesDir, "xbox-runtime")).apply { mkdirs() }
|
||||
val command = LinuxRuntime.command(app, null, null, Environment.getExternalStorageDirectory(), listOf(
|
||||
"/usr/bin/env", "HOME=/root", "XODUS_LOG=off", "RUST_LOG=off",
|
||||
@@ -92,8 +99,12 @@ internal object Xodus {
|
||||
}
|
||||
val process = builder.start()
|
||||
credentials.bind(process, File(LinuxRuntime.rootDir(app), binary.removePrefix("/")), digest.digest())
|
||||
return Child(process, credentials, runtimeDirectory, launchDirectory)
|
||||
} catch (e: Exception) { credentials.close(); throw e }
|
||||
return Child(process, credentials, runtimeDirectory, launchDirectory, networkLink)
|
||||
} catch (e: Exception) {
|
||||
runCatching { networkLink.stop() }
|
||||
credentials.close()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
fun run(app: Context, args: List<String>, timeout: Long = 90, credentialGeneration: String? = null): JSONObject {
|
||||
|
||||
@@ -51,7 +51,6 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.stores.CatalogItem
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreAchievements
|
||||
import com.droiddeck.launcher.stores.StoresState
|
||||
@@ -68,7 +67,7 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro
|
||||
val narrow = LocalNarrowPane.current
|
||||
val stacked = narrow || LocalConfiguration.current.screenWidthDp < 700
|
||||
val id = key.substringAfter(':')
|
||||
val item = (StoresState.library[store].orEmpty() + StoresState.shelves[store]?.all.orEmpty()).firstOrNull { it.id == id }
|
||||
val item = StoresState.detailItem(store, id)
|
||||
val installed = StoresState.installedGame(store, id)
|
||||
val rawDownload = StoresState.download("${store.id}:$id")
|
||||
val download = rawDownload?.takeIf { it.isActive }
|
||||
@@ -93,11 +92,14 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro
|
||||
HeroScores(scores, Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = 18.dp))
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.align(Alignment.BottomStart).padding(horizontal = 22.dp, vertical = 18.dp)) {
|
||||
val passName = item?.includedWith.orEmpty()
|
||||
val state = when {
|
||||
installed != null -> stringResource(R.string.stores_eyebrow_installed) + " · " + com.droiddeck.launcher.stores.StoreInstallRoot.labelFor(ctx, installed.folder)
|
||||
download != null && download.location.isNotBlank() -> stringResource(R.string.stores_eyebrow_library) + " · " + download.location
|
||||
failedDownload != null && failedDownload.location.isNotBlank() -> stringResource(R.string.stores_eyebrow_library) + " · " + failedDownload.location
|
||||
item?.subscription == true -> "PC Game Pass"
|
||||
item?.owned == true -> stringResource(R.string.stores_eyebrow_library)
|
||||
item?.subscription == true -> passName.ifBlank { "PC Game Pass" }
|
||||
passName.isNotBlank() -> "Available with $passName"
|
||||
item?.accessible == true -> stringResource(R.string.stores_eyebrow_library)
|
||||
else -> stringResource(R.string.stores_eyebrow_store)
|
||||
}
|
||||
@@ -153,14 +155,25 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro
|
||||
com.droiddeck.launcher.stores.download.DownloadQueue.clear(ctx, failedDownload.key)
|
||||
}
|
||||
}
|
||||
item?.accessible == true && StoresState.isUnfinished(item) -> {
|
||||
item?.installable == true && StoresState.isUnfinished(item) -> {
|
||||
InstallButton(stringResource(R.string.stores_resume_install), leadOn) { StoresState.requestInstall(ctx, item) }
|
||||
ConfirmButton(stringResource(R.string.stores_dl_clear), stringResource(R.string.stores_dl_cancel_confirm), compact = true) { StoresState.clearUnfinished(ctx, item) }
|
||||
}
|
||||
item?.accessible == true -> InstallButton(
|
||||
if (item.sizeBytes > 0) stringResource(R.string.stores_install_size, formatBytes(item.sizeBytes)) else stringResource(R.string.stores_install),
|
||||
leadOn,
|
||||
) { StoresState.requestInstall(ctx, item) }
|
||||
item?.installable == true -> {
|
||||
InstallButton(
|
||||
if (item.sizeBytes > 0) stringResource(R.string.stores_install_size, formatBytes(item.sizeBytes)) else stringResource(R.string.stores_install),
|
||||
leadOn,
|
||||
) { StoresState.requestInstall(ctx, item) }
|
||||
if (!item.accessible) {
|
||||
SecondaryButton(
|
||||
when {
|
||||
item.isFree -> stringResource(R.string.stores_get_free)
|
||||
item.hasPrice && item.finalPrice.isNotBlank() -> stringResource(R.string.stores_buy_price, item.finalPrice)
|
||||
else -> stringResource(R.string.stores_view_on, store.shortLabel)
|
||||
},
|
||||
) { openStoreUrl(ctx, item) }
|
||||
}
|
||||
}
|
||||
item != null && item.isFree -> PrimaryButton(stringResource(R.string.stores_get_free), main = true, modifier = leadOn) { openStoreUrl(ctx, item) }
|
||||
item != null -> PrimaryButton(if (item.hasPrice && item.finalPrice.isNotBlank()) stringResource(R.string.stores_buy_price, item.finalPrice) else stringResource(R.string.stores_view_on, store.shortLabel), main = true, modifier = leadOn) { openStoreUrl(ctx, item) }
|
||||
}
|
||||
@@ -171,7 +184,7 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro
|
||||
}
|
||||
if (StoresMotion.heroLands) hero() else Rise(1) { hero() }
|
||||
if (scores != null) Rise(2) {
|
||||
val listed = StoresState.shelves[store]?.all.orEmpty().firstOrNull { it.id == id }
|
||||
val listed = (StoresState.found[store].orEmpty() + StoresState.shelves[store]?.all.orEmpty()).firstOrNull { it.id == id }
|
||||
val developer = item?.developer?.ifBlank { null } ?: listed?.developer.orEmpty()
|
||||
val publisher = item?.publisher?.ifBlank { null } ?: listed?.publisher.orEmpty()
|
||||
val releaseDate = item?.releaseDate?.ifBlank { null } ?: listed?.releaseDate.orEmpty()
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.droiddeck.launcher.stores
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class DetailCatalogItemTest {
|
||||
private fun item(store: Store, id: String, title: String) = CatalogItem(store, id, title, imageUrl = null)
|
||||
private val shelves = StoreShelves(trending = listOf(item(Store.XBOX, "9SHELF000001", "Shelf game")))
|
||||
|
||||
@Test fun aSearchOnlyResultKeepsItsMetadataOnTheDetailPage() {
|
||||
val found = listOf(item(Store.XBOX, "9SEARCH00001", "Echo Weaver"))
|
||||
assertEquals("Echo Weaver", detailCatalogItem(Store.XBOX, "9SEARCH00001", emptyList(), found, shelves)?.title)
|
||||
}
|
||||
|
||||
@Test fun theLibraryWinsOverSearchAndShelvesForTheSameProduct() {
|
||||
val library = listOf(item(Store.XBOX, "9SEARCH00001", "Echo Weaver").copy(owned = true, sizeBytes = 42))
|
||||
val found = listOf(item(Store.XBOX, "9SEARCH00001", "Echo Weaver").copy(owned = false))
|
||||
val resolved = detailCatalogItem(Store.XBOX, "9SEARCH00001", library, found, shelves)!!
|
||||
assertEquals(true, resolved.owned)
|
||||
assertEquals(42L, resolved.sizeBytes)
|
||||
}
|
||||
|
||||
@Test fun shelvesRemainAFallbackAndTheItemStaysInItsStore() {
|
||||
assertEquals("Shelf game", detailCatalogItem(Store.XBOX, "9SHELF000001", emptyList(), emptyList(), shelves)?.title)
|
||||
assertNull(detailCatalogItem(Store.GOG, "9SEARCH00001", emptyList(), listOf(item(Store.XBOX, "9SEARCH00001", "Echo Weaver")), shelves))
|
||||
assertNull(detailCatalogItem(Store.XBOX, "9UNKNOWN0001", emptyList(), emptyList(), shelves))
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ class XboxCatalogTest {
|
||||
private fun envelope(vararg rows: JSONObject) = JSONObject().put("schema", 1).put("account", "test@example.test")
|
||||
.put("market", "US").put("checkedAt", now.toString()).put("entitlements", JSONArray(rows.toList()))
|
||||
private fun access(vararg rows: JSONObject) = XboxCatalog.access(envelope(*rows), "test@example.test", "US", now)
|
||||
private fun membership() = JSONObject().put("productId", plan).put("status", "Active").put("endDate", "2099-01-01T00:00:00Z")
|
||||
private fun membership(id: String = plan) = JSONObject().put("productId", id).put("status", "Active").put("endDate", "2099-01-01T00:00:00Z")
|
||||
private fun withMembership(json: JSONObject, vararg plans: JSONObject) = json.put("subscriptions", JSONArray(plans.toList()))
|
||||
|
||||
@Test fun separateMembershipRestoresSubscriptionChildrenWithoutInventingOwnership() {
|
||||
@@ -83,6 +83,16 @@ class XboxCatalogTest {
|
||||
assertEquals(mapOf(game to "gamepass", parent to "gamepass"), access(row(game, parent), row(parent, plan), row(plan)))
|
||||
}
|
||||
|
||||
@Test fun onlyPcPlansGrantSubscriptionAccess() {
|
||||
for (pc in listOf("CFQ7TTC0KGQ8", "CFQ7TTC0KHS0", "CFQ7TTC0P85B", "CFQ7TTC0K5DJ", "CFQ7TTC10QFD")) {
|
||||
assertEquals(mapOf(game to "gamepass"), access(row(game, pc), row(pc)))
|
||||
}
|
||||
val console = "CFQ7TTC0K6L8"
|
||||
assertTrue(access(row(game, console), row(console)).isEmpty())
|
||||
assertTrue(XboxCatalog.access(withMembership(envelope(row(game, console)), membership(console)),
|
||||
"test@example.test", "US", now).isEmpty())
|
||||
}
|
||||
|
||||
@Test fun backendRfc3339OffsetsAndNanosecondsAreAccepted() {
|
||||
val checked = "2026-10-19T19:02:38.173609086+00:00"
|
||||
val response = envelope(row(game).put("endDate", "2099-01-01T01:00:00.000000001+01:00"))
|
||||
@@ -120,6 +130,29 @@ class XboxCatalogTest {
|
||||
assertFalse(card.owned); assertTrue(card.subscription)
|
||||
}
|
||||
|
||||
@Test fun pcSubscriptionAffirmationsOfferAnInstallWithoutClaimingOwnership() {
|
||||
fun offer(vararg affirmations: String) = JSONObject().put("ProductId", game).put("ProductType", "Game")
|
||||
.put("LocalizedProperties", JSONArray().put(JSONObject().put("ProductTitle", "A Pass game")
|
||||
.put("EligibilityProperties", JSONObject().put("Affirmations", JSONArray(affirmations.map {
|
||||
JSONObject().put("AffirmationProductId", it)
|
||||
})))))
|
||||
.put("DisplaySkuAvailabilities", JSONArray().put(JSONObject().put("Sku", JSONObject().put("Properties", JSONObject()
|
||||
.put("Packages", JSONArray().put(JSONObject().put("PackageFormat", "MSIXVC").put("PlatformDependencies",
|
||||
JSONArray().put(JSONObject().put("PlatformName", "Windows.Desktop")))))))))
|
||||
|
||||
val pc = XboxCatalog.product(offer("CFQ7TTC0KGQ8"), emptyMap())!!
|
||||
assertEquals("PC Game Pass", pc.includedWith)
|
||||
assertFalse(pc.accessible); assertTrue(pc.installable)
|
||||
|
||||
assertEquals("Game Pass Ultimate", XboxCatalog.product(offer("CFQ7TTC0KHS0"), emptyMap())!!.includedWith)
|
||||
assertEquals("Xbox Game Pass Premium", XboxCatalog.product(offer("CFQ7TTC0P85B"), emptyMap())!!.includedWith)
|
||||
assertEquals("Xbox Game Pass Essential", XboxCatalog.product(offer("CFQ7TTC0K5DJ"), emptyMap())!!.includedWith)
|
||||
assertEquals("Xbox Game Pass (Starter Edition)", XboxCatalog.product(offer("CFQ7TTC10QFD"), emptyMap())!!.includedWith)
|
||||
val console = XboxCatalog.product(offer("CFQ7TTC0K6L8"), emptyMap())!!
|
||||
assertEquals("", console.includedWith); assertFalse(console.installable)
|
||||
assertFalse(XboxCatalog.product(offer(), emptyMap())!!.installable)
|
||||
}
|
||||
|
||||
@Test fun consoleAndUwpOffersAreFilteredOut() {
|
||||
fun product(platform: String, format: String) = JSONObject().put("ProductId", game).put("ProductType", "Game")
|
||||
.put("LocalizedProperties", JSONArray().put(JSONObject().put("ProductTitle", "A PC game")))
|
||||
|
||||
@@ -18,6 +18,12 @@ not Xbox console emulation or UWP/APPX packages.
|
||||
scoped to account and market; on later starts, snapshots older than 15 minutes
|
||||
refresh quietly in the background. Refresh checks immediately.
|
||||
Downloading and launching still perform Microsoft licensing.
|
||||
A detail page can be opened from a store search result that is not in the
|
||||
library; it keeps the catalog metadata and, when the product's eligibility
|
||||
affirmations name a plan that includes PC games (PC Game Pass, Ultimate,
|
||||
Premium, Essential or Starter Edition), offers Install even before the title
|
||||
is in the entitlement list. The retired console-only plan does not offer a
|
||||
Windows install, and Microsoft licensing still decides at download time.
|
||||
3. Start with a small licensed title such as LIMBO. Search `LIMBO` if needed;
|
||||
the verified US Product ID is `9PNG83MDD6SH`. Install through the normal store
|
||||
download queue. If an offer contains several PC base packages, choose the
|
||||
|
||||
Reference in new issue
Block a user